mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Compare commits
32 Commits
v1.4.1-dev
...
v1.4.4
Author | SHA1 | Date | |
---|---|---|---|
23afafd1d6 | |||
![]() |
3b6e0adbbb | ||
20a57c7a36 | |||
665f50ed2a | |||
55733ef505 | |||
0c46214619 | |||
749a1a47fb | |||
76e9ef87d4 | |||
dcd6466547 | |||
ed70fdba93 | |||
ded0068ea6 | |||
![]() |
7dc6be622a | ||
![]() |
88f093f7e5 | ||
8f357b3e6c | |||
9ee82975e8 | |||
![]() |
9f048685e4 | ||
![]() |
bc1f5e11b5 | ||
1f2147ef72 | |||
fba365fd93 | |||
a5e3fbaee5 | |||
190e645a12 | |||
![]() |
8a83ff5367 | ||
6e14942dab | |||
146fc70143 | |||
b37ea01aca | |||
bf7b90313a | |||
929c1a9d91 | |||
9ff68d0701 | |||
dfd15ed34a | |||
![]() |
dfe2a0db6a | ||
c6714f79b6 | |||
6877aa120f |
@@ -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,
|
||||
|
@@ -390,7 +390,11 @@
|
||||
"Show single image on first page": "在首页显示单张图片",
|
||||
"Click to select an image": "点击选择一张图片",
|
||||
"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": {
|
||||
"Home": "首頁",
|
||||
@@ -783,6 +787,10 @@
|
||||
"Show single image on first page": "在首頁顯示單張圖片",
|
||||
"Click to select an image": "點擊選擇一張圖片",
|
||||
"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": "刪除章節"
|
||||
}
|
||||
}
|
||||
}
|
@@ -46,12 +46,14 @@
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Choose images</string>
|
||||
<string>Choose images</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<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>
|
||||
</plist>
|
||||
|
@@ -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(
|
||||
|
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.4.1";
|
||||
final version = "1.4.4";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,6 +196,7 @@ class Settings with ChangeNotifier {
|
||||
'comicListDisplayMode': 'paging', // paging, continuous
|
||||
'showPageNumberInReader': true,
|
||||
'showSingleImageOnFirstPage': false,
|
||||
'enableDoubleTapToZoom': true,
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
@@ -193,7 +205,9 @@ class Settings with ChangeNotifier {
|
||||
|
||||
operator []=(String key, dynamic value) {
|
||||
_data[key] = value;
|
||||
notifyListeners();
|
||||
if (key != "dataVersion") {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
@@ -209,7 +211,22 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
|
||||
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 {
|
||||
counts = {};
|
||||
_db = sqlite3.open("${App.dataPath}/local_favorite.db");
|
||||
_db.execute("""
|
||||
create table if not exists folder_order (
|
||||
@@ -256,6 +273,13 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
} else {
|
||||
appdata.settings['followUpdatesFolder'] = null;
|
||||
}
|
||||
initCounts();
|
||||
}
|
||||
|
||||
void initCounts() {
|
||||
for (var folder in folderNames) {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> find(String id, ComicType type) {
|
||||
@@ -357,6 +381,23 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
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() {
|
||||
var res = <FavoriteItem>{};
|
||||
for (final folder in folderNames) {
|
||||
@@ -368,6 +409,26 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
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) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
@@ -433,6 +494,7 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
);
|
||||
""");
|
||||
notifyListeners();
|
||||
counts[name] = 0;
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -547,6 +609,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
""", [updateTime, comic.id, comic.type.value]);
|
||||
}
|
||||
}
|
||||
if (counts[folder] == null) {
|
||||
counts[folder] = count(folder);
|
||||
} else {
|
||||
counts[folder] = counts[folder]! + 1;
|
||||
}
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
@@ -586,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;
|
||||
@@ -596,14 +759,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
delete from folder_order
|
||||
where folder_name == ?;
|
||||
""", [name]);
|
||||
counts.remove(name);
|
||||
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);
|
||||
@@ -611,6 +770,60 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
delete from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [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();
|
||||
}
|
||||
|
||||
@@ -641,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();
|
||||
}
|
||||
|
||||
@@ -670,6 +898,8 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
set folder_name = ?
|
||||
where folder_name == ?;
|
||||
""", [after, before]);
|
||||
counts[after] = counts[before] ?? 0;
|
||||
counts.remove(before);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
@@ -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';
|
||||
@@ -305,6 +306,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 +406,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();
|
||||
}
|
||||
}
|
||||
|
@@ -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":
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -461,7 +462,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 +547,95 @@ 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]) {
|
||||
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 {
|
||||
|
@@ -234,19 +234,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();
|
||||
|
@@ -18,7 +18,9 @@ import 'package:venera/network/download.dart';
|
||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||
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/tags_translation.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
part 'favorite_actions.dart';
|
||||
|
@@ -2,6 +2,10 @@ part of 'favorites_page.dart';
|
||||
|
||||
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 {
|
||||
const _LocalFavoritesPage({required this.folder, super.key});
|
||||
|
||||
@@ -35,40 +39,110 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
|
||||
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() {
|
||||
if (keyword.isEmpty) {
|
||||
setState(() {
|
||||
if (isAllFolder) {
|
||||
comics = LocalFavoritesManager().getAllComics();
|
||||
} else {
|
||||
comics = LocalFavoritesManager().getFolderComics(widget.folder);
|
||||
}
|
||||
});
|
||||
if (isLoading) return;
|
||||
if (isAllFolder) {
|
||||
var totalComics = manager.totalComics;
|
||||
if (totalComics < _asyncDataFetchLimit) {
|
||||
comics = manager.getAllComics();
|
||||
} else {
|
||||
isLoading = true;
|
||||
manager
|
||||
.getAllComicsAsync()
|
||||
.minTime(const Duration(milliseconds: 200))
|
||||
.then((value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
comics = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setState(() {
|
||||
if (isAllFolder) {
|
||||
comics = LocalFavoritesManager().search(keyword);
|
||||
} else {
|
||||
comics =
|
||||
LocalFavoritesManager().searchInFolder(widget.folder, keyword);
|
||||
}
|
||||
});
|
||||
var folderComics = manager.folderComics(widget.folder);
|
||||
if (folderComics < _asyncDataFetchLimit) {
|
||||
comics = manager.getFolderComics(widget.folder);
|
||||
} else {
|
||||
isLoading = true;
|
||||
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
|
||||
void initState() {
|
||||
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
|
||||
if (!isAllFolder) {
|
||||
comics = LocalFavoritesManager().getFolderComics(widget.folder);
|
||||
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
||||
networkSource = a;
|
||||
networkFolder = b;
|
||||
} else {
|
||||
comics = LocalFavoritesManager().getAllComics();
|
||||
networkSource = null;
|
||||
networkFolder = null;
|
||||
}
|
||||
comics = [];
|
||||
updateComics();
|
||||
LocalFavoritesManager().addListener(updateComics);
|
||||
super.initState();
|
||||
}
|
||||
@@ -81,16 +155,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,7 +306,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
keyword = "";
|
||||
searchMode = true;
|
||||
updateSearchResult();
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -340,10 +433,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,
|
||||
@@ -411,9 +506,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
keyword = "";
|
||||
updateComics();
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -422,132 +517,142 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search".tl,
|
||||
border: InputBorder.none,
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onChanged: (v) {
|
||||
keyword = v;
|
||||
updateComics();
|
||||
updateSearchResult();
|
||||
},
|
||||
),
|
||||
).paddingBottom(8).paddingRight(8),
|
||||
),
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
selections: selectedComics,
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
if (!isAllFolder)
|
||||
if (isLoading)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: const Center(
|
||||
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(
|
||||
icon: Icons.delete,
|
||||
text: "Delete".tl,
|
||||
icon: Icons.check,
|
||||
text: "Select".tl,
|
||||
onClick: () {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
widget.folder,
|
||||
c.id,
|
||||
(c as FavoriteItem).type,
|
||||
setState(() {
|
||||
if (!multiSelectMode) {
|
||||
multiSelectMode = true;
|
||||
}
|
||||
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(
|
||||
icon: Icons.check,
|
||||
text: "Select".tl,
|
||||
onClick: () {
|
||||
setState(() {
|
||||
if (!multiSelectMode) {
|
||||
multiSelectMode = true;
|
||||
}
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (appdata.settings["onClickFavorite"] == "viewDetail")
|
||||
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);
|
||||
if (appdata.settings["onClickFavorite"] == "viewDetail")
|
||||
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 {
|
||||
if (lastSelectedIndex != null) {
|
||||
int start = lastSelectedIndex!;
|
||||
int end = comics.indexOf(c as FavoriteItem);
|
||||
if (start > end) {
|
||||
int temp = start;
|
||||
start = end;
|
||||
end = temp;
|
||||
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 {
|
||||
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++) {
|
||||
if (i == lastSelectedIndex) continue;
|
||||
for (int i = start; i <= end; i++) {
|
||||
if (i == lastSelectedIndex) continue;
|
||||
|
||||
var comic = comics[i];
|
||||
if (selectedComics.containsKey(comic)) {
|
||||
selectedComics.remove(comic);
|
||||
} else {
|
||||
selectedComics[comic] = true;
|
||||
var comic = comics[i];
|
||||
if (selectedComics.containsKey(comic)) {
|
||||
selectedComics.remove(comic);
|
||||
} else {
|
||||
selectedComics[comic] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
|
||||
}
|
||||
lastSelectedIndex = comics.indexOf(c as FavoriteItem);
|
||||
}
|
||||
_checkExitSelectMode();
|
||||
});
|
||||
},
|
||||
),
|
||||
_checkExitSelectMode();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
body = AppScrollBar(
|
||||
@@ -670,32 +775,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();
|
||||
@@ -731,13 +830,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();
|
||||
}
|
||||
}
|
||||
@@ -778,7 +872,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();
|
||||
}
|
||||
@@ -813,27 +910,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>(
|
||||
|
@@ -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
|
||||
@@ -86,51 +88,10 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
padding: widget.withAppbar
|
||||
? EdgeInsets.zero
|
||||
: EdgeInsets.only(top: context.padding.top),
|
||||
itemCount: folders.length + networkFolders.length + 2,
|
||||
itemCount: folders.length + networkFolders.length + 3,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
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),
|
||||
);
|
||||
return buildLocalTitle();
|
||||
}
|
||||
index--;
|
||||
if (index == 0) {
|
||||
@@ -142,38 +103,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
}
|
||||
index -= folders.length;
|
||||
if (index == 0) {
|
||||
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),
|
||||
);
|
||||
return buildNetworkTitle();
|
||||
}
|
||||
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) {
|
||||
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(
|
||||
onTap: () {
|
||||
if (isSelected) {
|
||||
@@ -211,9 +228,25 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: Text(name == _localAllFolderLabel
|
||||
? "All".tl
|
||||
: getFavoriteDataOrNull(name)?.title ?? name),
|
||||
child: Row(
|
||||
children: [
|
||||
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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -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: () {
|
||||
|
@@ -374,15 +374,21 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
},
|
||||
),
|
||||
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,
|
||||
);
|
||||
isDeleted = true;
|
||||
},
|
||||
child: Text("Confirm".tl),
|
||||
@@ -497,3 +503,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),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -152,12 +152,18 @@ class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDet
|
||||
|
||||
bool _dragInProgress = false;
|
||||
|
||||
bool get _enableDoubleTapToZoom => appdata.settings["enableDoubleTapToZoom"];
|
||||
|
||||
void onTapUp(TapUpDetails event) {
|
||||
if (_longPressInProgress) {
|
||||
_longPressInProgress = false;
|
||||
return;
|
||||
}
|
||||
final location = event.globalPosition;
|
||||
if (!_enableDoubleTapToZoom) {
|
||||
onTap(location);
|
||||
return;
|
||||
}
|
||||
final previousLocation = _previousEvent?.globalPosition;
|
||||
if (previousLocation != null) {
|
||||
if ((location - previousLocation).distanceSquared <
|
||||
|
@@ -119,7 +119,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
/// [totalPages] is the total number of pages in the current chapter.
|
||||
/// More than one images can be displayed on one page.
|
||||
int get totalPages {
|
||||
if (!showSingleImageOnFirstPage) {
|
||||
if (!reader.showSingleImageOnFirstPage) {
|
||||
return (reader.images!.length / reader.imagesPerPage).ceil();
|
||||
} else {
|
||||
return 1 +
|
||||
@@ -144,11 +144,9 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
super.initState();
|
||||
}
|
||||
|
||||
bool get showSingleImageOnFirstPage => appdata.settings["showSingleImageOnFirstPage"];
|
||||
|
||||
/// Get the range of images for the given page. [page] is 1-based.
|
||||
(int start, int end) getPageImagesRange(int page) {
|
||||
if (showSingleImageOnFirstPage) {
|
||||
if (reader.showSingleImageOnFirstPage) {
|
||||
if (page == 1) {
|
||||
return (0, 1);
|
||||
} else {
|
||||
@@ -252,6 +250,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
}
|
||||
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
childSize: reader.size * 2,
|
||||
controller: photoViewControllers[index],
|
||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||
maxScale: PhotoViewComputedScale.covered * 10.0,
|
||||
|
@@ -111,7 +111,16 @@ class _ReaderState extends State<Reader>
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
@@ -125,7 +134,8 @@ class _ReaderState extends State<Reader>
|
||||
late ReaderMode mode;
|
||||
|
||||
@override
|
||||
bool get isPortrait => MediaQuery.of(context).orientation == Orientation.portrait;
|
||||
bool get isPortrait =>
|
||||
MediaQuery.of(context).orientation == Orientation.portrait;
|
||||
|
||||
History? history;
|
||||
|
||||
@@ -343,6 +353,9 @@ abstract mixin class _ImagePerPageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
bool get showSingleImageOnFirstPage =>
|
||||
appdata.settings["showSingleImageOnFirstPage"];
|
||||
|
||||
/// The number of images displayed on one screen
|
||||
int get imagesPerPage {
|
||||
if (mode.isContinuous) return 1;
|
||||
|
@@ -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(
|
||||
title: 'Long press to zoom'.tl,
|
||||
settingKey: 'enableLongPressToZoom',
|
||||
|
@@ -107,4 +107,15 @@ abstract class MapOrNull{
|
||||
static Map<K, V>? from<K, V>(Map<dynamic, dynamic>? 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];
|
||||
}
|
||||
}
|
42
pubspec.lock
42
pubspec.lock
@@ -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:
|
||||
@@ -178,14 +178,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 +433,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 +524,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 +548,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 +766,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 +1037,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 +1107,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.0"
|
||||
|
13
pubspec.yaml
13
pubspec.yaml
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.4.1+141
|
||||
version: 1.4.4+144
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
flutter: 3.29.3
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.32.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -58,10 +58,10 @@ dependencies:
|
||||
git:
|
||||
url: https://github.com/venera-app/lodepng_flutter
|
||||
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
|
||||
rhttp:
|
||||
git:
|
||||
rhttp:
|
||||
git:
|
||||
url: https://github.com/wgh136/rhttp
|
||||
ref: e7dca15ca543b5df49f3ada06016e874b79bce36
|
||||
ref: 1f0ff50336062c5f809c256726dc55cd30b9ce59
|
||||
path: rhttp
|
||||
webdav_client:
|
||||
git:
|
||||
@@ -85,6 +85,7 @@ dependencies:
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
yaml: ^3.1.3
|
||||
enough_convert: ^1.6.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Reference in New Issue
Block a user