54 Commits

Author SHA1 Message Date
f822e198ea Update version code. 2025-07-22 17:55:51 +08:00
7035f11eb5 Add optional image parameter to showInputDialog for captcha support. Close #422 2025-07-22 17:51:40 +08:00
f2f5a4f573 Convert between Simplified Chinese and Traditional Chinese when searching favorites. Close #438 2025-07-20 18:52:05 +08:00
2acf234f7d Fix response handling for unhandled method calls in flutter_window.cpp 2025-07-20 18:47:49 +08:00
9ed8f351c7 Add heartbeat monitoring in release builds. 2025-07-20 18:45:42 +08:00
7c35dc7cf7 Update doc. 2025-07-20 16:39:21 +08:00
ccb03343f4 Fix the issue where the toolbar can not be open when chapter data loading failed. Close #415 2025-07-13 20:22:56 +08:00
角砂糖
b9817ec030 Fix page calculation logic && trigger recalculation on orientation change (#428) 2025-06-26 19:55:21 +08:00
角砂糖
5ebb554e54 Add an option to filter logs by level (#427) 2025-06-26 19:55:07 +08:00
23ee79fe9d Set high refresh rate on Android. 2025-06-23 19:39:47 +08:00
nyne
85baac657a Merge pull request #421 from lings03/local
Allow user to keep favorite and history when delete local comic. Close #420
2025-06-23 19:06:34 +08:00
nyne
cceca6b96f Merge branch 'master' into local 2025-06-23 19:04:20 +08:00
角砂糖
b5b0dc85e3 Show group in last read and history when group existing. (#419) 2025-06-23 19:03:24 +08:00
nyne
50044c4372 Merge pull request #418 from lings03/reverse
Add a option to reverse the default chapter order. Close #414
2025-06-23 19:02:52 +08:00
nyne
5fd7f1b880 Merge branch 'master' into reverse 2025-06-23 19:00:48 +08:00
角砂糖
058fde3f5a Add a button to show system status bar (#417) 2025-06-23 19:00:04 +08:00
角砂糖
a2d46123dd Add missing translation
debug时发现之前少了这一句翻译,也不至于提个pr,顺便加在这里吧
2025-06-22 20:07:15 +08:00
角砂糖
01acc4f9de Allow user to keep favorite and history when delete local comic. Close #420 2025-06-22 19:50:38 +08:00
角砂糖
856aae0769 Add a option to reverse the default chapter order. Close #414 2025-06-22 00:29:49 +08:00
nyne
8eda8adcc8 Merge pull request #410 from venera-app/v1.4.5-dev
V1.4.5
2025-06-18 16:52:59 +08:00
defd4b8624 Update version code. 2025-06-18 16:39:02 +08:00
b2a164e066 Remove the config file repository url from app. 2025-06-18 16:34:49 +08:00
a46ceebf19 Fixed the issue where the update dialog was not showed on startup. 2025-06-18 16:07:36 +08:00
cc08445f13 Set initial chapter to first downloaded chapter if there is no history when starting to read a local comic. Close #405 2025-06-17 17:36:13 +08:00
93f7f72d07 Fixed some issues when using custom download path on Android. Close #400 2025-06-17 17:15:35 +08:00
20f7ab4866 Clear folder value if it does not exist in local favorites. Close #389 2025-06-15 15:02:45 +08:00
54363919cd Fixed RangeError when translating tags. Close #356 2025-06-15 14:58:15 +08:00
182a821fc5 Fixed the issue where the download task would stop after exiting the reader. Close #387 2025-06-15 14:58:15 +08:00
8868c6edb3 Update Flutter SDK version to 3.32.4 2025-06-15 14:58:15 +08:00
角砂糖
fffbb4ed23 Only add closeListener when app is desktop (#397) 2025-06-04 12:11:45 +08:00
角砂糖
b057be0311 Fix abnormal history recording when not flipping pages. Close #392 (#395) 2025-06-03 17:36:20 +08:00
角砂糖
fc5fed1707 Fix history of page when show single image on first page (#393) 2025-06-03 17:35:45 +08:00
角砂糖
8525f5318f Fix page calculate when showSingleImageOnFirstPage is enabled (#391) 2025-06-03 17:35:17 +08:00
角砂糖
d58cafc4a0 Fix abnormal single image height when imagesPerPage > 1. Close #379 2025-05-31 10:50:17 +08:00
23afafd1d6 Update rhttp 2025-05-26 19:05:15 +08:00
nyne
3b6e0adbbb Merge pull request #377 from venera-app/v1.4.4
V1.4.4
2025-05-26 18:18:43 +08:00
20a57c7a36 Update version code 2025-05-26 18:10:07 +08:00
665f50ed2a Fixed an issue where comic counts would become invalid after renaming a favorite folder. Close #357 2025-05-26 16:42:05 +08:00
55733ef505 Update selectAll method to handle search mode for selecting comics. Close #359 2025-05-26 16:09:23 +08:00
0c46214619 Reduce maximum length for comic directory names to improve consistency. Close #362 2025-05-26 15:35:24 +08:00
749a1a47fb Fix dialog content overflow. Close #363 2025-05-25 20:33:31 +08:00
76e9ef87d4 Add functionality to delete specific comic chapters. Close #368 2025-05-25 20:26:35 +08:00
dcd6466547 Improve performance of deleting favorites, coping favorites, moving favorites and deleting downloads. Close #365 2025-05-24 16:24:53 +08:00
ed70fdba93 Improve reordering local comics. Close #374 2025-05-22 20:51:47 +08:00
ded0068ea6 Improve performance for clearing history. 2025-05-22 20:37:25 +08:00
nyne
7dc6be622a fix clearing history. 2025-05-22 20:01:07 +08:00
nyne
88f093f7e5 Add clear unfavorited history functionality. Close #372 2025-05-22 19:59:42 +08:00
8f357b3e6c Merge branch 'master' into v1.4.4 2025-05-20 15:51:28 +08:00
9ee82975e8 Handle invalid appdata file. 2025-05-20 15:40:30 +08:00
nyne
9f048685e4 fix decryptAesCbc 2025-05-05 18:29:46 +08:00
nyne
bc1f5e11b5 Update version code 2025-05-05 18:26:01 +08:00
1f2147ef72 Add support for gbk. Close #354 2025-05-05 12:51:36 +08:00
fba365fd93 Fix crash caused by cache manager. Close #351 2025-05-04 23:03:37 +08:00
a5e3fbaee5 Improve image loading 2025-05-04 22:24:39 +08:00
40 changed files with 5243 additions and 364 deletions

View File

@@ -39,6 +39,32 @@ let Convert = {
});
},
/**
* @param str {string}
* @returns {ArrayBuffer}
*/
encodeGbk: (str) => {
return sendMessage({
method: "convert",
type: "gbk",
value: str,
isEncode: true
});
},
/**
* @param value {ArrayBuffer}
* @returns {string}
*/
decodeGbk: (value) => {
return sendMessage({
method: "convert",
type: "gbk",
value: value,
isEncode: false
});
},
/**
* @param {ArrayBuffer} value
* @returns {string}
@@ -176,7 +202,7 @@ let Convert = {
decryptAesCbc: (value, key, iv) => {
return sendMessage({
method: "convert",
type: "aes-ecb",
type: "aes-cbc",
value: value,
key: key,
iv: iv,
@@ -1296,13 +1322,15 @@ let UI = {
* Show an input dialog
* @param title {string}
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
* @param image {string?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
*/
showInputDialog: (title, validator) => {
showInputDialog: (title, validator, image) => {
return sendMessage({
method: 'UI',
function: 'showInputDialog',
title: title,
image: image,
validator: validator
})
},

3982
assets/opencc.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -234,8 +234,10 @@
"Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面",
"Group @group": "第 @group 组",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 页",
"Remove local favorite and history": "删除本地收藏和历史记录",
"Also remove files on disk": "同时删除磁盘上的文件",
"Copy to app local path": "将漫画复制到本地存储目录中",
"Delete all unavailable local favorite items": "删除所有无效的本地收藏",
@@ -388,10 +390,21 @@
"Suggestions": "建议",
"Do not report any issues related to sources to App repo.": "请不要向App仓库报告任何与源相关的问题",
"Show single image on first page": "在首页显示单张图片",
"Show system status bar": "显示系统状态栏",
"Click to select an image": "点击选择一张图片",
"Source URL": "地址",
"Repo URL": "仓库地址",
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
"Double tap to zoom": "双击缩放"
"Double tap to zoom": "双击缩放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反转",
"Delete Chapters": "删除章节",
"Path copied to clipboard": "路径已复制到剪贴板",
"Reverse default chapter order": "反转默认章节顺序",
"Reload Configs": "重新加载配置文件",
"Reload": "重载",
"Disable Length Limitation": "禁用长度限制",
"Only valid for this run": "仅对本次运行有效",
"Logs": "日志"
},
"zh_TW": {
"Home": "首頁",
@@ -628,8 +641,10 @@
"Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面",
"Group @group": "第 @group 組",
"Chapter @ep": "第 @ep 章",
"Page @page": "第 @page 頁",
"Remove local favorite and history": "刪除本機收藏和歷史記錄",
"Also remove files on disk": "同時刪除磁碟上的文件",
"Copy to app local path": "將漫畫複製到本機儲存目錄中",
"Delete all unavailable local favorite items": "刪除所有無效的本機收藏",
@@ -782,9 +797,20 @@
"Suggestions": "建議",
"Do not report any issues related to sources to App repo.": "請不要向App倉庫報告任何與源相關的問題",
"Show single image on first page": "在首頁顯示單張圖片",
"Show system status bar": "顯示系統狀態欄",
"Click to select an image": "點擊選擇一張圖片",
"Source URL": "地址",
"Repo URL": "倉庫地址",
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
"Double tap to zoom": "雙擊縮放"
"Double tap to zoom": "雙擊縮放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反轉",
"Delete Chapters": "刪除章節",
"Path copied to clipboard": "路徑已複製到剪貼簿",
"Reverse default chapter order": "反轉預設章節順序",
"Reload Configs": "重新載入設定檔",
"Reload": "重載",
"Disable Length Limitation": "禁用長度限制",
"Only valid for this run": "僅對本次運行有效",
"Logs": "日誌"
}
}

View File

@@ -9,13 +9,47 @@ Venera uses [flutter_qjs](https://github.com/wgh136/flutter_qjs) as js engine wh
This document will describe how to write a comic source for Venera.
## Preparation
## Comic Source List
Venera can display a list of comic sources in the app.
You can use the following repo url:
```
https://git.nyne.dev/nyne/venera-configs/raw/branch/main/index.json
```
The repo is maintained by the Venera team.
> The link is a mirror of the original repo. To contribute your comic source, please visit the [original repo](https://github.com/venera-app/venera-configs)
You should provide a repository url to let the app load the comic source list.
The url should point to a JSON file that contains the list of comic sources.
The JSON file should have the following format:
```json
[
{
"name": "Source Name",
"url": "https://example.com/source.js",
"filename": "Relative path to the source file",
"version": "1.0.0",
"description": "A brief description of the source"
}
]
```
Only one of `url` and `filename` should be provided.
The description field is optional.
## Create a Comic Source
### Preparation
- Install Venera. Using flutter to run the project is recommended since it's easier to debug.
- An editor that supports javascript.
- Download template and venera javascript api from [here](https://github.com/venera-app/venera-configs).
## Start Writing
### Start Writing
The template contains detailed comments and examples. You can refer to it when writing your own comic source.
@@ -23,7 +57,7 @@ Here is a brief introduction to the template:
> Note: Javascript api document is [here](js_api.md).
### Write basic information
#### Write basic information
```javascript
class NewComicSource extends ComicSource {
@@ -49,7 +83,7 @@ In this part, you need to do the following:
- Change the class name to your source name.
- Fill in the name, key, version, minAppVersion, and url fields.
### init function
#### init function
```javascript
/**
@@ -64,7 +98,7 @@ The function will be called when the source is initialized. You can do some init
Remove this function if not used.
### Account
#### Account
```javascript
// [Optional] account related
@@ -140,7 +174,7 @@ In this part, you can implement login, logout, and register functions.
Remove this part if not used.
### Explore page
#### Explore page
```javascript
// explore page list
@@ -185,7 +219,7 @@ There are three types of explore pages:
- multiPageComicList: An explore page contains multiple comics, the comics are loaded page by page.
- mixed: An explore page contains multiple parts, each part can be a list of comics or a block of comics which have a title and a view more button.
### Category Page
#### Category Page
```javascript
// categories
@@ -227,7 +261,7 @@ Category page is a static page that contains multiple parts, each part contains
A comic source can only have one category page.
### Category Comics Page
#### Category Comics Page
```javascript
/// category comic loading related
@@ -280,7 +314,7 @@ When user clicks on a category, the category comics page will be displayed.
This part is used to load comics of a category.
### Search
#### Search
```javascript
/// search related
@@ -339,7 +373,7 @@ This part is used to load search results.
`load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored.
### Favorites
#### Favorites
```javascript
// favorite related
@@ -411,7 +445,7 @@ This part is used to manage network favorites of the source.
`load` and `loadNext` functions are used to load search results.
If `load` function is implemented, `loadNext` function will be ignored.
### Comic Details
#### Comic Details
```javascript
/// single comic related
@@ -576,7 +610,7 @@ If `load` function is implemented, `loadNext` function will be ignored.
This part is used to load comic details.
### Settings
#### Settings
```javascript
/*
@@ -635,7 +669,7 @@ This part is used to load comic details.
This part is used to provide settings for the source.
### Translations
#### Translations
```javascript
// [Optional] translations for the strings in this config

View File

@@ -37,9 +37,11 @@ mixin class JsUiApi {
case 'showInputDialog':
var title = message['title'];
var validator = message['validator'];
var image = message['image'];
if (title is! String) return;
if (validator != null && validator is! JSInvokable) return;
return _showInputDialog(title, validator);
if (image != null && image is! String) return;
return _showInputDialog(title, validator, image);
case 'showSelectDialog':
var title = message['title'];
var options = message['options'];
@@ -124,12 +126,13 @@ mixin class JsUiApi {
controller?.close();
}
Future<String?> _showInputDialog(String title, JSInvokable? validator) async {
Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async {
String? result;
var func = validator == null ? null : JSAutoFreeFunction(validator);
await showInputDialog(
context: App.rootContext,
title: title,
image: image,
onConfirm: (v) {
if (func != null) {
var res = func.call([v]);

View File

@@ -290,28 +290,30 @@ class ContentDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
var content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title != null
? Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title!),
backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content,
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions,
).paddingRight(12),
const SizedBox(height: 16),
],
var content = SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title != null
? Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title!),
backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content,
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions,
).paddingRight(12),
const SizedBox(height: 16),
],
),
);
return Dialog(
shape: RoundedRectangleBorder(
@@ -357,6 +359,7 @@ Future<void> showInputDialog({
String confirmText = "Confirm",
String cancelText = "Cancel",
RegExp? inputValidator,
String? image,
}) {
var controller = TextEditingController(text: initialValue);
bool isLoading = false;
@@ -369,14 +372,23 @@ Future<void> showInputDialog({
builder: (context, setState) {
return ContentDialog(
title: title,
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
errorText: error,
),
).paddingHorizontal(12),
content: Column(
children: [
if (image != null)
SizedBox(
height: 108,
child: Image.network(image, fit: BoxFit.none),
).paddingBottom(8),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
errorText: error,
),
).paddingHorizontal(12),
],
),
actions: [
Button.filled(
isLoading: isLoading,

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.4.2";
final version = "1.4.6";
bool get isAndroid => Platform.isAndroid;

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart';
@@ -110,21 +111,31 @@ class Appdata with Init {
if (!await file.exists()) {
return;
}
var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
try {
var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
}
}
searchHistory = List.from(json['searchHistory']);
}
searchHistory = List.from(json['searchHistory']);
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
try {
catch(e) {
Log.error("Appdata", "Failed to load appdata", e);
Log.info("Appdata", "Resetting appdata");
file.deleteIgnoreError();
}
try {
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
}
catch(_) {
// ignore
}
}
catch (e) {
Log.error("Appdata", "Failed to load implicit data", e);
Log.info("Appdata", "Resetting implicit data");
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
implicitDataFile.deleteIgnoreError();
}
}
}
@@ -178,7 +189,7 @@ class Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': defaultComicSourceUrl,
'comicSourceListUrl': '',
'preloadImageCount': 4,
'followUpdatesFolder': null,
'initialPage': '0',
@@ -186,6 +197,8 @@ class Settings with ChangeNotifier {
'showPageNumberInReader': true,
'showSingleImageOnFirstPage': false,
'enableDoubleTapToZoom': true,
'reverseChapterOrder': false,
'showSystemStatusBar': false,
};
operator [](String key) {
@@ -194,7 +207,9 @@ class Settings with ChangeNotifier {
operator []=(String key, dynamic value) {
_data[key] = value;
notifyListeners();
if (key != "dataVersion") {
notifyListeners();
}
}
@override
@@ -220,5 +235,3 @@ function processImage(image, cid, eid, page, sourceKey) {
return futureImage;
}
''';
const defaultComicSourceUrl = "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json";

View File

@@ -1,5 +1,7 @@
import 'dart:ffi';
import 'dart:isolate';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/utils/io.dart';
@@ -21,6 +23,51 @@ class CacheManager {
int _limitSize = 2 * 1024 * 1024 * 1024;
static Future<int> _scanDir(Pointer<void> dbP, String dir) async {
var res = await Isolate.run(() async {
int totalSize = 0;
List<String> unmanagedFiles = [];
var db = sqlite3.fromPointer(dbP);
await for (var file in Directory(dir).list(recursive: true)) {
if (file is File) {
var size = await file.length();
var segments = file.uri.pathSegments;
var name = segments.last;
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
var res = db.select('''
SELECT * FROM cache
WHERE dir = ? AND name = ?
''', [dir, name]);
if (res.isEmpty) {
unmanagedFiles.add(file.path);
} else {
totalSize += size;
}
}
}
return {
'totalSize': totalSize,
'unmanagedFiles': unmanagedFiles,
};
});
// delete unmanaged files
// Only modify the database in the main isolate to avoid deadlock
for (var filePath in res['unmanagedFiles'] as List<String>) {
var file = File(filePath);
if (await file.exists()) {
await file.delete();
}
var segments = file.uri.pathSegments;
var name = segments.last;
var dir = segments.elementAtOrNull(segments.length - 2) ?? "*";
CacheManager()._db.execute('''
DELETE FROM cache
WHERE dir = ? AND name = ?
''', [dir, name]);
}
return res['totalSize'] as int;
}
CacheManager._create() {
Directory(cachePath).createSync(recursive: true);
_db = sqlite3.open('${App.dataPath}/cache.db');
@@ -33,7 +80,7 @@ class CacheManager {
type TEXT
)
''');
compute((path) => Directory(path).size, cachePath).then((value) {
_scanDir(_db.handle, cachePath).then((value) {
_currentSize = value;
checkCache();
});
@@ -50,6 +97,7 @@ class CacheManager {
/// Write cache to disk.
Future<void> writeCache(String key, List<int> data,
[int duration = 7 * 24 * 60 * 60 * 1000]) async {
await delete(key);
this.dir++;
this.dir %= 100;
var dir = this.dir;
@@ -146,10 +194,12 @@ class CacheManager {
await file.delete();
}
}
_db.execute('''
if (res.isNotEmpty) {
_db.execute('''
DELETE FROM cache
WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]);
}
while (_currentSize != null && _currentSize! > _limitSize) {
var res = _db.select('''
@@ -157,6 +207,13 @@ class CacheManager {
ORDER BY expires ASC
limit 10
''');
if (res.isEmpty) {
// There are many files unmanaged by the cache manager.
// Clear all cache.
await Directory(cachePath).delete(recursive: true);
Directory(cachePath).createSync(recursive: true);
break;
}
for (var row in res) {
var key = row[0] as String;
var dir = row[1] as String;

View File

@@ -116,6 +116,26 @@ class Comic {
toString() => "$sourceKey@$id";
}
class ComicID {
final ComicType type;
final String id;
const ComicID(this.type, this.id);
@override
bool operator ==(Object other) {
if (other is! ComicID) return false;
return other.type == type && other.id == id;
}
@override
int get hashCode => type.hashCode ^ id.hashCode;
@override
String toString() => "$type@$id";
}
class ComicDetails with HistoryMixin {
@override
final String title;

View File

@@ -653,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners();
}
void batchMoveFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
_db.execute("BEGIN TRANSACTION");
var displayOrder = maxValue(targetFolder) + 1;
try {
for (var item in items) {
_db.execute("""
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
select id, name, author, type, tags, cover_path, time, ?
from "$sourceFolder"
where id == ? and type == ?;
""", [displayOrder, item.id, item.type.value]);
_db.execute("""
delete from "$sourceFolder"
where id == ? and type == ?;
""", [item.id, item.type.value]);
displayOrder++;
}
notifyListeners();
} catch (e) {
Log.error("Batch Move Favorites", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
// Update counts
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
if (counts[sourceFolder] != null) {
counts[sourceFolder] = counts[sourceFolder]! - items.length;
} else {
counts[sourceFolder] = count(sourceFolder);
}
notifyListeners();
}
void batchCopyFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
if (!existsFolder(targetFolder)) {
throw Exception("Target folder does not exist");
}
_db.execute("BEGIN TRANSACTION");
var displayOrder = maxValue(targetFolder) + 1;
try {
for (var item in items) {
_db.execute("""
insert or ignore into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order)
select id, name, author, type, tags, cover_path, time, ?
from "$sourceFolder"
where id == ? and type == ?;
""", [displayOrder, item.id, item.type.value]);
displayOrder++;
}
notifyListeners();
} catch (e) {
Log.error("Batch Copy Favorites", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
// Update counts
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
notifyListeners();
}
/// delete a folder
void deleteFolder(String name) {
_modifiedAfterLastCache = true;
@@ -667,11 +763,6 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners();
}
void deleteComic(String folder, FavoriteItem comic) {
_modifiedAfterLastCache = true;
deleteComicWithId(folder, comic.id, comic.type);
}
void deleteComicWithId(String folder, String id, ComicType type) {
_modifiedAfterLastCache = true;
LocalFavoriteImageProvider.delete(id, type.value);
@@ -687,6 +778,55 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners();
}
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION");
try {
for (var comic in comics) {
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
}
if (counts[folder] != null) {
counts[folder] = counts[folder]! - comics.length;
} else {
counts[folder] = count(folder);
}
} catch (e) {
Log.error("Batch Delete Comics", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
notifyListeners();
}
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION");
var folderNames = _getFolderNamesWithDB();
try {
for (var comic in comics) {
LocalFavoriteImageProvider.delete(comic.id, comic.type.value);
for (var folder in folderNames) {
_db.execute("""
delete from "$folder"
where id == ? and type == ?;
""", [comic.id, comic.type.value]);
}
}
} catch (e) {
Log.error("Batch Delete Comics in All Folders", e.toString());
_db.execute("ROLLBACK");
return;
}
initCounts();
_db.execute("COMMIT");
notifyListeners();
}
Future<int> removeInvalid() async {
int count = 0;
await Future.microtask(() {
@@ -714,11 +854,26 @@ class LocalFavoritesManager with ChangeNotifier {
if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found");
}
deleteFolder(folder);
createFolder(folder);
for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], i);
_db.execute("BEGIN TRANSACTION");
try {
for (int i = 0; i < newFolder.length; i++) {
_db.execute("""
update "$folder"
set display_order = ?
where id == ? and type == ?;
""", [
i,
newFolder[i].id,
newFolder[i].type.value
]);
}
}
catch (e) {
Log.error("Reorder", e.toString());
_db.execute("ROLLBACK");
return;
}
_db.execute("COMMIT");
notifyListeners();
}
@@ -743,6 +898,8 @@ class LocalFavoritesManager with ChangeNotifier {
set folder_name = ?
where folder_name == ?;
""", [after, before]);
counts[after] = counts[before] ?? 0;
counts.remove(before);
notifyListeners();
}

View File

@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart';
@@ -132,6 +133,11 @@ class History implements Comic {
@override
String get description {
var res = "";
if (group != null){
res += "${"Group @group".tlParams({
"group": group!,
})} - ";
}
if (ep >= 1) {
res += "Chapter @ep".tlParams({
"ep": ep,
@@ -305,6 +311,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners();
}
void clearUnfavoritedHistory() {
_db.execute('BEGIN TRANSACTION;');
try {
final idAndTypes = _db.select("""
select id, type from history;
""");
for (var element in idAndTypes) {
final id = element["id"] as String;
final type = ComicType(element["type"] as int);
if (!LocalFavoritesManager().isExist(id, type)) {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [id, type.value]);
}
}
_db.execute('COMMIT;');
} catch (e) {
_db.execute('ROLLBACK;');
rethrow;
}
updateCache();
notifyListeners();
}
void remove(String id, ComicType type) async {
_db.execute("""
delete from history
@@ -380,4 +411,23 @@ class HistoryManager with ChangeNotifier {
isInitialized = false;
_db.dispose();
}
void batchDeleteHistories(List<ComicID> histories) {
if (histories.isEmpty) return;
_db.execute('BEGIN TRANSACTION;');
try {
for (var history in histories) {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [history.id, history.type.value]);
}
_db.execute('COMMIT;');
} catch (e) {
_db.execute('ROLLBACK;');
rethrow;
}
updateCache();
notifyListeners();
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:dio/io.dart';
import 'package:enough_convert/enough_convert.dart';
import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html;
@@ -372,6 +373,11 @@ mixin class _JSEngineApi {
switch (type) {
case "utf8":
return isEncode ? utf8.encode(value) : utf8.decode(value);
case "gbk":
final codec = const GbkCodec();
return isEncode
? Uint8List.fromList(codec.encode(value))
: codec.decode(value);
case "base64":
return isEncode ? base64Encode(value) : base64Decode(value);
case "md5":

View File

@@ -1,6 +1,8 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:flutter_saf/flutter_saf.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -107,15 +109,42 @@ class LocalComic with HistoryMixin implements Comic {
void read() {
var history = HistoryManager().find(id, comicType);
int? firstDownloadedChapter;
int? firstDownloadedChapterGroup;
if (downloadedChapters.isNotEmpty && chapters != null) {
final chapters = this.chapters!;
if (chapters.isGrouped) {
for (int i=0; i<chapters.groupCount; i++) {
var group = chapters.getGroupByIndex(i);
var keys = group.keys.toList();
for (int j=0; j<keys.length; j++) {
var chapterId = keys[j];
if (downloadedChapters.contains(chapterId)) {
firstDownloadedChapter = j + 1;
firstDownloadedChapterGroup = i + 1;
break;
}
}
}
} else {
var keys = chapters.allChapters.keys;
for (int i = 0; i < keys.length; i++) {
if (downloadedChapters.contains(keys.elementAt(i))) {
firstDownloadedChapter = i + 1;
break;
}
}
}
}
App.rootContext.to(
() => Reader(
type: comicType,
cid: id,
name: title,
chapters: chapters,
initialChapter: history?.ep,
initialChapter: history?.ep ?? firstDownloadedChapter,
initialPage: history?.page,
initialChapterGroup: history?.group,
initialChapterGroup: history?.group ?? firstDownloadedChapterGroup,
history: history ??
History.fromModel(
model: this,
@@ -461,7 +490,7 @@ class LocalManager with ChangeNotifier {
if (comic != null) {
return Directory(FilePath.join(path, comic.directory));
}
const comicDirectoryMaxLength = 128;
const comicDirectoryMaxLength = 80;
if (name.length > comicDirectoryMaxLength) {
name = name.substring(0, comicDirectoryMaxLength);
}
@@ -546,6 +575,99 @@ class LocalManager with ChangeNotifier {
remove(c.id, c.comicType);
notifyListeners();
}
void deleteComicChapters(LocalComic c, List<String> chapters) {
if (chapters.isEmpty) {
return;
}
var newDownloadedChapters = c.downloadedChapters
.where((e) => !chapters.contains(e))
.toList();
if (newDownloadedChapters.isNotEmpty) {
_db.execute(
'UPDATE comics SET downloadedChapters = ? WHERE id = ? AND comic_type = ?;',
[
jsonEncode(newDownloadedChapters),
c.id,
c.comicType.value,
],
);
} else {
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[c.id, c.comicType.value],
);
}
var shouldRemovedDirs = <Directory>[];
for (var chapter in chapters) {
var dir = Directory(FilePath.join(c.baseDir, chapter));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
}
if (shouldRemovedDirs.isNotEmpty) {
_deleteDirectories(shouldRemovedDirs);
}
notifyListeners();
}
void batchDeleteComics(List<LocalComic> comics, [bool removeFileOnDisk = true, bool removeFavoriteAndHistory = true]) {
if (comics.isEmpty) {
return;
}
var shouldRemovedDirs = <Directory>[];
_db.execute('BEGIN TRANSACTION;');
try {
for (var c in comics) {
if (removeFileOnDisk) {
var dir = Directory(FilePath.join(path, c.directory));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
}
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[c.id, c.comicType.value],
);
}
}
catch(e, s) {
Log.error("LocalManager", "Failed to batch delete comics: $e", s);
_db.execute('ROLLBACK;');
return;
}
_db.execute('COMMIT;');
var comicIDs = comics.map((e) => ComicID(e.comicType, e.id)).toList();
if (removeFavoriteAndHistory) {
LocalFavoritesManager().batchDeleteComicsInAllFolders(comicIDs);
HistoryManager().batchDeleteHistories(comicIDs);
}
notifyListeners();
if (removeFileOnDisk) {
_deleteDirectories(shouldRemovedDirs);
}
}
/// Deletes the directories in a separate isolate to avoid blocking the UI thread.
static void _deleteDirectories(List<Directory> directories) {
Isolate.run(() async {
await SAFTaskWorker().init();
for (var dir in directories) {
try {
if (dir.existsSync()) {
await dir.delete(recursive: true);
}
} catch (e) {
continue;
}
}
});
}
}
enum LocalSortType {

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:display_mode/display_mode.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_saf/flutter_saf.dart';
@@ -15,6 +16,7 @@ import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/handle_text_share.dart';
import 'package:venera/utils/opencc.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart';
@@ -43,6 +45,7 @@ Future<void> init() async {
TagsTranslation.readData().wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
OpenCC.init(),
];
await Future.wait(futures);
CacheManager().setLimitSize(appdata.settings['cacheSize']);
@@ -50,6 +53,11 @@ Future<void> init() async {
if (App.isAndroid) {
handleLinks();
handleTextShare();
try {
await FlutterDisplayMode.setHighRefreshRate();
} catch(e) {
Log.error("Display Mode", "Failed to set high refresh rate: $e");
}
}
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
@@ -95,8 +103,7 @@ Future<void> _checkAppUpdates() async {
appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
await checkUpdateUi(false, true);
}
}

View File

@@ -552,7 +552,7 @@ class _ImageDownloadWrapper {
void start() async {
int lastBytes = 0;
try {
await for (var p in ImageDownloader.loadComicImage(
await for (var p in ImageDownloader.loadComicImageUnwrapped(
image, task.source.key, task.comicId, chapter)) {
if (isCancelled) {
return;

View File

@@ -111,6 +111,11 @@ abstract class ImageDownloader {
return stream.stream;
}
static Stream<ImageDownloadProgress> loadComicImageUnwrapped(
String imageKey, String? sourceKey, String cid, String eid) {
return _loadComicImage(imageKey, sourceKey, cid, eid);
}
static Stream<ImageDownloadProgress> _loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) async* {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
@@ -234,19 +239,30 @@ class _StreamWrapper<T> {
}
void _listen() async {
await for (var data in _stream) {
if (isClosed) {
break;
}
for (var controller in controllers) {
if (!controller.isClosed) {
controller.add(data);
try {
await for (var data in _stream) {
if (isClosed) {
break;
}
for (var controller in controllers) {
if (!controller.isClosed) {
controller.add(data);
}
}
}
}
for (var controller in controllers) {
if (!controller.isClosed) {
controller.close();
catch (e) {
for (var controller in controllers) {
if (!controller.isClosed) {
controller.addError(e);
}
}
}
finally {
for (var controller in controllers) {
if (!controller.isClosed) {
controller.close();
}
}
}
controllers.clear();

View File

@@ -27,7 +27,7 @@ class _NormalComicChapters extends StatefulWidget {
class _NormalComicChaptersState extends State<_NormalComicChapters> {
late _ComicPageState state;
bool reverse = false;
late bool reverse;
bool showAll = false;
@@ -38,6 +38,7 @@ class _NormalComicChaptersState extends State<_NormalComicChapters> {
@override
void initState() {
super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history;
}
@@ -176,7 +177,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
with SingleTickerProviderStateMixin {
late _ComicPageState state;
bool reverse = false;
late bool reverse;
bool showAll = false;
@@ -191,6 +192,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
@override
void initState() {
super.initState();
reverse = appdata.settings["reverseChapterOrder"] ?? false;
history = widget.history;
if (history?.group != null) {
index = history!.group! - 1;

View File

@@ -410,20 +410,26 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
String text;
if (haveChapter) {
var epName = "E$ep";
String? groupName;
try {
epName = group == null
? comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
)
: comic.chapters!
.getGroupByIndex(group - 1)
.values
.elementAt(ep - 1);
if (group == null){
epName = comic.chapters!.titles.elementAt(
math.min(ep - 1, comic.chapters!.length - 1),
);
} else {
groupName = comic.chapters!.groups.elementAt(group - 1);
epName = comic.chapters!
.getGroupByIndex(group - 1)
.values
.elementAt(ep - 1);
}
}
catch(e) {
// ignore
}
text = "${"Last Reading".tl}: $epName P$page";
text = groupName == null
? "${"Last Reading".tl}: $epName P$page"
: "${"Last Reading".tl}: $groupName $epName P$page";
} else {
text = "${"Last Reading".tl}: P$page";
}

View File

@@ -51,9 +51,7 @@ class ComicSourcePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: const _Body(),
);
return Scaffold(body: const _Body());
}
}
@@ -87,10 +85,7 @@ class _BodyState extends State<_Body> {
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(
title: Text('Comic Source'.tl),
style: AppbarStyle.shadow,
),
SliverAppbar(title: Text('Comic Source'.tl), style: AppbarStyle.shadow),
buildCard(context),
for (var source in ComicSource.all())
_SliverComicSource(
@@ -109,9 +104,7 @@ class _BodyState extends State<_Body> {
showConfirmDialog(
context: App.rootContext,
title: "Delete".tl,
content: "Delete comic source '@n' ?".tlParams({
"n": source.name,
}),
content: "Delete comic source '@n' ?".tlParams({"n": source.name}),
btnColor: context.colorScheme.error,
onConfirm: () {
var file = File(source.filePath);
@@ -133,14 +126,16 @@ class _BodyState extends State<_Body> {
title: const Text("Reload Configs"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("cancel")),
onPressed: () => Navigator.pop(context),
child: const Text("cancel"),
),
TextButton(
onPressed: () async {
await ComicSourceManager().reload();
App.forceRebuild();
},
child: const Text("continue")),
onPressed: () async {
await ComicSourceManager().reload();
App.forceRebuild();
},
child: const Text("continue"),
),
],
),
);
@@ -157,8 +152,10 @@ class _BodyState extends State<_Body> {
);
}
static Future<void> update(ComicSource source,
[bool showLoading = true]) async {
static Future<void> update(
ComicSource source, [
bool showLoading = true,
]) async {
if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config");
return;
@@ -174,8 +171,10 @@ class _BodyState extends State<_Body> {
);
}
try {
var res = await AppDio().get<String>(source.url,
options: Options(responseType: ResponseType.plain));
var res = await AppDio().get<String>(
source.url,
options: Options(responseType: ResponseType.plain),
);
if (cancel) return;
controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath);
@@ -192,12 +191,11 @@ class _BodyState extends State<_Body> {
}
Widget buildCard(BuildContext context) {
Widget buildButton(
{required Widget child, required VoidCallback onPressed}) {
return Button.normal(
onPressed: onPressed,
child: child,
).fixHeight(32);
Widget buildButton({
required Widget child,
required VoidCallback onPressed,
}) {
return Button.normal(onPressed: onPressed, child: child).fixHeight(32);
}
return SliverToBoxAdapter(
@@ -213,12 +211,14 @@ class _BodyState extends State<_Body> {
),
TextField(
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton(
onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check))),
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton(
onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check),
),
),
onChanged: (value) {
url = value;
},
@@ -245,10 +245,7 @@ class _BodyState extends State<_Body> {
),
ListTile(
title: Text("Help".tl),
trailing: buildButton(
onPressed: help,
child: Text("Open".tl),
),
trailing: buildButton(onPressed: help, child: Text("Open".tl)),
),
ListTile(
title: Text("Check updates".tl),
@@ -277,7 +274,8 @@ class _BodyState extends State<_Body> {
void help() {
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
);
}
Future<void> handleAddSource(String url) async {
@@ -288,11 +286,16 @@ class _BodyState extends State<_Body> {
splits.removeWhere((element) => element == "");
var fileName = splits.last;
bool cancel = false;
var controller = showLoadingDialog(App.rootContext,
onCancel: () => cancel = true, barrierDismissible: false);
var controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
try {
var res = await AppDio()
.get<String>(url, options: Options(responseType: ResponseType.plain));
var res = await AppDio().get<String>(
url,
options: Options(responseType: ResponseType.plain),
);
if (cancel) return;
controller.close();
await addSource(res.data!, fileName);
@@ -332,6 +335,12 @@ class _ComicSourceListState extends State<_ComicSourceList> {
json = null;
});
}
if (controller.text.isEmpty) {
setState(() {
json = [];
});
return;
}
var dio = AppDio();
try {
var res = await dio.get<String>(controller.text);
@@ -343,8 +352,7 @@ class _ComicSourceListState extends State<_ComicSourceList> {
json = jsonDecode(res.data!);
});
}
}
catch(e) {
} catch (e) {
context.showMessage(message: "Network error".tl);
if (mounted) {
setState(() {
@@ -372,10 +380,7 @@ class _ComicSourceListState extends State<_ComicSourceList> {
@override
Widget build(BuildContext context) {
return PopUpWidgetScaffold(
title: "Comic Source".tl,
body: buildBody(),
);
return PopUpWidgetScaffold(title: "Comic Source".tl, body: buildBody());
}
Widget buildBody() {
@@ -399,32 +404,36 @@ class _ComicSourceListState extends State<_ComicSourceList> {
children: [
ListTile(
leading: Icon(Icons.source_outlined),
title: Text("Source URL".tl),
title: Text("Repo URL".tl),
),
TextField(
controller: controller,
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
),
onChanged: (value) {
changed = true;
},
).paddingHorizontal(16).paddingBottom(8),
Text("The URL should point to a 'index.json' file".tl).paddingLeft(16),
Text("Do not report any issues related to sources to App repo.".tl).paddingLeft(16),
Text(
"The URL should point to a 'index.json' file".tl,
).paddingLeft(16),
Text(
"Do not report any issues related to sources to App repo.".tl,
).paddingLeft(16),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
controller.text = defaultComicSourceUrl;
changed = true;
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md",
);
},
child: Text("Reset".tl),
child: Text("Help".tl),
),
FilledButton.tonal(
onPressed: load,
@@ -440,7 +449,11 @@ class _ComicSourceListState extends State<_ComicSourceList> {
}
if (index == 1 && json == null) {
return Center(child: CircularProgressIndicator());
return Center(
child: CircularProgressIndicator(
strokeWidth: 2,
).fixWidth(24).fixHeight(24),
);
}
index--;
@@ -449,28 +462,28 @@ class _ComicSourceListState extends State<_ComicSourceList> {
var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8)
: Button.filled(
child: Text("Add".tl),
onPressed: () async {
var fileName = json![index]["fileName"];
var url = json![index]["url"];
if (url == null || !(url.toString()).isURL) {
var listUrl =
appdata.settings['comicSourceListUrl'] as String;
if (listUrl
.replaceFirst("https://", "")
.replaceFirst("http://", "")
.contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
},
).fixHeight(32);
child: Text("Add".tl),
onPressed: () async {
var fileName = json![index]["fileName"];
var url = json![index]["url"];
if (url == null || !(url.toString()).isURL) {
var listUrl =
appdata.settings['comicSourceListUrl'] as String;
if (listUrl
.replaceFirst("https://", "")
.replaceFirst("http://", "")
.contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
},
).fixHeight(32);
var description = json![index]["version"];
if (json![index]["description"] != null) {
@@ -551,8 +564,7 @@ void _addAllPagesWithComicSource(ComicSource source) {
!networkFavorites.contains(source.favoriteData!.key)) {
networkFavorites.add(source.favoriteData!.key);
}
if (source.searchPageData != null &&
!searchPages.contains(source.key)) {
if (source.searchPageData != null && !searchPages.contains(source.key)) {
searchPages.add(source.key);
}
@@ -594,15 +606,10 @@ class __EditFilePageState extends State<_EditFilePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text("Edit".tl),
),
appBar: Appbar(title: Text("Edit".tl)),
body: Column(
children: [
Container(
height: 0.6,
color: context.colorScheme.outlineVariant,
),
Container(height: 0.6, color: context.colorScheme.outlineVariant),
Expanded(
child: CodeEditor(
initialValue: current,
@@ -643,9 +650,11 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
}
void showUpdateDialog() async {
var text = ComicSourceManager().availableUpdates.entries.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}";
}).join("\n");
var text = ComicSourceManager().availableUpdates.entries
.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}";
})
.join("\n");
bool doUpdate = false;
await showDialog(
context: App.rootContext,
@@ -783,10 +792,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
child: ListTile(
title: Row(
children: [
Text(
source.name,
style: ts.s18,
),
Text(source.name, style: ts.s18),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
@@ -819,7 +825,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
style: const TextStyle(fontSize: 13),
),
),
).paddingLeft(4)
).paddingLeft(4),
],
),
trailing: Row(
@@ -864,15 +870,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
),
),
SliverToBoxAdapter(
child: Column(
children: buildSourceSettings().toList(),
),
),
SliverToBoxAdapter(
child: Column(
children: _buildAccount().toList(),
),
child: Column(children: buildSourceSettings().toList()),
),
SliverToBoxAdapter(child: Column(children: _buildAccount().toList())),
],
);
}
@@ -898,8 +898,10 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
}
}
} else {
current = item.value['options']
.firstWhere((e) => e['value'] == current)['text'] ??
current =
item.value['options'].firstWhere(
(e) => e['value'] == current,
)['text'] ??
current;
}
yield ListTile(
@@ -907,8 +909,9 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
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))
.map<String>(
(e) => ((e['text'] ?? e['value']) as String).ts(source.key),
)
.toList(),
onTap: (i) {
source.data['settings'][key] =
@@ -936,8 +939,11 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
subtitle:
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
current,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
@@ -978,10 +984,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: const Icon(Icons.arrow_right),
onTap: () async {
await context.to(
() => _LoginPage(
config: source.account!,
source: source,
),
() => _LoginPage(config: source.account!, source: source),
);
source.saveData();
setState(() {});
@@ -1027,9 +1030,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
trailing: loading
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
);
@@ -1070,9 +1071,7 @@ class _LoginPageState extends State<_LoginPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const Appbar(
title: Text(''),
),
appBar: const Appbar(title: Text('')),
body: Center(
child: Container(
padding: const EdgeInsets.all(16),
@@ -1200,8 +1199,9 @@ class _LoginPageState extends State<_LoginPage> {
setState(() {
loading = true;
});
var cookies =
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
var cookies = widget.config.cookieFields!
.map((e) => _cookies[e] ?? '')
.toList();
widget.config.validateCookies!(cookies).then((value) {
if (value) {
widget.source.data['account'] = 'ok';

View File

@@ -20,6 +20,7 @@ import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/opencc.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
@@ -66,6 +67,11 @@ class _FavoritesPageState extends State<FavoritesPage> {
folder = data['name'];
isNetwork = data['isNetwork'] ?? false;
}
if (folder != null
&& !isNetwork
&& !LocalFavoritesManager().existsFolder(folder!)) {
folder = null;
}
super.initState();
}

View File

@@ -52,7 +52,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} else {
searchResults = [];
for (var comic in comics) {
if (matchKeyword(keyword, comic)) {
if (matchKeyword(keyword, comic) ||
matchKeywordT(keyword, comic) ||
matchKeywordS(keyword, comic)) {
searchResults.add(comic);
}
}
@@ -130,6 +132,24 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return true;
}
// Convert keyword to traditional Chinese to match comics
bool matchKeywordT(String keyword, FavoriteItem comic) {
if (!OpenCC.hasChineseSimplified(keyword)) {
return false;
}
keyword = OpenCC.simplifiedToTraditional(keyword);
return matchKeyword(keyword, comic);
}
// Convert keyword to simplified Chinese to match comics
bool matchKeywordS(String keyword, FavoriteItem comic) {
if (!OpenCC.hasChineseTraditional(keyword)) {
return false;
}
keyword = OpenCC.traditionalToSimplified(keyword);
return matchKeyword(keyword, comic);
}
@override
void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
@@ -155,16 +175,33 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
void selectAll() {
setState(() {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
if (searchMode) {
selectedComics = searchResults.asMap().map((k, v) => MapEntry(v, true));
} else {
selectedComics = comics.asMap().map((k, v) => MapEntry(v, true));
}
});
}
void invertSelection() {
setState(() {
comics.asMap().forEach((k, v) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false);
});
selectedComics.removeWhere((k, v) => !v);
if (searchMode) {
for (var c in searchResults) {
if (selectedComics.containsKey(c)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
} else {
for (var c in comics) {
if (selectedComics.containsKey(c)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
}
});
}
@@ -416,10 +453,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
"Selected @c comics".tlParams({"c": selectedComics.length})),
actions: [
MenuButton(entries: [
if (!isAllFolder)
MenuEntry(
icon: Icons.drive_file_move,
text: "Move to folder".tl,
onClick: () => favoriteOption('move')),
if (!isAllFolder)
MenuEntry(
icon: Icons.copy,
text: "Copy to folder".tl,
@@ -756,32 +795,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return;
}
if (option == 'move') {
for (var c in selectedComics.keys) {
for (var s in selectedLocalFolders) {
LocalFavoritesManager().moveFavorite(
favPage.folder as String,
s,
c.id,
(c as FavoriteItem).type);
}
var comics = selectedComics.keys
.map((e) => e as FavoriteItem)
.toList();
for (var f in selectedLocalFolders) {
LocalFavoritesManager().batchMoveFavorites(
favPage.folder as String,
f,
comics,
);
}
} else {
for (var c in selectedComics.keys) {
for (var s in selectedLocalFolders) {
LocalFavoritesManager().addComic(
s,
FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
author: c.subtitle ?? '',
type: ComicType((c.sourceKey == 'local'
? 0
: c.sourceKey.hashCode)),
tags: c.tags ?? [],
),
);
}
var comics = selectedComics.keys
.map((e) => e as FavoriteItem)
.toList();
for (var f in selectedLocalFolders) {
LocalFavoritesManager().batchCopyFavorites(
favPage.folder as String,
f,
comics,
);
}
}
App.rootContext.pop();
@@ -817,13 +850,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
}
void _deleteComicWithId() {
for (var c in selectedComics.keys) {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
}
var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList();
LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted);
_cancel();
}
}
@@ -864,7 +892,10 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
@override
void dispose() {
if (changed) {
LocalFavoritesManager().reorder(comics, widget.name);
// Delay to ensure navigation is completed
Future.delayed(const Duration(milliseconds: 200), () {
LocalFavoritesManager().reorder(comics, widget.name);
});
}
super.dispose();
}
@@ -899,27 +930,31 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
appBar: Appbar(
title: Text("Reorder".tl),
actions: [
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showInfoDialog(
context: context,
title: "Reorder".tl,
content: "Long press and drag to reorder.".tl,
);
},
),
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
});
},
Tooltip(
message: "Information".tl,
child: IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
showInfoDialog(
context: context,
title: "Reorder".tl,
content: "Long press and drag to reorder.".tl,
);
},
),
),
Tooltip(
message: "Reverse".tl,
child: IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
});
},
),
)
],
),
body: ReorderableBuilder<FavoriteItem>(

View File

@@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
folders = LocalFavoritesManager().folderNames;
findNetworkFolders();
appdata.settings.addListener(updateFolders);
LocalFavoritesManager().addListener(updateFolders);
super.initState();
}
@@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
void dispose() {
super.dispose();
appdata.settings.removeListener(updateFolders);
LocalFavoritesManager().removeListener(updateFolders);
}
@override

View File

@@ -140,6 +140,14 @@ class _HistoryPageState extends State<HistoryPage> {
title: 'Clear History'.tl,
content: Text('Are you sure you want to clear your history?'.tl),
actions: [
Button.outlined(
onPressed: () {
HistoryManager().clearUnfavoritedHistory();
context.pop();
},
child: Text('Clear Unfavorited'.tl),
),
const SizedBox(width: 4),
Button.filled(
color: context.colorScheme.error,
onPressed: () {

View File

@@ -361,28 +361,49 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
context: App.rootContext,
builder: (context) {
bool removeComicFile = true;
bool removeFavoriteAndHistory = true;
return StatefulBuilder(builder: (context, state) {
return ContentDialog(
title: "Delete".tl,
content: CheckboxListTile(
title: Text("Also remove files on disk".tl),
value: removeComicFile,
onChanged: (v) {
state(() {
removeComicFile = !removeComicFile;
});
},
content: Column(
children: [
CheckboxListTile(
title: Text("Remove local favorite and history".tl),
value: removeFavoriteAndHistory,
onChanged: (v) {
state(() {
removeFavoriteAndHistory = !removeFavoriteAndHistory;
});
},
),
CheckboxListTile(
title: Text("Also remove files on disk".tl),
value: removeComicFile,
onChanged: (v) {
state(() {
removeComicFile = !removeComicFile;
});
},
)
],
),
actions: [
if (comics.length == 1 && comics.first.hasChapters)
TextButton(
child: Text("Delete Chapters".tl),
onPressed: () {
context.pop();
showDeleteChaptersPopWindow(context, comics.first);
},
),
FilledButton(
onPressed: () {
context.pop();
for (var comic in comics) {
LocalManager().deleteComic(
comic,
removeComicFile,
);
}
LocalManager().batchDeleteComics(
comics,
removeComicFile,
removeFavoriteAndHistory,
);
isDeleted = true;
},
child: Text("Confirm".tl),
@@ -497,3 +518,59 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
typedef ExportComicFunc = Future<File> Function(
LocalComic comic, String outFilePath);
void showDeleteChaptersPopWindow(BuildContext context, LocalComic comic) {
var chapters = <String>[];
showPopUpWidget(
context,
PopUpWidgetScaffold(
title: "Delete Chapters".tl,
body: StatefulBuilder(builder: (context, setState) {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: comic.downloadedChapters.length,
itemBuilder: (context, index) {
var id = comic.downloadedChapters[index];
var chapter = comic.chapters![id] ?? "Unknown Chapter";
return CheckboxListTile(
title: Text(chapter),
value: chapters.contains(id),
onChanged: (v) {
setState(() {
if (v == true) {
chapters.add(id);
} else {
chapters.remove(id);
}
});
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton(
onPressed: () {
Future.delayed(const Duration(milliseconds: 200), () {
LocalManager().deleteComicChapters(comic, chapters);
});
App.rootContext.pop();
},
child: Text("Submit".tl),
)
],
),
)
],
);
}),
),
);
}

View File

@@ -40,6 +40,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
reader.images = images;
reader.isLoading = false;
inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
});
} catch (e) {
setState(() {
@@ -65,6 +68,9 @@ class _ReaderImagesState extends State<_ReaderImages> {
reader.images = res.data;
reader.isLoading = false;
inProgress = false;
Future.microtask(() {
reader.updateHistory();
});
});
}
}
@@ -79,14 +85,21 @@ class _ReaderImagesState extends State<_ReaderImages> {
child: CircularProgressIndicator(),
);
} else if (error != null) {
return NetworkError(
message: error!,
retry: () {
setState(() {
reader.isLoading = true;
error = null;
});
return GestureDetector(
onTap: () {
context.readerScaffold.openOrClose();
},
child: SizedBox.expand(
child: NetworkError(
message: error!,
retry: () {
setState(() {
reader.isLoading = true;
error = null;
});
},
),
),
);
} else {
if (reader.mode.isGallery) {
@@ -233,7 +246,7 @@ class _GalleryModeState extends State<_GalleryMode>
photoViewControllers[index] ??= PhotoViewController();
if (reader.imagesPerPage == 1) {
if (reader.imagesPerPage == 1 || pageImages.length == 1) {
return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium,
controller: photoViewControllers[index],

View File

@@ -164,10 +164,9 @@ class _ReaderState extends State<Reader>
}
mode = ReaderMode.fromKey(appdata.settings['readerMode']);
history = widget.history;
Future.microtask(() {
updateHistory();
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if (!appdata.settings['showSystemStatusBar']) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
if (appdata.settings['enableTurnPageByVolumeKey']) {
handleVolumeEvent();
}
@@ -178,10 +177,18 @@ class _ReaderState extends State<Reader>
super.initState();
}
bool _isInitialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
initImagesPerPage(widget.initialPage ?? 1);
if (!_isInitialized) {
initImagesPerPage(widget.initialPage ?? 1);
_isInitialized = true;
} else {
// For orientation changed
_checkImagesPerPageChange();
}
initReaderWindow();
}
@@ -267,7 +274,15 @@ class _ReaderState extends State<Reader>
history!.page = images?.length ?? 1;
} else {
/// Record the first image of the page
history!.page = (page - 1) * imagesPerPage + 1;
if (!showSingleImageOnFirstPage || imagesPerPage == 1) {
history!.page = (page - 1) * imagesPerPage + 1;
} else {
if (page == 1) {
history!.page = 1;
} else {
history!.page = (page - 2) * imagesPerPage + 2;
}
}
}
history!.maxPage = images?.length ?? 1;
if (widget.chapters?.isGrouped ?? false) {
@@ -338,6 +353,8 @@ class _ReaderState extends State<Reader>
abstract mixin class _ImagePerPageHandler {
late int _lastImagesPerPage;
late bool _lastOrientation;
bool get isPortrait;
int get page;
@@ -348,8 +365,13 @@ abstract mixin class _ImagePerPageHandler {
void initImagesPerPage(int initialPage) {
_lastImagesPerPage = imagesPerPage;
_lastOrientation = isPortrait;
if (imagesPerPage != 1) {
page = (initialPage / imagesPerPage).ceil();
if (showSingleImageOnFirstPage) {
page = ((initialPage - 1) / imagesPerPage).ceil() + 1;
} else {
page = (initialPage / imagesPerPage).ceil();
}
}
}
@@ -369,19 +391,42 @@ abstract mixin class _ImagePerPageHandler {
/// Check if the number of images per page has changed
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
bool currentOrientation = isPortrait;
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
_lastOrientation = currentOrientation;
}
}
/// Adjust the page number when the number of images per page changes
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
int previousImageIndex = 1;
if (!showSingleImageOnFirstPage || oldImagesPerPage == 1) {
previousImageIndex = (page - 1) * oldImagesPerPage + 1;
} else {
if (page == 1) {
previousImageIndex = 1;
} else {
previousImageIndex = (page - 2) * oldImagesPerPage + 2;
}
}
int newPage;
if (newImagesPerPage != 1) {
if (showSingleImageOnFirstPage) {
newPage = ((previousImageIndex - 1) / newImagesPerPage).ceil() + 1;
} else {
newPage = (previousImageIndex / newImagesPerPage).ceil();
}
} else {
newPage = previousImageIndex;
}
page = newPage>0 ? newPage : 1;
}
}

View File

@@ -107,7 +107,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
if (!_isOpen) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if (!appdata.settings['showSystemStatusBar']) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
}
setState(() {
_isOpen = !_isOpen;

View File

@@ -96,10 +96,13 @@ Future<bool> checkUpdate() async {
return false;
}
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true, bool delay = false]) async {
try {
var value = await checkUpdate();
if (value) {
if (delay) {
await Future.delayed(const Duration(seconds: 2));
}
showDialog(
context: App.rootContext,
builder: (context) {

View File

@@ -193,12 +193,46 @@ class LogsPage extends StatefulWidget {
}
class _LogsPageState extends State<LogsPage> {
String logLevelToShow = "all";
@override
Widget build(BuildContext context) {
var logToShow = logLevelToShow == "all"
? Log.logs
: Log.logs.where((log) => log.level.name == logLevelToShow).toList();
return Scaffold(
appBar: Appbar(
title: const Text("Logs"),
title: Text("Logs".tl),
actions: [
IconButton(
onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
MediaQuery.of(context).padding.top + kToolbarHeight,
0.0,
0.0,
);
showMenu(context: context, position: position, items: [
PopupMenuItem(
child: Text("all"),
onTap: () => setState(() => logLevelToShow = "all")
),
PopupMenuItem(
child: Text("info"),
onTap: () => setState(() => logLevelToShow = "info")
),
PopupMenuItem(
child: Text("warning"),
onTap: () => setState(() => logLevelToShow = "warning")
),
PopupMenuItem(
child: Text("error"),
onTap: () => setState(() => logLevelToShow = "error")
),
]);
}),
icon: const Icon(Icons.filter_list_outlined)
),
IconButton(
onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB(
@@ -217,7 +251,7 @@ class _LogsPageState extends State<LogsPage> {
onTap: () {
Log.ignoreLimitation = true;
context.showMessage(
message: "Only valid for this run");
message: "Only valid for this run".tl);
},
),
PopupMenuItem(
@@ -232,9 +266,9 @@ class _LogsPageState extends State<LogsPage> {
body: ListView.builder(
reverse: true,
controller: ScrollController(),
itemCount: Log.logs.length,
itemCount: logToShow.length,
itemBuilder: (context, index) {
index = Log.logs.length - index - 1;
index = logToShow.length - index - 1;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: SelectionArea(
@@ -253,7 +287,7 @@ class _LogsPageState extends State<LogsPage> {
),
child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(Log.logs[index].title),
child: Text(logToShow[index].title),
),
),
const SizedBox(
@@ -265,16 +299,16 @@ class _LogsPageState extends State<LogsPage> {
Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.errorContainer,
Theme.of(context).colorScheme.primaryContainer
][Log.logs[index].level.index],
][logToShow[index].level.index],
borderRadius:
const BorderRadius.all(Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(
Log.logs[index].level.name,
logToShow[index].level.name,
style: TextStyle(
color: Log.logs[index].level.index == 0
color: logToShow[index].level.index == 0
? Colors.white
: Colors.black),
),
@@ -282,14 +316,14 @@ class _LogsPageState extends State<LogsPage> {
),
],
),
Text(Log.logs[index].content),
Text(Log.logs[index].time
Text(logToShow[index].content),
Text(logToShow[index].time
.toString()
.replaceAll(RegExp(r"\.\w+"), "")),
TextButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: Log.logs[index].content));
ClipboardData(text: logToShow[index].content));
},
child: Text("Copy".tl),
),

View File

@@ -18,8 +18,8 @@ class DebugPageState extends State<DebugPage> {
slivers: [
SliverAppbar(title: Text("Debug".tl)),
_CallbackSetting(
title: "Reload Configs",
actionTitle: "Reload",
title: "Reload Configs".tl,
actionTitle: "Reload".tl,
callback: () {
ComicSourceManager().reload();
},

View File

@@ -52,6 +52,10 @@ class _ExploreSettingsState extends State<ExploreSettings> {
title: "Show history on comic tile".tl,
settingKey: "showHistoryStatusOnTile",
).toSliver(),
_SwitchSetting(
title: "Reverse default chapter order".tl,
settingKey: "reverseChapterOrder",
).toSliver(),
_PopupWindowSetting(
title: "Keyword blocking".tl,
builder: () => const _ManageBlockingWordView(),

View File

@@ -163,6 +163,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
},
).toSliver(),
_SwitchSetting(
title: "Show system status bar".tl,
settingKey: "showSystemStatusBar",
onChanged: () {
widget.onChanged?.call("showSystemStatusBar");
},
).toSliver(),
SelectSetting(
title: "Quick collect image".tl,
settingKey: "quickCollectImage",

View File

@@ -22,10 +22,12 @@ class DataSync with ChangeNotifier {
}
LocalFavoritesManager().addListener(onDataChanged);
ComicSourceManager().addListener(onDataChanged);
Future.delayed(const Duration(seconds: 1), () {
var controller = WindowFrame.of(App.rootContext);
controller.addCloseListener(_handleWindowClose);
});
if (App.isDesktop) {
Future.delayed(const Duration(seconds: 1), () {
var controller = WindowFrame.of(App.rootContext);
controller.addCloseListener(_handleWindowClose);
});
}
}
void onDataChanged() {

67
lib/utils/opencc.dart Normal file
View File

@@ -0,0 +1,67 @@
import 'dart:convert';
import 'package:flutter/services.dart';
abstract class OpenCC {
static late final Map<int, int> _s2t;
static late final Map<int, int> _t2s;
static Future<void> init() async {
var data = await rootBundle.load("assets/opencc.txt");
var txt = utf8.decode(data.buffer.asUint8List());
_s2t = <int, int>{};
_t2s = <int, int>{};
for (var line in txt.split('\n')) {
if (line.isEmpty || line.startsWith('#') || line.length != 2) continue;
var s = line.runes.elementAt(0);
var t = line.runes.elementAt(1);
_s2t[s] = t;
_t2s[t] = s;
}
}
static bool hasChineseSimplified(String text) {
if (text != "监禁") {
return false;
}
for (var rune in text.runes) {
if (_s2t.containsKey(rune)) {
return true;
}
}
return false;
}
static bool hasChineseTraditional(String text) {
for (var rune in text.runes) {
if (_t2s.containsKey(rune)) {
return true;
}
}
return false;
}
static String simplifiedToTraditional(String text) {
var sb = StringBuffer();
for (var rune in text.runes) {
if (_s2t.containsKey(rune)) {
sb.write(String.fromCharCodes([_s2t[rune]!]));
} else {
sb.write(String.fromCharCodes([rune]));
}
}
return sb.toString();
}
static String traditionalToSimplified(String text) {
var sb = StringBuffer();
for (var rune in text.runes) {
if (_t2s.containsKey(rune)) {
sb.write(String.fromCharCodes([_t2s[rune]!]));
} else {
sb.write(String.fromCharCodes([rune]));
}
}
return sb.toString();
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/image.dart';
@@ -74,6 +75,9 @@ Future<Isolate> _runIsolate(
return Isolate.spawn<SendPort>(
(sendPort) => overrideIO(
() async {
if (App.isAndroid) {
await SAFTaskWorker().init();
}
var receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);

View File

@@ -35,8 +35,10 @@ extension TagsTranslation on String{
/// 对tag进行处理后进行翻译: 代表'或'的分割符'|', namespace.
static String _translateTags(String tag){
if (tag.contains('|')) {
var splits = tag.split(' | ');
return enTagsTranslations[splits[0]]??enTagsTranslations[splits[1]]??tag;
var splits = tag.split('|');
return enTagsTranslations[splits[0].trim()]
?? enTagsTranslations[splits[1].trim()]
?? tag;
} else if(tag.contains(':')) {
var splits = tag.split(':');
if(_haveNamespace(splits[0])) {

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.12.0"
version: "2.13.0"
battery_plus:
dependency: "direct main"
description:
@@ -170,6 +170,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
display_mode:
dependency: "direct main"
description:
name: display_mode
sha256: "8a381f3602a09dc4e96140a0df30808631468d6d0dfff7722f67b1f83757a7cc"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
dynamic_color:
dependency: "direct main"
description:
@@ -178,14 +186,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.7.0"
enough_convert:
dependency: "direct main"
description:
name: enough_convert
sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01
url: "https://pub.dev"
source: hosted
version: "1.6.0"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version: "1.3.3"
ffi:
dependency: transitive
description:
@@ -425,10 +441,10 @@ packages:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611"
sha256: b416ff56002789e636244fb4cc449f587656eff995e5a7169457eb0593fcaddb
url: "https://pub.dev"
source: hosted
version: "2.9.0"
version: "2.10.0"
flutter_saf:
dependency: "direct main"
description:
@@ -516,10 +532,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.19.0"
version: "0.20.2"
io:
dependency: transitive
description:
@@ -540,10 +556,10 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "10.0.8"
version: "10.0.9"
leak_tracker_flutter_testing:
dependency: transitive
description:
@@ -758,11 +774,11 @@ packages:
dependency: "direct main"
description:
path: rhttp
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
resolved-ref: e7dca15ca543b5df49f3ada06016e874b79bce36
ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
resolved-ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
url: "https://github.com/wgh136/rhttp"
source: git
version: "0.11.0"
version: "0.12.0"
screen_retriever:
dependency: transitive
description:
@@ -1029,10 +1045,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "14.3.1"
version: "15.0.0"
web:
dependency: transitive
description:
@@ -1099,5 +1115,5 @@ packages:
source: hosted
version: "0.0.12"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.3"
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.32.6"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.4.2+142
version: 1.4.6+146
environment:
sdk: '>=3.6.0 <4.0.0'
flutter: 3.29.3
sdk: '>=3.8.0 <4.0.0'
flutter: 3.32.6
dependencies:
flutter:
@@ -61,7 +61,7 @@ dependencies:
rhttp:
git:
url: https://github.com/wgh136/rhttp
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
ref: 1f0ff50336062c5f809c256726dc55cd30b9ce59
path: rhttp
webdav_client:
git:
@@ -85,6 +85,8 @@ dependencies:
flutter_localizations:
sdk: flutter
yaml: ^3.1.3
enough_convert: ^1.6.0
display_mode: ^0.0.2
dev_dependencies:
flutter_test:
@@ -101,6 +103,7 @@ flutter:
- assets/app_icon.png
- assets/tags.json
- assets/tags_tw.json
- assets/opencc.txt
flutter_to_arch:
name: Venera

View File

@@ -98,14 +98,20 @@ bool FlutterWindow::OnCreate() {
else
result->Success(flutter::EncodableValue("No Proxy"));
delete(res);
return;
}
#ifdef NDEBUG
else if (call.method_name() == "heartBeat") {
if (monitorThread == nullptr) {
monitorThread = new std::thread{ monitorUIThread };
}
lastHeartbeat = std::chrono::steady_clock::now();
result->Success();
return;
}
#endif
result->Success(); // Default response for unhandled method calls
});
flutter::EventChannel<> channel2(