32 Commits

Author SHA1 Message Date
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
190e645a12 Update translation 2025-04-29 11:35:54 +08:00
nyne
8a83ff5367 Merge pull request #349 from venera-app/v1.4.2-dev
V1.4.2
2025-04-29 11:32:40 +08:00
6e14942dab Add application category type to Info.plist 2025-04-29 11:29:30 +08:00
146fc70143 Update version code 2025-04-29 11:19:59 +08:00
b37ea01aca Add an option to disable double tap to zoom. 2025-04-29 11:18:59 +08:00
bf7b90313a Fix invalid total page count. Close #348 2025-04-28 20:18:29 +08:00
929c1a9d91 Show comics count of a folder on sidebar. 2025-04-28 19:46:29 +08:00
9ff68d0701 Improve local favorites performance. 2025-04-28 19:40:12 +08:00
dfd15ed34a Fix an issue where folders were not fully displayed on the favorites page. 2025-04-26 10:23:18 +08:00
nyne
dfe2a0db6a Merge pull request #345 from venera-app/v1.4.1-dev
V1.4.1
2025-04-25 09:22:51 +08:00
c6714f79b6 Revert "Add windows arm64"
This reverts commit 6877aa120f.
2025-04-25 09:18:45 +08:00
6877aa120f Add windows arm64 2025-04-15 17:08:28 +08:00
25 changed files with 1127 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 * @param {ArrayBuffer} value
* @returns {string} * @returns {string}
@@ -176,7 +202,7 @@ let Convert = {
decryptAesCbc: (value, key, iv) => { decryptAesCbc: (value, key, iv) => {
return sendMessage({ return sendMessage({
method: "convert", method: "convert",
type: "aes-ecb", type: "aes-cbc",
value: value, value: value,
key: key, key: key,
iv: iv, iv: iv,

View File

@@ -390,7 +390,11 @@
"Show single image on first page": "在首页显示单张图片", "Show single image on first page": "在首页显示单张图片",
"Click to select an image": "点击选择一张图片", "Click to select an image": "点击选择一张图片",
"Source URL": "源地址", "Source URL": "源地址",
"The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件" "The URL should point to a 'index.json' file": "该URL应指向一个'index.json'文件",
"Double tap to zoom": "双击缩放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反转",
"Delete Chapters": "删除章节"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -783,6 +787,10 @@
"Show single image on first page": "在首頁顯示單張圖片", "Show single image on first page": "在首頁顯示單張圖片",
"Click to select an image": "點擊選擇一張圖片", "Click to select an image": "點擊選擇一張圖片",
"Source URL": "源地址", "Source URL": "源地址",
"The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件" "The URL should point to a 'index.json' file": "該URL應指向一個'index.json'文件",
"Double tap to zoom": "雙擊縮放",
"Clear Unfavorited": "清除未收藏",
"Reverse": "反轉",
"Delete Chapters": "刪除章節"
} }
} }

View File

@@ -46,12 +46,14 @@
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Choose images</string> <string>Choose images</string>
<key>UIFileSharingEnabled</key> <key>UIFileSharingEnabled</key>
<true/> <true/>
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
<true/> <true/>
<key>NSFaceIDUsageDescription</key> <key>NSFaceIDUsageDescription</key>
<string>Ensure that the operation is being performed by the user themselves.</string> <string>Ensure that the operation is being performed by the user themselves.</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.books</string>
</dict> </dict>
</plist> </plist>

View File

@@ -290,28 +290,30 @@ class ContentDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var content = Column( var content = SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
title != null children: [
? Appbar( title != null
leading: IconButton( ? Appbar(
icon: const Icon(Icons.close), leading: IconButton(
onPressed: dismissible ? context.pop : null, icon: const Icon(Icons.close),
), onPressed: dismissible ? context.pop : null,
title: Text(title!), ),
backgroundColor: Colors.transparent, title: Text(title!),
) backgroundColor: Colors.transparent,
: const SizedBox.shrink(), )
this.content, : const SizedBox.shrink(),
const SizedBox(height: 16), this.content,
Row( const SizedBox(height: 16),
mainAxisAlignment: MainAxisAlignment.end, Row(
children: actions, mainAxisAlignment: MainAxisAlignment.end,
).paddingRight(12), children: actions,
const SizedBox(height: 16), ).paddingRight(12),
], const SizedBox(height: 16),
],
),
); );
return Dialog( return Dialog(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(

View File

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

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/init.dart'; import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -110,21 +111,31 @@ class Appdata with Init {
if (!await file.exists()) { if (!await file.exists()) {
return; return;
} }
var json = jsonDecode(await file.readAsString()); try {
for (var key in (json['settings'] as Map<String, dynamic>).keys) { var json = jsonDecode(await file.readAsString());
if (json['settings'][key] != null) { for (var key in (json['settings'] as Map<String, dynamic>).keys) {
settings[key] = json['settings'][key]; if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
}
} }
searchHistory = List.from(json['searchHistory']);
} }
searchHistory = List.from(json['searchHistory']); catch(e) {
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json')); Log.error("Appdata", "Failed to load appdata", e);
if (await implicitDataFile.exists()) { Log.info("Appdata", "Resetting appdata");
try { file.deleteIgnoreError();
}
try {
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString()); 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();
} }
} }
} }
@@ -185,6 +196,7 @@ class Settings with ChangeNotifier {
'comicListDisplayMode': 'paging', // paging, continuous 'comicListDisplayMode': 'paging', // paging, continuous
'showPageNumberInReader': true, 'showPageNumberInReader': true,
'showSingleImageOnFirstPage': false, 'showSingleImageOnFirstPage': false,
'enableDoubleTapToZoom': true,
}; };
operator [](String key) { operator [](String key) {
@@ -193,7 +205,9 @@ class Settings with ChangeNotifier {
operator []=(String key, dynamic value) { operator []=(String key, dynamic value) {
_data[key] = value; _data[key] = value;
notifyListeners(); if (key != "dataVersion") {
notifyListeners();
}
} }
@override @override

View File

@@ -1,5 +1,7 @@
import 'dart:ffi';
import 'dart:isolate';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -21,6 +23,51 @@ class CacheManager {
int _limitSize = 2 * 1024 * 1024 * 1024; 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() { CacheManager._create() {
Directory(cachePath).createSync(recursive: true); Directory(cachePath).createSync(recursive: true);
_db = sqlite3.open('${App.dataPath}/cache.db'); _db = sqlite3.open('${App.dataPath}/cache.db');
@@ -33,7 +80,7 @@ class CacheManager {
type TEXT type TEXT
) )
'''); ''');
compute((path) => Directory(path).size, cachePath).then((value) { _scanDir(_db.handle, cachePath).then((value) {
_currentSize = value; _currentSize = value;
checkCache(); checkCache();
}); });
@@ -50,6 +97,7 @@ class CacheManager {
/// Write cache to disk. /// Write cache to disk.
Future<void> writeCache(String key, List<int> data, Future<void> writeCache(String key, List<int> data,
[int duration = 7 * 24 * 60 * 60 * 1000]) async { [int duration = 7 * 24 * 60 * 60 * 1000]) async {
await delete(key);
this.dir++; this.dir++;
this.dir %= 100; this.dir %= 100;
var dir = this.dir; var dir = this.dir;
@@ -146,10 +194,12 @@ class CacheManager {
await file.delete(); await file.delete();
} }
} }
_db.execute(''' if (res.isNotEmpty) {
_db.execute('''
DELETE FROM cache DELETE FROM cache
WHERE expires < ? WHERE expires < ?
''', [DateTime.now().millisecondsSinceEpoch]); ''', [DateTime.now().millisecondsSinceEpoch]);
}
while (_currentSize != null && _currentSize! > _limitSize) { while (_currentSize != null && _currentSize! > _limitSize) {
var res = _db.select(''' var res = _db.select('''
@@ -157,6 +207,13 @@ class CacheManager {
ORDER BY expires ASC ORDER BY expires ASC
limit 10 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) { for (var row in res) {
var key = row[0] as String; var key = row[0] as String;
var dir = row[1] as String; var dir = row[1] as String;

View File

@@ -116,6 +116,26 @@ class Comic {
toString() => "$sourceKey@$id"; 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 { class ComicDetails with HistoryMixin {
@override @override
final String title; final String title;

View File

@@ -1,4 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
@@ -209,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
late Database _db; late Database _db;
late Map<String, int> counts;
int get totalComics {
int total = 0;
for (var t in counts.values) {
total += t;
}
return total;
}
int folderComics(String folder) {
return counts[folder] ?? 0;
}
Future<void> init() async { Future<void> init() async {
counts = {};
_db = sqlite3.open("${App.dataPath}/local_favorite.db"); _db = sqlite3.open("${App.dataPath}/local_favorite.db");
_db.execute(""" _db.execute("""
create table if not exists folder_order ( create table if not exists folder_order (
@@ -256,6 +273,13 @@ class LocalFavoritesManager with ChangeNotifier {
} else { } else {
appdata.settings['followUpdatesFolder'] = null; appdata.settings['followUpdatesFolder'] = null;
} }
initCounts();
}
void initCounts() {
for (var folder in folderNames) {
counts[folder] = count(folder);
}
} }
List<String> find(String id, ComicType type) { List<String> find(String id, ComicType type) {
@@ -357,6 +381,23 @@ class LocalFavoritesManager with ChangeNotifier {
return rows.map((element) => FavoriteItem.fromRow(element)).toList(); return rows.map((element) => FavoriteItem.fromRow(element)).toList();
} }
static Future<List<FavoriteItem>> _getFolderComicsAsync(
String folder, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var rows = db.select("""
select * from "$folder"
ORDER BY display_order;
""");
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
});
}
/// Start a new isolate to get the comics in the folder
Future<List<FavoriteItem>> getFolderComicsAsync(String folder) {
return _getFolderComicsAsync(folder, _db.handle);
}
List<FavoriteItem> getAllComics() { List<FavoriteItem> getAllComics() {
var res = <FavoriteItem>{}; var res = <FavoriteItem>{};
for (final folder in folderNames) { for (final folder in folderNames) {
@@ -368,6 +409,26 @@ class LocalFavoritesManager with ChangeNotifier {
return res.toList(); return res.toList();
} }
static Future<List<FavoriteItem>> _getAllComicsAsync(
List<String> folders, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var res = <FavoriteItem>{};
for (final folder in folders) {
var comics = db.select("""
select * from "$folder";
""");
res.addAll(comics.map((element) => FavoriteItem.fromRow(element)));
}
return res.toList();
});
}
/// Start a new isolate to get all the comics
Future<List<FavoriteItem>> getAllComicsAsync() {
return _getAllComicsAsync(folderNames, _db.handle);
}
void addTagTo(String folder, String id, String tag) { void addTagTo(String folder, String id, String tag) {
_db.execute(""" _db.execute("""
update "$folder" update "$folder"
@@ -433,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
); );
"""); """);
notifyListeners(); notifyListeners();
counts[name] = 0;
return name; return name;
} }
@@ -547,6 +609,11 @@ class LocalFavoritesManager with ChangeNotifier {
""", [updateTime, comic.id, comic.type.value]); """, [updateTime, comic.id, comic.type.value]);
} }
} }
if (counts[folder] == null) {
counts[folder] = count(folder);
} else {
counts[folder] = counts[folder]! + 1;
}
notifyListeners(); notifyListeners();
return true; return true;
} }
@@ -586,6 +653,102 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); 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 /// delete a folder
void deleteFolder(String name) { void deleteFolder(String name) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
@@ -596,14 +759,10 @@ class LocalFavoritesManager with ChangeNotifier {
delete from folder_order delete from folder_order
where folder_name == ?; where folder_name == ?;
""", [name]); """, [name]);
counts.remove(name);
notifyListeners(); notifyListeners();
} }
void deleteComic(String folder, FavoriteItem comic) {
_modifiedAfterLastCache = true;
deleteComicWithId(folder, comic.id, comic.type);
}
void deleteComicWithId(String folder, String id, ComicType type) { void deleteComicWithId(String folder, String id, ComicType type) {
_modifiedAfterLastCache = true; _modifiedAfterLastCache = true;
LocalFavoriteImageProvider.delete(id, type.value); LocalFavoriteImageProvider.delete(id, type.value);
@@ -611,6 +770,60 @@ class LocalFavoritesManager with ChangeNotifier {
delete from "$folder" delete from "$folder"
where id == ? and type == ?; where id == ? and type == ?;
""", [id, type.value]); """, [id, type.value]);
if (counts[folder] != null) {
counts[folder] = counts[folder]! - 1;
} else {
counts[folder] = count(folder);
}
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(); notifyListeners();
} }
@@ -641,11 +854,26 @@ class LocalFavoritesManager with ChangeNotifier {
if (!existsFolder(folder)) { if (!existsFolder(folder)) {
throw Exception("Failed to reorder: folder not found"); throw Exception("Failed to reorder: folder not found");
} }
deleteFolder(folder); _db.execute("BEGIN TRANSACTION");
createFolder(folder); try {
for (int i = 0; i < newFolder.length; i++) { for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], 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(); notifyListeners();
} }
@@ -670,6 +898,8 @@ class LocalFavoritesManager with ChangeNotifier {
set folder_name = ? set folder_name = ?
where folder_name == ?; where folder_name == ?;
""", [after, before]); """, [after, before]);
counts[after] = counts[before] ?? 0;
counts.remove(before);
notifyListeners(); notifyListeners();
} }

View File

@@ -10,6 +10,7 @@ import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.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/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -305,6 +306,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners(); 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 { void remove(String id, ComicType type) async {
_db.execute(""" _db.execute("""
delete from history delete from history
@@ -380,4 +406,23 @@ class HistoryManager with ChangeNotifier {
isInitialized = false; isInitialized = false;
_db.dispose(); _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 'dart:math' as math;
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:enough_convert/enough_convert.dart';
import 'package:flutter/foundation.dart' show protected; import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html; import 'package:html/parser.dart' as html;
@@ -372,6 +373,11 @@ mixin class _JSEngineApi {
switch (type) { switch (type) {
case "utf8": case "utf8":
return isEncode ? utf8.encode(value) : utf8.decode(value); 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": case "base64":
return isEncode ? base64Encode(value) : base64Decode(value); return isEncode ? base64Encode(value) : base64Decode(value);
case "md5": case "md5":

View File

@@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -461,7 +462,7 @@ class LocalManager with ChangeNotifier {
if (comic != null) { if (comic != null) {
return Directory(FilePath.join(path, comic.directory)); return Directory(FilePath.join(path, comic.directory));
} }
const comicDirectoryMaxLength = 128; const comicDirectoryMaxLength = 80;
if (name.length > comicDirectoryMaxLength) { if (name.length > comicDirectoryMaxLength) {
name = name.substring(0, comicDirectoryMaxLength); name = name.substring(0, comicDirectoryMaxLength);
} }
@@ -546,6 +547,95 @@ class LocalManager with ChangeNotifier {
remove(c.id, c.comicType); remove(c.id, c.comicType);
notifyListeners(); 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]) {
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();
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 {
for (var dir in directories) {
try {
if (dir.existsSync()) {
await dir.delete(recursive: true);
}
} catch (e) {
continue;
}
}
});
}
} }
enum LocalSortType { enum LocalSortType {

View File

@@ -234,19 +234,30 @@ class _StreamWrapper<T> {
} }
void _listen() async { void _listen() async {
await for (var data in _stream) { try {
if (isClosed) { await for (var data in _stream) {
break; if (isClosed) {
} break;
for (var controller in controllers) { }
if (!controller.isClosed) { for (var controller in controllers) {
controller.add(data); if (!controller.isClosed) {
controller.add(data);
}
} }
} }
} }
for (var controller in controllers) { catch (e) {
if (!controller.isClosed) { for (var controller in controllers) {
controller.close(); if (!controller.isClosed) {
controller.addError(e);
}
}
}
finally {
for (var controller in controllers) {
if (!controller.isClosed) {
controller.close();
}
} }
} }
controllers.clear(); controllers.clear();

View File

@@ -18,7 +18,9 @@ import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.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/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
part 'favorite_actions.dart'; part 'favorite_actions.dart';

View File

@@ -2,6 +2,10 @@ part of 'favorites_page.dart';
const _localAllFolderLabel = '^_^[%local_all%]^_^'; const _localAllFolderLabel = '^_^[%local_all%]^_^';
/// If the number of comics in a folder exceeds this limit, it will be
/// fetched asynchronously.
const _asyncDataFetchLimit = 500;
class _LocalFavoritesPage extends StatefulWidget { class _LocalFavoritesPage extends StatefulWidget {
const _LocalFavoritesPage({required this.folder, super.key}); const _LocalFavoritesPage({required this.folder, super.key});
@@ -35,40 +39,110 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
bool get isAllFolder => widget.folder == _localAllFolderLabel; bool get isAllFolder => widget.folder == _localAllFolderLabel;
LocalFavoritesManager get manager => LocalFavoritesManager();
bool isLoading = false;
var searchResults = <FavoriteItem>[];
void updateSearchResult() {
setState(() {
if (keyword.trim().isEmpty) {
searchResults = comics;
} else {
searchResults = [];
for (var comic in comics) {
if (matchKeyword(keyword, comic)) {
searchResults.add(comic);
}
}
}
});
}
void updateComics() { void updateComics() {
if (keyword.isEmpty) { if (isLoading) return;
setState(() { if (isAllFolder) {
if (isAllFolder) { var totalComics = manager.totalComics;
comics = LocalFavoritesManager().getAllComics(); if (totalComics < _asyncDataFetchLimit) {
} else { comics = manager.getAllComics();
comics = LocalFavoritesManager().getFolderComics(widget.folder); } else {
} isLoading = true;
}); manager
.getAllComicsAsync()
.minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
} else { } else {
setState(() { var folderComics = manager.folderComics(widget.folder);
if (isAllFolder) { if (folderComics < _asyncDataFetchLimit) {
comics = LocalFavoritesManager().search(keyword); comics = manager.getFolderComics(widget.folder);
} else { } else {
comics = isLoading = true;
LocalFavoritesManager().searchInFolder(widget.folder, keyword); manager
} .getFolderComicsAsync(widget.folder)
}); .minTime(const Duration(milliseconds: 200))
.then((value) {
if (mounted) {
setState(() {
isLoading = false;
comics = value;
});
}
});
}
} }
setState(() {});
}
bool matchKeyword(String keyword, FavoriteItem comic) {
var list = keyword.split(" ");
for (var k in list) {
if (k.isEmpty) continue;
if (comic.title.contains(k)) {
continue;
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) {
continue;
} else if (comic.tags.any((tag) {
if (tag == k) {
return true;
} else if (tag.contains(':') && tag.split(':')[1] == k) {
return true;
} else if (App.locale.languageCode != 'en' &&
tag.translateTagsToCN == k) {
return true;
}
return false;
})) {
continue;
} else if (comic.author == k) {
continue;
}
return false;
}
return true;
} }
@override @override
void initState() { void initState() {
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!; favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
if (!isAllFolder) { if (!isAllFolder) {
comics = LocalFavoritesManager().getFolderComics(widget.folder);
var (a, b) = LocalFavoritesManager().findLinked(widget.folder); var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
networkSource = a; networkSource = a;
networkFolder = b; networkFolder = b;
} else { } else {
comics = LocalFavoritesManager().getAllComics();
networkSource = null; networkSource = null;
networkFolder = null; networkFolder = null;
} }
comics = [];
updateComics();
LocalFavoritesManager().addListener(updateComics); LocalFavoritesManager().addListener(updateComics);
super.initState(); super.initState();
} }
@@ -81,16 +155,33 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
void selectAll() { void selectAll() {
setState(() { 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() { void invertSelection() {
setState(() { setState(() {
comics.asMap().forEach((k, v) { if (searchMode) {
selectedComics[v] = !selectedComics.putIfAbsent(v, () => false); for (var c in searchResults) {
}); if (selectedComics.containsKey(c)) {
selectedComics.removeWhere((k, v) => !v); selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
} else {
for (var c in comics) {
if (selectedComics.containsKey(c)) {
selectedComics.remove(c);
} else {
selectedComics[c] = true;
}
}
}
}); });
} }
@@ -215,7 +306,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () {
setState(() { setState(() {
keyword = "";
searchMode = true; searchMode = true;
updateSearchResult();
}); });
}, },
), ),
@@ -340,10 +433,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
"Selected @c comics".tlParams({"c": selectedComics.length})), "Selected @c comics".tlParams({"c": selectedComics.length})),
actions: [ actions: [
MenuButton(entries: [ MenuButton(entries: [
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.drive_file_move, icon: Icons.drive_file_move,
text: "Move to folder".tl, text: "Move to folder".tl,
onClick: () => favoriteOption('move')), onClick: () => favoriteOption('move')),
if (!isAllFolder)
MenuEntry( MenuEntry(
icon: Icons.copy, icon: Icons.copy,
text: "Copy to folder".tl, text: "Copy to folder".tl,
@@ -411,9 +506,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () { onPressed: () {
setState(() { setState(() {
searchMode = false; setState(() {
keyword = ""; searchMode = false;
updateComics(); });
}); });
}, },
), ),
@@ -422,132 +517,142 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Search".tl, hintText: "Search".tl,
border: InputBorder.none, border: UnderlineInputBorder(),
), ),
onChanged: (v) { onChanged: (v) {
keyword = v; keyword = v;
updateComics(); updateSearchResult();
}, },
), ).paddingBottom(8).paddingRight(8),
), ),
SliverGridComics( if (isLoading)
comics: comics, SliverToBoxAdapter(
selections: selectedComics, child: SizedBox(
menuBuilder: (c) { height: 200,
return [ child: const Center(
if (!isAllFolder) child: CircularProgressIndicator(),
),
),
)
else
SliverGridComics(
comics: searchMode ? searchResults : comics,
selections: selectedComics,
menuBuilder: (c) {
return [
if (!isAllFolder)
MenuEntry(
icon: Icons.delete,
text: "Delete".tl,
onClick: () {
LocalFavoritesManager().deleteComicWithId(
widget.folder,
c.id,
(c as FavoriteItem).type,
);
},
),
MenuEntry( MenuEntry(
icon: Icons.delete, icon: Icons.check,
text: "Delete".tl, text: "Select".tl,
onClick: () { onClick: () {
LocalFavoritesManager().deleteComicWithId( setState(() {
widget.folder, if (!multiSelectMode) {
c.id, multiSelectMode = true;
(c as FavoriteItem).type, }
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
},
),
MenuEntry(
icon: Icons.download,
text: "Download".tl,
onClick: () {
downloadComic(c as FavoriteItem);
context.showMessage(
message: "Download started".tl,
); );
}, },
), ),
MenuEntry( if (appdata.settings["onClickFavorite"] == "viewDetail")
icon: Icons.check, MenuEntry(
text: "Select".tl, icon: Icons.menu_book_outlined,
onClick: () { text: "Read".tl,
setState(() { onClick: () {
if (!multiSelectMode) { App.mainNavigatorKey?.currentContext?.to(
multiSelectMode = true; () => ReaderWithLoading(
} id: c.id,
if (selectedComics.containsKey(c as FavoriteItem)) { sourceKey: c.sourceKey,
selectedComics.remove(c); ),
_checkExitSelectMode(); );
} else { },
selectedComics[c] = true; ),
} ];
lastSelectedIndex = comics.indexOf(c); },
}); onTap: (c) {
}, if (multiSelectMode) {
), setState(() {
MenuEntry( if (selectedComics.containsKey(c as FavoriteItem)) {
icon: Icons.download, selectedComics.remove(c);
text: "Download".tl, _checkExitSelectMode();
onClick: () { } else {
downloadComic(c as FavoriteItem); selectedComics[c] = true;
context.showMessage( }
message: "Download started".tl, lastSelectedIndex = comics.indexOf(c);
); });
}, } else if (appdata.settings["onClickFavorite"] == "viewDetail") {
), App.mainNavigatorKey?.currentContext
if (appdata.settings["onClickFavorite"] == "viewDetail") ?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
MenuEntry(
icon: Icons.menu_book_outlined,
text: "Read".tl,
onClick: () {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
},
),
];
},
onTap: (c) {
if (multiSelectMode) {
setState(() {
if (selectedComics.containsKey(c as FavoriteItem)) {
selectedComics.remove(c);
_checkExitSelectMode();
} else {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
});
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
} else {
App.mainNavigatorKey?.currentContext?.to(
() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
),
);
}
},
onLongPressed: (c) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
}
lastSelectedIndex = comics.indexOf(c);
} else { } else {
if (lastSelectedIndex != null) { App.mainNavigatorKey?.currentContext?.to(
int start = lastSelectedIndex!; () => ReaderWithLoading(
int end = comics.indexOf(c as FavoriteItem); id: c.id,
if (start > end) { sourceKey: c.sourceKey,
int temp = start; ),
start = end; );
end = temp; }
},
onLongPressed: (c) {
setState(() {
if (!multiSelectMode) {
multiSelectMode = true;
if (!selectedComics.containsKey(c as FavoriteItem)) {
selectedComics[c] = true;
} }
lastSelectedIndex = comics.indexOf(c);
} else {
if (lastSelectedIndex != null) {
int start = lastSelectedIndex!;
int end = comics.indexOf(c as FavoriteItem);
if (start > end) {
int temp = start;
start = end;
end = temp;
}
for (int i = start; i <= end; i++) { for (int i = start; i <= end; i++) {
if (i == lastSelectedIndex) continue; if (i == lastSelectedIndex) continue;
var comic = comics[i]; var comic = comics[i];
if (selectedComics.containsKey(comic)) { if (selectedComics.containsKey(comic)) {
selectedComics.remove(comic); selectedComics.remove(comic);
} else { } else {
selectedComics[comic] = true; selectedComics[comic] = true;
}
} }
} }
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
} }
lastSelectedIndex = comics.indexOf(c as FavoriteItem); _checkExitSelectMode();
} });
_checkExitSelectMode(); },
}); ),
},
),
], ],
); );
body = AppScrollBar( body = AppScrollBar(
@@ -670,32 +775,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
return; return;
} }
if (option == 'move') { if (option == 'move') {
for (var c in selectedComics.keys) { var comics = selectedComics.keys
for (var s in selectedLocalFolders) { .map((e) => e as FavoriteItem)
LocalFavoritesManager().moveFavorite( .toList();
favPage.folder as String, for (var f in selectedLocalFolders) {
s, LocalFavoritesManager().batchMoveFavorites(
c.id, favPage.folder as String,
(c as FavoriteItem).type); f,
} comics,
);
} }
} else { } else {
for (var c in selectedComics.keys) { var comics = selectedComics.keys
for (var s in selectedLocalFolders) { .map((e) => e as FavoriteItem)
LocalFavoritesManager().addComic( .toList();
s, for (var f in selectedLocalFolders) {
FavoriteItem( LocalFavoritesManager().batchCopyFavorites(
id: c.id, favPage.folder as String,
name: c.title, f,
coverPath: c.cover, comics,
author: c.subtitle ?? '', );
type: ComicType((c.sourceKey == 'local'
? 0
: c.sourceKey.hashCode)),
tags: c.tags ?? [],
),
);
}
} }
} }
App.rootContext.pop(); App.rootContext.pop();
@@ -731,13 +830,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
} }
void _deleteComicWithId() { void _deleteComicWithId() {
for (var c in selectedComics.keys) { var toBeDeleted = selectedComics.keys.map((e) => e as FavoriteItem).toList();
LocalFavoritesManager().deleteComicWithId( LocalFavoritesManager().batchDeleteComics(widget.folder, toBeDeleted);
widget.folder,
c.id,
(c as FavoriteItem).type,
);
}
_cancel(); _cancel();
} }
} }
@@ -778,7 +872,10 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
@override @override
void dispose() { void dispose() {
if (changed) { 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(); super.dispose();
} }
@@ -813,27 +910,31 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
appBar: Appbar( appBar: Appbar(
title: Text("Reorder".tl), title: Text("Reorder".tl),
actions: [ actions: [
IconButton( Tooltip(
icon: const Icon(Icons.info_outline), message: "Information".tl,
onPressed: () { child: IconButton(
showInfoDialog( icon: const Icon(Icons.info_outline),
context: context, onPressed: () {
title: "Reorder".tl, showInfoDialog(
content: "Long press and drag to reorder.".tl, 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: "Reverse".tl,
child: IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
});
},
),
)
], ],
), ),
body: ReorderableBuilder<FavoriteItem>( body: ReorderableBuilder<FavoriteItem>(

View File

@@ -42,6 +42,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
findNetworkFolders(); findNetworkFolders();
appdata.settings.addListener(updateFolders); appdata.settings.addListener(updateFolders);
LocalFavoritesManager().addListener(updateFolders);
super.initState(); super.initState();
} }
@@ -49,6 +50,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
void dispose() { void dispose() {
super.dispose(); super.dispose();
appdata.settings.removeListener(updateFolders); appdata.settings.removeListener(updateFolders);
LocalFavoritesManager().removeListener(updateFolders);
} }
@override @override
@@ -86,51 +88,10 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
padding: widget.withAppbar padding: widget.withAppbar
? EdgeInsets.zero ? EdgeInsets.zero
: EdgeInsets.only(top: context.padding.top), : EdgeInsets.only(top: context.padding.top),
itemCount: folders.length + networkFolders.length + 2, itemCount: folders.length + networkFolders.length + 3,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) { if (index == 0) {
return Container( return buildLocalTitle();
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
Icons.local_activity,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders =
LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders =
LocalFavoritesManager().folderNames;
});
});
},
),
],
),
],
).paddingHorizontal(16),
);
} }
index--; index--;
if (index == 0) { if (index == 0) {
@@ -142,38 +103,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
} }
index -= folders.length; index -= folders.length;
if (index == 0) { if (index == 0) {
return Container( return buildNetworkTitle();
padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
).paddingHorizontal(16),
);
} }
index--; index--;
return buildNetworkFolder(networkFolders[index]); return buildNetworkFolder(networkFolders[index]);
@@ -185,8 +115,95 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
); );
} }
Widget buildLocalTitle() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
Icons.local_activity,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Local".tl),
const Spacer(),
MenuButton(
entries: [
MenuEntry(
icon: Icons.add,
text: 'Create Folder'.tl,
onClick: () {
newFolder().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
MenuEntry(
icon: Icons.reorder,
text: 'Sort'.tl,
onClick: () {
sortFolders().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
});
});
},
),
],
),
],
).paddingHorizontal(16),
);
}
Widget buildNetworkTitle() {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
).paddingHorizontal(16),
);
}
Widget buildLocalFolder(String name) { Widget buildLocalFolder(String name) {
bool isSelected = name == favPage.folder && !favPage.isNetwork; bool isSelected = name == favPage.folder && !favPage.isNetwork;
int count = 0;
if (name == _localAllFolderLabel) {
count = LocalFavoritesManager().totalComics;
} else {
count = LocalFavoritesManager().folderComics(name);
}
var folderName = name == _localAllFolderLabel
? "All".tl
: getFavoriteDataOrNull(name)?.title ?? name;
return InkWell( return InkWell(
onTap: () { onTap: () {
if (isSelected) { if (isSelected) {
@@ -211,9 +228,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
), ),
), ),
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: Text(name == _localAllFolderLabel child: Row(
? "All".tl children: [
: getFavoriteDataOrNull(name)?.title ?? name), Expanded(
child: Text(folderName),
),
Container(
margin: EdgeInsets.only(right: 8),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(count.toString()),
),
],
),
), ),
); );
} }

View File

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

View File

@@ -374,15 +374,21 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
}, },
), ),
actions: [ actions: [
if (comics.length == 1 && comics.first.hasChapters)
TextButton(
child: Text("Delete Chapters".tl),
onPressed: () {
context.pop();
showDeleteChaptersPopWindow(context, comics.first);
},
),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
context.pop(); context.pop();
for (var comic in comics) { LocalManager().batchDeleteComics(
LocalManager().deleteComic( comics,
comic, removeComicFile,
removeComicFile, );
);
}
isDeleted = true; isDeleted = true;
}, },
child: Text("Confirm".tl), child: Text("Confirm".tl),
@@ -497,3 +503,59 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
typedef ExportComicFunc = Future<File> Function( typedef ExportComicFunc = Future<File> Function(
LocalComic comic, String outFilePath); 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

@@ -152,12 +152,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
bool _dragInProgress = false; bool _dragInProgress = false;
bool get _enableDoubleTapToZoom => appdata.settings["enableDoubleTapToZoom"];
void onTapUp(TapUpDetails event) { void onTapUp(TapUpDetails event) {
if (_longPressInProgress) { if (_longPressInProgress) {
_longPressInProgress = false; _longPressInProgress = false;
return; return;
} }
final location = event.globalPosition; final location = event.globalPosition;
if (!_enableDoubleTapToZoom) {
onTap(location);
return;
}
final previousLocation = _previousEvent?.globalPosition; final previousLocation = _previousEvent?.globalPosition;
if (previousLocation != null) { if (previousLocation != null) {
if ((location - previousLocation).distanceSquared < if ((location - previousLocation).distanceSquared <

View File

@@ -119,7 +119,7 @@ class _GalleryModeState extends State<_GalleryMode>
/// [totalPages] is the total number of pages in the current chapter. /// [totalPages] is the total number of pages in the current chapter.
/// More than one images can be displayed on one page. /// More than one images can be displayed on one page.
int get totalPages { int get totalPages {
if (!showSingleImageOnFirstPage) { if (!reader.showSingleImageOnFirstPage) {
return (reader.images!.length / reader.imagesPerPage).ceil(); return (reader.images!.length / reader.imagesPerPage).ceil();
} else { } else {
return 1 + return 1 +
@@ -144,11 +144,9 @@ class _GalleryModeState extends State<_GalleryMode>
super.initState(); super.initState();
} }
bool get showSingleImageOnFirstPage => appdata.settings["showSingleImageOnFirstPage"];
/// Get the range of images for the given page. [page] is 1-based. /// Get the range of images for the given page. [page] is 1-based.
(int start, int end) getPageImagesRange(int page) { (int start, int end) getPageImagesRange(int page) {
if (showSingleImageOnFirstPage) { if (reader.showSingleImageOnFirstPage) {
if (page == 1) { if (page == 1) {
return (0, 1); return (0, 1);
} else { } else {
@@ -252,6 +250,7 @@ class _GalleryModeState extends State<_GalleryMode>
} }
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
childSize: reader.size * 2,
controller: photoViewControllers[index], controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0, minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0, maxScale: PhotoViewComputedScale.covered * 10.0,

View File

@@ -111,7 +111,16 @@ class _ReaderState extends State<Reader>
} }
@override @override
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil(); int get maxPage {
if (images == null) {
return 1;
}
if (!showSingleImageOnFirstPage) {
return (images!.length / imagesPerPage).ceil();
} else {
return 1 + ((images!.length - 1) / imagesPerPage).ceil();
}
}
ComicType get type => widget.type; ComicType get type => widget.type;
@@ -125,7 +134,8 @@ class _ReaderState extends State<Reader>
late ReaderMode mode; late ReaderMode mode;
@override @override
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait; bool get isPortrait =>
MediaQuery.of(context).orientation == Orientation.portrait;
History? history; History? history;
@@ -343,6 +353,9 @@ abstract mixin class _ImagePerPageHandler {
} }
} }
bool get showSingleImageOnFirstPage =>
appdata.settings["showSingleImageOnFirstPage"];
/// The number of images displayed on one screen /// The number of images displayed on one screen
int get imagesPerPage { int get imagesPerPage {
if (mode.isContinuous) return 1; if (mode.isContinuous) return 1;

View File

@@ -113,6 +113,14 @@ class _ReaderSettingsState extends State<ReaderSettings> {
}, },
), ),
), ),
_SwitchSetting(
title: 'Double tap to zoom'.tl,
settingKey: 'enableDoubleTapToZoom',
onChanged: () {
setState(() {});
widget.onChanged?.call('enableDoubleTapToZoom');
},
).toSliver(),
_SwitchSetting( _SwitchSetting(
title: 'Long press to zoom'.tl, title: 'Long press to zoom'.tl,
settingKey: 'enableLongPressToZoom', settingKey: 'enableLongPressToZoom',

View File

@@ -107,4 +107,15 @@ abstract class MapOrNull{
static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? i){ static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? i){
return i == null ? null : Map<K, V>.from(i); return i == null ? null : Map<K, V>.from(i);
} }
}
extension FutureExt<T> on Future<T>{
/// Wrap the future to make sure it will return at least the duration.
Future<T> minTime(Duration duration) async {
var res = await Future.wait([
this,
Future.delayed(duration),
]);
return res[0];
}
} }

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.12.0" version: "2.13.0"
battery_plus: battery_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -178,14 +178,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.7.0" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.2" version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@@ -425,10 +433,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_rust_bridge name: flutter_rust_bridge
sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611" sha256: b416ff56002789e636244fb4cc449f587656eff995e5a7169457eb0593fcaddb
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.9.0" version: "2.10.0"
flutter_saf: flutter_saf:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -516,10 +524,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: intl name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.20.2"
io: io:
dependency: transitive dependency: transitive
description: description:
@@ -540,10 +548,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.8" version: "10.0.9"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
@@ -758,11 +766,11 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: rhttp path: rhttp
ref: e7dca15ca543b5df49f3ada06016e874b79bce36 ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
resolved-ref: e7dca15ca543b5df49f3ada06016e874b79bce36 resolved-ref: "1f0ff50336062c5f809c256726dc55cd30b9ce59"
url: "https://github.com/wgh136/rhttp" url: "https://github.com/wgh136/rhttp"
source: git source: git
version: "0.11.0" version: "0.12.0"
screen_retriever: screen_retriever:
dependency: transitive dependency: transitive
description: description:
@@ -1029,10 +1037,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.1" version: "15.0.0"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -1099,5 +1107,5 @@ packages:
source: hosted source: hosted
version: "0.0.12" version: "0.0.12"
sdks: sdks:
dart: ">=3.7.0 <4.0.0" dart: ">=3.8.0 <4.0.0"
flutter: ">=3.29.3" flutter: ">=3.32.0"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.4.1+141 version: 1.4.4+144
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
flutter: 3.29.3 flutter: 3.32.0
dependencies: dependencies:
flutter: flutter:
@@ -58,10 +58,10 @@ dependencies:
git: git:
url: https://github.com/venera-app/lodepng_flutter url: https://github.com/venera-app/lodepng_flutter
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1 ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
rhttp: rhttp:
git: git:
url: https://github.com/wgh136/rhttp url: https://github.com/wgh136/rhttp
ref: e7dca15ca543b5df49f3ada06016e874b79bce36 ref: 1f0ff50336062c5f809c256726dc55cd30b9ce59
path: rhttp path: rhttp
webdav_client: webdav_client:
git: git:
@@ -85,6 +85,7 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
yaml: ^3.1.3 yaml: ^3.1.3
enough_convert: ^1.6.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: