mirror of
https://github.com/venera-app/venera.git
synced 2025-12-15 14:41:15 +00:00
Compare commits
31 Commits
v1.5.1
...
feat/js-di
| Author | SHA1 | Date | |
|---|---|---|---|
| e8d98e8274 | |||
| 09a1d2821c | |||
|
|
7842b5a1ac | ||
|
|
079f574e2f | ||
|
|
b08f11f6ac | ||
|
|
cd925df125 | ||
|
|
8c87c4a906 | ||
|
|
c234a53518 | ||
| 49fd64358c | |||
| 3426d707fe | |||
| ebc106d45b | |||
| 0cda9a2921 | |||
| 0eb5d76687 | |||
| 29d25f7fcd | |||
| 7d60e78f27 | |||
|
|
e93b56a008 | ||
|
|
d10873a903 | ||
|
|
2d27f7d650 | ||
| e1fbdfbd50 | |||
| 0a5b70b161 | |||
|
|
5a76a10fb2 | ||
| 9173665afe | |||
|
|
f09e766a8a | ||
| e0ea449c17 | |||
| c438a84537 | |||
| 8c625e212a | |||
| ab786ed2ab | |||
| d9303aab2e | |||
|
|
b7f79476c8 | ||
|
|
44bcce4385 | ||
|
|
6ce6066de2 |
5
.github/workflows/main.yml
vendored
5
.github/workflows/main.yml
vendored
@@ -116,6 +116,8 @@ jobs:
|
||||
run: |
|
||||
choco install yq -y
|
||||
pip install httpx
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup --no-progress
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
@@ -170,6 +172,9 @@ jobs:
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
- name: "Patch font"
|
||||
run: |
|
||||
dart run patch/font.dart
|
||||
- run: python3 debian/build.py arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
13
.github/workflows/update_alt_store.yml
vendored
13
.github/workflows/update_alt_store.yml
vendored
@@ -40,8 +40,19 @@ jobs:
|
||||
if git diff --staged --quiet; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# Create a new branch for the PR
|
||||
branch_name="update-altstore-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -m "Updated source with latest release"
|
||||
git push
|
||||
git push -u origin "$branch_name"
|
||||
|
||||
# Create PR using GitHub CLI
|
||||
gh pr create \
|
||||
--title "Update AltStore source with latest release" \
|
||||
--body "This PR updates the alt_store.json file with the latest release information." \
|
||||
--head "$branch_name" \
|
||||
--base master
|
||||
|
||||
echo "changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
"bundleIdentifier": "com.github.wgh136.venera",
|
||||
"developerName": "wgh136",
|
||||
"subtitle": "A comic reader that supports reading local and network comics",
|
||||
"version": "1.4.5",
|
||||
"versionDate": "2025-06-18",
|
||||
"versionDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||
"version": "1.5.3",
|
||||
"versionDate": "2025-10-13",
|
||||
"versionDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"localizedDescription": "A comic reader that supports reading local and network comics",
|
||||
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||
"tintColor": "#0784FC",
|
||||
"category": "utilities",
|
||||
"size": 14960268,
|
||||
"size": 15047841,
|
||||
"appPermissions": {
|
||||
"entitlements": [
|
||||
"application-identifier",
|
||||
@@ -39,6 +39,13 @@
|
||||
}
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"date": "2025-10-13",
|
||||
"localizedDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"size": 15047841
|
||||
},
|
||||
{
|
||||
"version": "1.4.5",
|
||||
"date": "2025-06-18",
|
||||
@@ -59,6 +66,16 @@
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.4.5 - Venera 18/06/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
|
||||
},
|
||||
{
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": "Update of Venera just got released!",
|
||||
"date": "2025-10-13T12:47:27Z",
|
||||
"identifier": "release-v1.5.3",
|
||||
"notify": true,
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.5.3 - Venera 13/10/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.5.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -23,7 +23,7 @@ linter:
|
||||
rules:
|
||||
collection_methods_unrelated_type: false
|
||||
use_build_context_synchronously: false
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
avoid_print: false
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
|
||||
@@ -84,9 +84,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Temporarily solution to fix crash
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -1334,7 +1334,7 @@ let UI = {
|
||||
* Show an input dialog
|
||||
* @param title {string}
|
||||
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
|
||||
* @param image {string?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
|
||||
* @param image {string | ArrayBuffer | null | undefined} - Since 1.4.6, you can pass an image url to show an image in the dialog. Since 1.5.3, you can also pass an ArrayBuffer to show a custom image.
|
||||
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
|
||||
*/
|
||||
showInputDialog: (title, validator, image) => {
|
||||
|
||||
@@ -83,7 +83,10 @@
|
||||
"New Folder": "新建文件夹",
|
||||
"Reading": "阅读中",
|
||||
"Appearance": "外观",
|
||||
"Network Favorites": "网络收藏",
|
||||
"Local Favorites": "本地收藏",
|
||||
"Show local favorites before network favorites": "在网络收藏之前显示本地收藏",
|
||||
"Auto close favorite panel after operation": "自动关闭收藏面板",
|
||||
"APP": "应用",
|
||||
"About": "关于",
|
||||
"Display mode of comic tile": "漫画缩略图的显示模式",
|
||||
@@ -497,7 +500,10 @@
|
||||
"New Folder": "建立資料夾",
|
||||
"Reading": "閱讀中",
|
||||
"Appearance": "外觀",
|
||||
"Network Favorites": "網路收藏",
|
||||
"Local Favorites": "本機收藏",
|
||||
"Show local favorites before network favorites": "在網路收藏之前顯示本機收藏",
|
||||
"Auto close favorite panel after operation": "自動關閉收藏面板",
|
||||
"APP": "應用",
|
||||
"About": "關於",
|
||||
"Display mode of comic tile": "漫畫縮圖的顯示模式",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -40,7 +42,6 @@ mixin class JsUiApi {
|
||||
var image = message['image'];
|
||||
if (title is! String) return;
|
||||
if (validator != null && validator is! JSInvokable) return;
|
||||
if (image != null && image is! String) return;
|
||||
return _showInputDialog(title, validator, image);
|
||||
case 'showSelectDialog':
|
||||
var title = message['title'];
|
||||
@@ -126,13 +127,25 @@ mixin class JsUiApi {
|
||||
controller?.close();
|
||||
}
|
||||
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async {
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, dynamic image) async {
|
||||
String? result;
|
||||
var func = validator == null ? null : JSAutoFreeFunction(validator);
|
||||
String? imageUrl;
|
||||
Uint8List? imageData;
|
||||
if (image != null) {
|
||||
if (image is String) {
|
||||
imageUrl = image;
|
||||
} else if (image is Uint8List) {
|
||||
imageData = image;
|
||||
} else if (image is List<int>) {
|
||||
imageData = Uint8List.fromList(image);
|
||||
}
|
||||
}
|
||||
await showInputDialog(
|
||||
context: App.rootContext,
|
||||
title: title,
|
||||
image: image,
|
||||
image: imageUrl,
|
||||
imageData: imageData,
|
||||
onConfirm: (v) {
|
||||
if (func != null) {
|
||||
var res = func.call([v]);
|
||||
|
||||
@@ -360,6 +360,7 @@ Future<void> showInputDialog({
|
||||
String cancelText = "Cancel",
|
||||
RegExp? inputValidator,
|
||||
String? image,
|
||||
Uint8List? imageData,
|
||||
}) {
|
||||
var controller = TextEditingController(text: initialValue);
|
||||
bool isLoading = false;
|
||||
@@ -379,6 +380,11 @@ Future<void> showInputDialog({
|
||||
height: 108,
|
||||
child: Image.network(image, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
if (image == null && imageData != null)
|
||||
SizedBox(
|
||||
height: 108,
|
||||
child: Image.memory(imageData, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.5.1";
|
||||
final version = "1.5.3";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
enableIOSGesture
|
||||
enableIOSGesture && App.isIOS
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
@@ -302,7 +302,7 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ class Settings with ChangeNotifier {
|
||||
'comicSpecificSettings': <String, Map<String, dynamic>>{},
|
||||
'ignoreBadCertificate': false,
|
||||
'readerScrollSpeed': 1.0, // 0.5 - 3.0
|
||||
'localFavoritesFirst': true,
|
||||
'autoCloseFavoritePanel': false,
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:isolate';
|
||||
@@ -213,12 +214,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
|
||||
late Map<String, int> counts;
|
||||
|
||||
var _hashedIds = <int, int>{};
|
||||
|
||||
int get totalComics {
|
||||
int total = 0;
|
||||
for (var t in counts.values) {
|
||||
total += t;
|
||||
}
|
||||
return total;
|
||||
return _hashedIds.length;
|
||||
}
|
||||
|
||||
int folderComics(String folder) {
|
||||
@@ -280,6 +279,48 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
for (var folder in folderNames) {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
_initHashedIds(folderNames, _db.handle).then((value) {
|
||||
_hashedIds = value;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void refreshHashedIds() {
|
||||
_initHashedIds(folderNames, _db.handle).then((value) {
|
||||
_hashedIds = value;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void reduceHashedId(String id, int type) {
|
||||
var hash = id.hashCode ^ type;
|
||||
if (_hashedIds.containsKey(hash)) {
|
||||
if (_hashedIds[hash]! > 1) {
|
||||
_hashedIds[hash] = _hashedIds[hash]! - 1;
|
||||
} else {
|
||||
_hashedIds.remove(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<int, int>> _initHashedIds(
|
||||
List<String> folders, Pointer<void> p) {
|
||||
return Isolate.run(() {
|
||||
var db = sqlite3.fromPointer(p);
|
||||
var hashedIds = <int, int>{};
|
||||
for (var folder in folders) {
|
||||
var rows = db.select("""
|
||||
select id, type from "$folder";
|
||||
""");
|
||||
for (var row in rows) {
|
||||
var id = row["id"] as String;
|
||||
var type = row["type"] as int;
|
||||
var hash = id.hashCode ^ type;
|
||||
hashedIds[hash] = (hashedIds[hash] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return hashedIds;
|
||||
});
|
||||
}
|
||||
|
||||
List<String> find(String id, ComicType type) {
|
||||
@@ -559,7 +600,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
/// return true if success, false if already exists
|
||||
bool addComic(String folder, FavoriteItem comic,
|
||||
[int? order, String? updateTime]) {
|
||||
_modifiedAfterLastCache = true;
|
||||
if (!existsFolder(folder)) {
|
||||
throw Exception("Folder does not exists");
|
||||
}
|
||||
@@ -614,14 +654,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
} else {
|
||||
counts[folder] = counts[folder]! + 1;
|
||||
}
|
||||
var hash = comic.id.hashCode ^ comic.type.value;
|
||||
_hashedIds[hash] = (_hashedIds[hash] ?? 0) + 1;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void moveFavorite(
|
||||
String sourceFolder, String targetFolder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -655,8 +695,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
|
||||
void batchMoveFavorites(
|
||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -691,25 +729,15 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
_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);
|
||||
}
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
counts[sourceFolder] = count(sourceFolder);
|
||||
refreshHashedIds();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchCopyFavorites(
|
||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||
_modifiedAfterLastCache = true;
|
||||
|
||||
if (!existsFolder(sourceFolder)) {
|
||||
throw Exception("Source folder does not exist");
|
||||
}
|
||||
@@ -740,18 +768,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
_db.execute("COMMIT");
|
||||
|
||||
// Update counts
|
||||
if (counts[targetFolder] == null) {
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
} else {
|
||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
||||
}
|
||||
counts[targetFolder] = count(targetFolder);
|
||||
refreshHashedIds();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// delete a folder
|
||||
void deleteFolder(String name) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("""
|
||||
drop table "$name";
|
||||
""");
|
||||
@@ -760,11 +784,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
where folder_name == ?;
|
||||
""", [name]);
|
||||
counts.remove(name);
|
||||
refreshHashedIds();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void deleteComicWithId(String folder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
LocalFavoriteImageProvider.delete(id, type.value);
|
||||
_db.execute("""
|
||||
delete from "$folder"
|
||||
@@ -775,11 +799,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
} else {
|
||||
counts[folder] = count(folder);
|
||||
}
|
||||
reduceHashedId(id, type.value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
try {
|
||||
for (var comic in comics) {
|
||||
@@ -800,11 +824,13 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
_db.execute("COMMIT");
|
||||
for (var comic in comics) {
|
||||
reduceHashedId(comic.id, comic.type.value);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("BEGIN TRANSACTION");
|
||||
var folderNames = _getFolderNamesWithDB();
|
||||
try {
|
||||
@@ -824,6 +850,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
}
|
||||
initCounts();
|
||||
_db.execute("COMMIT");
|
||||
for (var comic in comics) {
|
||||
var hash = comic.id.hashCode ^ comic.type.value;
|
||||
_hashedIds.remove(hash);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -908,7 +938,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
markAsRead(id, type);
|
||||
return;
|
||||
}
|
||||
_modifiedAfterLastCache = true;
|
||||
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
|
||||
for (final folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
@@ -1029,28 +1058,9 @@ class LocalFavoritesManager with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final _cachedFavoritedIds = <String, bool>{};
|
||||
|
||||
bool isExist(String id, ComicType type) {
|
||||
if (_modifiedAfterLastCache) {
|
||||
_cacheFavoritedIds();
|
||||
}
|
||||
return _cachedFavoritedIds.containsKey("$id@${type.value}");
|
||||
}
|
||||
|
||||
bool _modifiedAfterLastCache = true;
|
||||
|
||||
void _cacheFavoritedIds() {
|
||||
_modifiedAfterLastCache = false;
|
||||
_cachedFavoritedIds.clear();
|
||||
for (var folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select id, type from "$folder";
|
||||
""");
|
||||
for (var row in rows) {
|
||||
_cachedFavoritedIds["${row["id"]}@${row["type"]}"] = true;
|
||||
}
|
||||
}
|
||||
var hash = id.hashCode ^ type.value;
|
||||
return _hashedIds.containsKey(hash);
|
||||
}
|
||||
|
||||
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/utils/channel.dart';
|
||||
|
||||
class ComicUpdateResult {
|
||||
final bool updated;
|
||||
@@ -62,6 +63,7 @@ Future<ComicUpdateResult> updateComic(
|
||||
return ComicUpdateResult(updated, null);
|
||||
} catch (e, s) {
|
||||
Log.error("Check Updates", e, s);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
retries--;
|
||||
if (retries == 0) {
|
||||
return ComicUpdateResult(false, e.toString());
|
||||
@@ -114,23 +116,50 @@ void updateFolderBase(
|
||||
current = 0;
|
||||
stream.add(UpdateProgress(total, current, errors, updated));
|
||||
|
||||
var futures = <Future>[];
|
||||
for (var comic in comicsToUpdate) {
|
||||
var future = updateComic(comic, folder).then((result) {
|
||||
current++;
|
||||
if (result.updated) {
|
||||
updated++;
|
||||
var channel = Channel<FavoriteItemWithUpdateInfo>(10);
|
||||
|
||||
// Producer
|
||||
() async {
|
||||
var c = 0;
|
||||
for (var comic in comicsToUpdate) {
|
||||
await channel.push(comic);
|
||||
c++;
|
||||
// Throttle
|
||||
if (c % 5 == 0) {
|
||||
var delay = c % 100 + 1;
|
||||
if (delay > 10) {
|
||||
delay = 10;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: delay));
|
||||
}
|
||||
if (result.errorMessage != null) {
|
||||
errors++;
|
||||
}
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// Consumers
|
||||
var updateFutures = <Future>[];
|
||||
for (var i = 0; i < 5; i++) {
|
||||
var f = () async {
|
||||
while (true) {
|
||||
var comic = await channel.pop();
|
||||
if (comic == null) {
|
||||
break;
|
||||
}
|
||||
var result = await updateComic(comic, folder);
|
||||
current++;
|
||||
if (result.updated) {
|
||||
updated++;
|
||||
}
|
||||
if (result.errorMessage != null) {
|
||||
errors++;
|
||||
}
|
||||
stream.add(UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
|
||||
}
|
||||
stream.add(
|
||||
UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
|
||||
});
|
||||
futures.add(future);
|
||||
}();
|
||||
updateFutures.add(f);
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
await Future.wait(updateFutures);
|
||||
|
||||
if (updated > 0) {
|
||||
LocalFavoritesManager().notifyChanges();
|
||||
|
||||
@@ -423,6 +423,7 @@ class LocalManager with ChangeNotifier {
|
||||
if (comic.hasChapters) {
|
||||
var cid =
|
||||
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
|
||||
cid = getChapterDirectoryName(cid);
|
||||
directory = Directory(FilePath.join(directory.path, cid));
|
||||
}
|
||||
var files = <File>[];
|
||||
@@ -600,7 +601,10 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
var shouldRemovedDirs = <Directory>[];
|
||||
for (var chapter in chapters) {
|
||||
var dir = Directory(FilePath.join(c.baseDir, chapter));
|
||||
var dir = Directory(FilePath.join(
|
||||
c.baseDir,
|
||||
getChapterDirectoryName(chapter),
|
||||
));
|
||||
if (dir.existsSync()) {
|
||||
shouldRemovedDirs.add(dir);
|
||||
}
|
||||
@@ -668,6 +672,21 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static String getChapterDirectoryName(String name) {
|
||||
var builder = StringBuffer();
|
||||
for (var i = 0; i < name.length; i++) {
|
||||
var char = name[i];
|
||||
if (char == '/' || char == '\\' || char == ':' || char == '*' ||
|
||||
char == '?'
|
||||
|| char == '"' || char == '<' || char == '>' || char == '|') {
|
||||
builder.write('_');
|
||||
} else {
|
||||
builder.write(char);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalSortType {
|
||||
|
||||
@@ -199,6 +199,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
tertiary = light.tertiary;
|
||||
}
|
||||
return MaterialApp(
|
||||
title: "venera",
|
||||
home: home,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: getTheme(primary, secondary, tertiary, Brightness.light),
|
||||
@@ -246,7 +247,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
/// https://github.com/flutter/flutter/issues/161086
|
||||
var isPaddingCheckError =
|
||||
MediaQuery.of(context).viewPadding.top <= 0 ||
|
||||
MediaQuery.of(context).viewPadding.top > 50;
|
||||
MediaQuery.of(context).viewPadding.top > 200;
|
||||
|
||||
if (isPaddingCheckError && Platform.isAndroid) {
|
||||
widget = MediaQuery(
|
||||
|
||||
@@ -107,7 +107,21 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
var local = LocalManager().find(id, comicType);
|
||||
if (path != null) {
|
||||
if (local == null) {
|
||||
Directory(path!).deleteIgnoreError(recursive: true);
|
||||
Future.sync(() async {
|
||||
var tasks = this.tasks.values.toList();
|
||||
for (var i = 0; i < tasks.length; i++) {
|
||||
if (!tasks[i].isComplete) {
|
||||
tasks[i].cancel();
|
||||
await tasks[i].wait();
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Directory(path!).delete(recursive: true);
|
||||
}
|
||||
catch(e) {
|
||||
Log.error("Download", "Failed to delete directory: $e");
|
||||
}
|
||||
});
|
||||
} else if (chapters != null) {
|
||||
for (var c in chapters!) {
|
||||
var dir = Directory(FilePath.join(path!, c));
|
||||
@@ -197,7 +211,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
if (comic!.chapters != null) {
|
||||
saveTo = Directory(FilePath.join(
|
||||
path!,
|
||||
_images!.keys.elementAt(_chapter),
|
||||
LocalManager.getChapterDirectoryName(
|
||||
_images!.keys.elementAt(_chapter),
|
||||
),
|
||||
));
|
||||
if (!saveTo.existsSync()) {
|
||||
saveTo.createSync(recursive: true);
|
||||
|
||||
@@ -181,7 +181,15 @@ abstract class ImageDownloader {
|
||||
}
|
||||
|
||||
if (configs['onResponse'] is JSInvokable) {
|
||||
buffer = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
|
||||
dynamic result = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
|
||||
if (result is Future) {
|
||||
result = await result;
|
||||
}
|
||||
if (result is List<int>) {
|
||||
buffer = result;
|
||||
} else {
|
||||
throw "Error: Invalid onResponse result.";
|
||||
}
|
||||
(configs['onResponse'] as JSInvokable).free();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,39 +17,50 @@ class CategoriesPage extends StatefulWidget {
|
||||
State<CategoriesPage> createState() => _CategoriesPageState();
|
||||
}
|
||||
|
||||
class _CategoriesPageState extends State<CategoriesPage> {
|
||||
class _CategoriesPageState extends State<CategoriesPage>
|
||||
with
|
||||
TickerProviderStateMixin,
|
||||
AutomaticKeepAliveClientMixin<CategoriesPage> {
|
||||
var categories = <String>[];
|
||||
|
||||
late TabController controller;
|
||||
|
||||
void onSettingsChanged() {
|
||||
var categories =
|
||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||
var categories = List.from(
|
||||
appdata.settings["categories"],
|
||||
).whereType<String>().toList();
|
||||
var allCategories = ComicSource.all()
|
||||
.map((e) => e.categoryData?.key)
|
||||
.where((element) => element != null)
|
||||
.map((e) => e!)
|
||||
.toList();
|
||||
categories =
|
||||
categories.where((element) => allCategories.contains(element)).toList();
|
||||
categories = categories
|
||||
.where((element) => allCategories.contains(element))
|
||||
.toList();
|
||||
if (!categories.isEqualTo(this.categories)) {
|
||||
setState(() {
|
||||
this.categories = categories;
|
||||
});
|
||||
controller = TabController(length: categories.length, vsync: this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
var categories =
|
||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
||||
var categories = List.from(
|
||||
appdata.settings["categories"],
|
||||
).whereType<String>().toList();
|
||||
var allCategories = ComicSource.all()
|
||||
.map((e) => e.categoryData?.key)
|
||||
.where((element) => element != null)
|
||||
.map((e) => e!)
|
||||
.toList();
|
||||
this.categories =
|
||||
categories.where((element) => allCategories.contains(element)).toList();
|
||||
this.categories = categories
|
||||
.where((element) => allCategories.contains(element))
|
||||
.toList();
|
||||
appdata.settings.addListener(onSettingsChanged);
|
||||
controller = TabController(length: categories.length, vsync: this);
|
||||
}
|
||||
|
||||
void addPage() {
|
||||
@@ -59,6 +70,7 @@ class _CategoriesPageState extends State<CategoriesPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
controller.dispose();
|
||||
appdata.settings.removeListener(onSettingsChanged);
|
||||
}
|
||||
|
||||
@@ -85,46 +97,45 @@ class _CategoriesPageState extends State<CategoriesPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
if (categories.isEmpty) {
|
||||
return buildEmpty();
|
||||
}
|
||||
|
||||
return Material(
|
||||
child: DefaultTabController(
|
||||
length: categories.length,
|
||||
key: Key(categories.toString()),
|
||||
child: Column(
|
||||
children: [
|
||||
AppTabBar(
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
try {
|
||||
title = getCategoryDataWithKey(e).title;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return Tab(
|
||||
text: title,
|
||||
key: Key(e),
|
||||
);
|
||||
}).toList(),
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
AppTabBar(
|
||||
controller: controller,
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
try {
|
||||
title = getCategoryDataWithKey(e).title;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return Tab(text: title, key: Key(e));
|
||||
}).toList(),
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: controller,
|
||||
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
typedef ClickTagCallback = void Function(String, String?);
|
||||
@@ -150,38 +161,42 @@ class _CategoryPage extends StatelessWidget {
|
||||
var children = <Widget>[];
|
||||
if (data.enableRankingPage || data.buttons.isNotEmpty) {
|
||||
children.add(buildTitle(data.title));
|
||||
children.add(Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
children: [
|
||||
if (data.enableRankingPage)
|
||||
buildTag("Ranking".tl, () {
|
||||
context.to(() => RankingPage(categoryKey: data.key));
|
||||
}),
|
||||
for (var buttonData in data.buttons)
|
||||
buildTag(buttonData.label.tl, buttonData.onTap)
|
||||
],
|
||||
children.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
children: [
|
||||
if (data.enableRankingPage)
|
||||
buildTag("Ranking".tl, () {
|
||||
context.to(() => RankingPage(categoryKey: data.key));
|
||||
}),
|
||||
for (var buttonData in data.buttons)
|
||||
buildTag(buttonData.label.tl, buttonData.onTap),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
for (var part in data.categories) {
|
||||
if (part.enableRandom) {
|
||||
children.add(StatefulBuilder(builder: (context, updater) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitleWithRefresh(part.title, () => updater(() {})),
|
||||
buildTags(part.categories)
|
||||
],
|
||||
);
|
||||
}));
|
||||
children.add(
|
||||
StatefulBuilder(
|
||||
builder: (context, updater) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTitleWithRefresh(part.title, () => updater(() {})),
|
||||
buildTags(part.categories),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
children.add(buildTitle(part.title));
|
||||
children.add(
|
||||
buildTags(part.categories),
|
||||
);
|
||||
children.add(buildTags(part.categories));
|
||||
}
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
@@ -195,8 +210,10 @@ class _CategoryPage extends StatelessWidget {
|
||||
Widget buildTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 5, 10),
|
||||
child: Text(title.tl,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500)),
|
||||
child: Text(
|
||||
title.tl,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,21 +224,16 @@ class _CategoryPage extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title.tl,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh))
|
||||
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTags(
|
||||
List<CategoryItem> categories,
|
||||
) {
|
||||
Widget buildTags(List<CategoryItem> categories) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||
child: Wrap(
|
||||
|
||||
@@ -56,8 +56,12 @@ abstract mixin class _ComicPageActions {
|
||||
type: comic.comicType,
|
||||
isFavorite: isFavorite,
|
||||
onFavorite: (local, network) {
|
||||
isFavorite = network ?? isFavorite;
|
||||
isAddToLocalFav = local ?? isAddToLocalFav;
|
||||
if (network != null) {
|
||||
isFavorite = network;
|
||||
}
|
||||
if (local != null) {
|
||||
isAddToLocalFav = local;
|
||||
}
|
||||
update();
|
||||
},
|
||||
favoriteItem: _toFavoriteItem(),
|
||||
@@ -151,64 +155,60 @@ abstract mixin class _ComicPageActions {
|
||||
builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Download".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<int>(
|
||||
value: -1,
|
||||
groupValue: selected,
|
||||
title: Text("Normal".tl),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
title: Text("Archive".tl),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
content: RadioGroup<int>(
|
||||
groupValue: selected,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v ?? selected;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RadioListTile<int>(
|
||||
value: -1,
|
||||
title: Text("Normal".tl),
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
onExpansionChanged: (b) {
|
||||
if (!isLoading && b && archives == null) {
|
||||
isLoading = true;
|
||||
comicSource.archiveDownloader!
|
||||
.getArchives(comic.id)
|
||||
.then((value) {
|
||||
if (value.success) {
|
||||
archives = value.data;
|
||||
} else {
|
||||
App.rootContext
|
||||
.showMessage(message: value.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
ExpansionTile(
|
||||
title: Text("Archive".tl),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
collapsedShape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
onExpansionChanged: (b) {
|
||||
if (!isLoading && b && archives == null) {
|
||||
isLoading = true;
|
||||
comicSource.archiveDownloader!
|
||||
.getArchives(comic.id)
|
||||
.then((value) {
|
||||
if (value.success) {
|
||||
archives = value.data;
|
||||
} else {
|
||||
App.rootContext
|
||||
.showMessage(message: value.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
children: [
|
||||
if (archives == null)
|
||||
const ListLoadingIndicator().toCenter()
|
||||
else
|
||||
for (int i = 0; i < archives!.length; i++)
|
||||
RadioListTile<int>(
|
||||
value: i,
|
||||
groupValue: selected,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = v!;
|
||||
});
|
||||
},
|
||||
title: Text(archives![i].title),
|
||||
subtitle: Text(archives![i].description),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
},
|
||||
children: [
|
||||
if (archives == null)
|
||||
const ListLoadingIndicator().toCenter()
|
||||
else
|
||||
for (int i = 0; i < archives!.length; i++)
|
||||
RadioListTile<int>(
|
||||
value: i,
|
||||
title: Text(archives![i].title),
|
||||
subtitle: Text(archives![i].description),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Button.filled(
|
||||
@@ -233,10 +233,12 @@ abstract mixin class _ComicPageActions {
|
||||
isGettingLink = false;
|
||||
});
|
||||
} else if (context.mounted) {
|
||||
LocalManager()
|
||||
if (res.data.isNotEmpty) {
|
||||
LocalManager()
|
||||
.addTask(ArchiveDownloadTask(res.data, comic));
|
||||
App.rootContext
|
||||
App.rootContext
|
||||
.showMessage(message: "Download started".tl);
|
||||
}
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:shimmer_animation/shimmer_animation.dart';
|
||||
import 'package:sliver_tools/sliver_tools.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -17,10 +20,12 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/network/cache.dart';
|
||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/file_type.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/tags_translation.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
@@ -38,6 +43,8 @@ part 'comments_preview.dart';
|
||||
|
||||
part 'actions.dart';
|
||||
|
||||
part 'cover_viewer.dart';
|
||||
|
||||
class ComicPage extends StatefulWidget {
|
||||
const ComicPage({
|
||||
super.key,
|
||||
@@ -256,6 +263,18 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
Future<void> onDataLoaded() async {
|
||||
isLiked = comic.isLiked ?? false;
|
||||
isFavorite = comic.isFavorite ?? false;
|
||||
// For sources with multi-folder favorites, prefer querying folders to get accurate favorite status
|
||||
// Some sources may not set isFavorite reliably when multi-folder is enabled
|
||||
if (comicSource.favoriteData?.loadFolders != null && comicSource.isLogged) {
|
||||
var res = await comicSource.favoriteData!.loadFolders!(comic.id);
|
||||
if (!res.error) {
|
||||
if (res.subData is List) {
|
||||
var list = List<String>.from(res.subData);
|
||||
isFavorite = list.isNotEmpty;
|
||||
update();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (comic.chapters == null) {
|
||||
isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
|
||||
}
|
||||
@@ -283,31 +302,35 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
Hero(
|
||||
tag: "cover${widget.heroID}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 144,
|
||||
width: 144 * 0.72,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
GestureDetector(
|
||||
onTap: () => _viewCover(context),
|
||||
onLongPress: () => _saveCover(context),
|
||||
child: Hero(
|
||||
tag: "cover${widget.heroID}",
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 144,
|
||||
width: 144 * 0.72,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -710,6 +733,54 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
}
|
||||
return _CommentsPart(comments: comic.comments!, showMore: showComments);
|
||||
}
|
||||
|
||||
void _viewCover(BuildContext context) {
|
||||
final imageProvider = CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
);
|
||||
|
||||
context.to(
|
||||
() => _CoverViewer(
|
||||
imageProvider: imageProvider,
|
||||
title: comic.title,
|
||||
heroTag: "cover${widget.heroID}",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveCover(BuildContext context) async {
|
||||
try {
|
||||
final imageProvider = CachedImageProvider(
|
||||
widget.cover ?? comic.cover,
|
||||
sourceKey: comic.sourceKey,
|
||||
cid: comic.id,
|
||||
);
|
||||
|
||||
final imageStream = imageProvider.resolve(const ImageConfiguration());
|
||||
final completer = Completer<Uint8List>();
|
||||
|
||||
imageStream.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) async {
|
||||
final byteData = await info.image.toByteData(
|
||||
format: ImageByteFormat.png,
|
||||
);
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
final data = await completer.future;
|
||||
final fileType = detectFileType(data);
|
||||
await saveFile(filename: "cover${fileType.ext}", data: data);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showMessage(message: "Error".tl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
|
||||
140
lib/pages/comic_details_page/cover_viewer.dart
Normal file
140
lib/pages/comic_details_page/cover_viewer.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
part of 'comic_page.dart';
|
||||
|
||||
class _CoverViewer extends StatefulWidget {
|
||||
const _CoverViewer({
|
||||
required this.imageProvider,
|
||||
required this.title,
|
||||
required this.heroTag,
|
||||
});
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
final String title;
|
||||
final String heroTag;
|
||||
|
||||
@override
|
||||
State<_CoverViewer> createState() => _CoverViewerState();
|
||||
}
|
||||
|
||||
class _CoverViewerState extends State<_CoverViewer> {
|
||||
bool isAppBarShow = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
backgroundColor: context.colorScheme.surface,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: PhotoView(
|
||||
imageProvider: widget.imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||
maxScale: PhotoViewComputedScale.covered * 3.0,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
),
|
||||
loadingBuilder: (context, event) => Center(
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
height: 24.0,
|
||||
child: CircularProgressIndicator(
|
||||
value: event == null || event.expectedTotalBytes == null
|
||||
? null
|
||||
: event.cumulativeBytesLoaded /
|
||||
event.expectedTotalBytes!,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTapUp: (context, details, controllerValue) {
|
||||
setState(() {
|
||||
isAppBarShow = !isAppBarShow;
|
||||
});
|
||||
},
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: widget.heroTag),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
top: isAppBarShow ? 0 : -(context.padding.top + 52),
|
||||
left: 0,
|
||||
right: 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: _buildAppBar(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar() {
|
||||
return Material(
|
||||
color: context.colorScheme.surface.toOpacity(0.72),
|
||||
child: BlurEffect(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
height: 52,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.save_alt),
|
||||
onPressed: _saveCover,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _saveCover() async {
|
||||
try {
|
||||
final imageStream = widget.imageProvider.resolve(
|
||||
const ImageConfiguration(),
|
||||
);
|
||||
final completer = Completer<Uint8List>();
|
||||
|
||||
imageStream.addListener(
|
||||
ImageStreamListener((ImageInfo info, bool _) async {
|
||||
final byteData = await info.image.toByteData(
|
||||
format: ImageByteFormat.png,
|
||||
);
|
||||
if (byteData != null) {
|
||||
completer.complete(byteData.buffer.asUint8List());
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
final data = await completer.future;
|
||||
final fileType = detectFileType(data);
|
||||
await saveFile(filename: "cover_${widget.title}${fileType.ext}", data: data);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
context.showMessage(message: "Error".tl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,198 +33,122 @@ class _FavoritePanelState extends State<_FavoritePanel>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late ComicSource comicSource;
|
||||
|
||||
late TabController tabController;
|
||||
|
||||
late bool hasNetwork;
|
||||
|
||||
late List<String> localFolders;
|
||||
|
||||
late List<String> added;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
comicSource = widget.type.comicSource!;
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
added = LocalFavoritesManager().find(widget.cid, widget.type);
|
||||
hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
|
||||
var initIndex = 0;
|
||||
if (appdata.implicitData['favoritePanelIndex'] is int) {
|
||||
initIndex = appdata.implicitData['favoritePanelIndex'];
|
||||
}
|
||||
initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0);
|
||||
tabController = TabController(
|
||||
initialIndex: initIndex,
|
||||
length: hasNetwork ? 2 : 1,
|
||||
vsync: this,
|
||||
);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
var currentIndex = tabController.index;
|
||||
appdata.implicitData['favoritePanelIndex'] = currentIndex;
|
||||
appdata.writeImplicitData();
|
||||
tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: Appbar(
|
||||
title: Text("Favorite".tl),
|
||||
appBar: Appbar(title: Text("Favorite".tl)),
|
||||
body: _FavoriteList(
|
||||
cid: widget.cid,
|
||||
type: widget.type,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: widget.onFavorite,
|
||||
favoriteItem: widget.favoriteItem,
|
||||
updateTime: widget.updateTime,
|
||||
comicSource: comicSource,
|
||||
hasNetwork: hasNetwork,
|
||||
localFolders: localFolders,
|
||||
added: added,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: [
|
||||
Tab(text: "Local".tl),
|
||||
if (hasNetwork) Tab(text: "Network".tl),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
buildLocal(),
|
||||
if (hasNetwork) buildNetwork(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
late List<String> localFolders;
|
||||
|
||||
late List<String> added;
|
||||
|
||||
var selectedLocalFolders = <String>{};
|
||||
|
||||
Widget buildLocal() {
|
||||
var isRemove = selectedLocalFolders.isNotEmpty &&
|
||||
added.contains(selectedLocalFolders.first);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: localFolders.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == localFolders.length) {
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
newFolder().then((v) {
|
||||
setState(() {
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
});
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text("New Folder".tl)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
var folder = localFolders[index];
|
||||
var disabled = false;
|
||||
if (selectedLocalFolders.isNotEmpty) {
|
||||
if (added.contains(folder) &&
|
||||
!added.contains(selectedLocalFolders.first)) {
|
||||
disabled = true;
|
||||
} else if (!added.contains(folder) &&
|
||||
added.contains(selectedLocalFolders.first)) {
|
||||
disabled = true;
|
||||
}
|
||||
}
|
||||
return CheckboxListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(folder),
|
||||
const SizedBox(width: 8),
|
||||
if (added.contains(folder))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
value: selectedLocalFolders.contains(folder),
|
||||
onChanged: disabled
|
||||
? null
|
||||
: (v) {
|
||||
setState(() {
|
||||
if (v!) {
|
||||
selectedLocalFolders.add(folder);
|
||||
} else {
|
||||
selectedLocalFolders.remove(folder);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
if (selectedLocalFolders.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (isRemove) {
|
||||
for (var folder in selectedLocalFolders) {
|
||||
LocalFavoritesManager()
|
||||
.deleteComicWithId(folder, widget.cid, widget.type);
|
||||
}
|
||||
widget.onFavorite(false, null);
|
||||
} else {
|
||||
for (var folder in selectedLocalFolders) {
|
||||
LocalFavoritesManager().addComic(
|
||||
folder,
|
||||
widget.favoriteItem,
|
||||
null,
|
||||
widget.updateTime,
|
||||
);
|
||||
}
|
||||
widget.onFavorite(true, null);
|
||||
}
|
||||
context.pop();
|
||||
},
|
||||
child: isRemove ? Text("Remove".tl) : Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildNetwork() {
|
||||
return _NetworkFavorites(
|
||||
cid: widget.cid,
|
||||
comicSource: comicSource,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: (network) {
|
||||
widget.onFavorite(null, network);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetworkFavorites extends StatefulWidget {
|
||||
const _NetworkFavorites({
|
||||
class _FavoriteList extends StatefulWidget {
|
||||
const _FavoriteList({
|
||||
required this.cid,
|
||||
required this.type,
|
||||
required this.isFavorite,
|
||||
required this.onFavorite,
|
||||
required this.favoriteItem,
|
||||
this.updateTime,
|
||||
required this.comicSource,
|
||||
required this.hasNetwork,
|
||||
required this.localFolders,
|
||||
required this.added,
|
||||
});
|
||||
|
||||
final String cid;
|
||||
final ComicType type;
|
||||
final bool? isFavorite;
|
||||
final void Function(bool?, bool?) onFavorite;
|
||||
final FavoriteItem favoriteItem;
|
||||
final String? updateTime;
|
||||
final ComicSource comicSource;
|
||||
final bool hasNetwork;
|
||||
final List<String> localFolders;
|
||||
final List<String> added;
|
||||
|
||||
@override
|
||||
State<_FavoriteList> createState() => _FavoriteListState();
|
||||
}
|
||||
|
||||
class _FavoriteListState extends State<_FavoriteList> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final localFavoritesFirst = appdata.settings['localFavoritesFirst'] ?? true;
|
||||
|
||||
final localSection = _LocalSection(
|
||||
cid: widget.cid,
|
||||
type: widget.type,
|
||||
favoriteItem: widget.favoriteItem,
|
||||
updateTime: widget.updateTime,
|
||||
localFolders: widget.localFolders,
|
||||
added: widget.added,
|
||||
onFavorite: (local) {
|
||||
widget.onFavorite(local, null);
|
||||
},
|
||||
);
|
||||
|
||||
final networkSection = widget.hasNetwork
|
||||
? _NetworkSection(
|
||||
cid: widget.cid,
|
||||
comicSource: widget.comicSource,
|
||||
isFavorite: widget.isFavorite,
|
||||
onFavorite: (network) {
|
||||
widget.onFavorite(null, network);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
final divider = widget.hasNetwork
|
||||
? Container(
|
||||
height: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: context.colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
)
|
||||
: null;
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
if (localFavoritesFirst) ...[
|
||||
localSection,
|
||||
if (widget.hasNetwork) ...[divider!, networkSection!],
|
||||
] else ...[
|
||||
if (widget.hasNetwork) ...[networkSection!, divider!],
|
||||
localSection,
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetworkSection extends StatefulWidget {
|
||||
const _NetworkSection({
|
||||
required this.cid,
|
||||
required this.comicSource,
|
||||
required this.isFavorite,
|
||||
@@ -232,82 +156,56 @@ class _NetworkFavorites extends StatefulWidget {
|
||||
});
|
||||
|
||||
final String cid;
|
||||
|
||||
final ComicSource comicSource;
|
||||
|
||||
final bool? isFavorite;
|
||||
|
||||
final void Function(bool) onFavorite;
|
||||
|
||||
@override
|
||||
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
|
||||
State<_NetworkSection> createState() => _NetworkSectionState();
|
||||
}
|
||||
|
||||
class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
|
||||
|
||||
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
|
||||
}
|
||||
|
||||
class _NetworkSectionState extends State<_NetworkSection> {
|
||||
bool isLoading = false;
|
||||
|
||||
Widget buildSingleFolder() {
|
||||
var isFavorite = widget.isFavorite ?? false;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget.comicSource.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
|
||||
if (res.success) {
|
||||
widget.onFavorite(!isFavorite);
|
||||
context.pop();
|
||||
App.rootContext.showMessage(
|
||||
message: isFavorite ? "Removed".tl : "Added".tl);
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
},
|
||||
child: isFavorite ? Text("Remove".tl) : Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String>? folders;
|
||||
|
||||
var addedFolders = <String>{};
|
||||
|
||||
var isLoadingFolders = true;
|
||||
bool? localIsFavorite;
|
||||
final Map<String, bool> _itemLoading = {};
|
||||
late List<double> _skeletonWidths;
|
||||
|
||||
// for network favorites, only one selection is allowed
|
||||
String? selected;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
localIsFavorite = widget.isFavorite;
|
||||
_skeletonWidths = List.generate(3, (_) => 0.3 + math.Random().nextDouble() * 0.5);
|
||||
if (widget.comicSource.favoriteData!.loadFolders != null) {
|
||||
loadFolders();
|
||||
} else {
|
||||
isLoadingFolders = false;
|
||||
}
|
||||
}
|
||||
|
||||
void loadFolders() async {
|
||||
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
|
||||
if (res.error) {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
setState(() {
|
||||
isLoadingFolders = false;
|
||||
});
|
||||
} else {
|
||||
folders = res.data;
|
||||
if (res.subData is List) {
|
||||
addedFolders = List<String>.from(res.subData).toSet();
|
||||
final list = List<String>.from(res.subData);
|
||||
if (list.isNotEmpty) {
|
||||
addedFolders = list.toSet();
|
||||
localIsFavorite = true;
|
||||
} else {
|
||||
addedFolders.clear();
|
||||
localIsFavorite = false;
|
||||
}
|
||||
} else {
|
||||
addedFolders.clear();
|
||||
localIsFavorite = false;
|
||||
}
|
||||
setState(() {
|
||||
isLoadingFolders = false;
|
||||
@@ -315,118 +213,414 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildMultiFolder() {
|
||||
if (widget.isFavorite == true &&
|
||||
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text("Added to favorites".tl),
|
||||
Widget _buildLoadingSkeleton() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget.comicSource.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', false, null);
|
||||
if (res.success) {
|
||||
widget.onFavorite(false);
|
||||
context.pop();
|
||||
App.rootContext.showMessage(message: "Removed".tl);
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
},
|
||||
child: Text("Remove".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (isLoadingFolders) {
|
||||
loadFolders();
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: folders!.length,
|
||||
itemBuilder: (context, index) {
|
||||
var name = folders!.values.elementAt(index);
|
||||
var id = folders!.keys.elementAt(index);
|
||||
return CheckboxListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(name),
|
||||
const SizedBox(width: 8),
|
||||
if (addedFolders.contains(id))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
Shimmer(
|
||||
child: Column(
|
||||
children: List.generate(3, (index) {
|
||||
return ListTile(
|
||||
title: Container(
|
||||
height: 20,
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: _skeletonWidths[index],
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
value: selected == id,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
selected = id;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
trailing: Container(
|
||||
height: 28,
|
||||
width: 60 + (index * 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
Center(
|
||||
child: Button.filled(
|
||||
isLoading: isLoading,
|
||||
onPressed: () async {
|
||||
if (selected == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
var res =
|
||||
await widget.comicSource.favoriteData!.addOrDelFavorite!(
|
||||
widget.cid,
|
||||
selected!,
|
||||
!addedFolders.contains(selected!),
|
||||
null,
|
||||
);
|
||||
if (res.success) {
|
||||
context.showMessage(message: "Success".tl);
|
||||
context.pop();
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: selected != null && addedFolders.contains(selected!)
|
||||
? Text("Remove".tl)
|
||||
: Text("Add".tl),
|
||||
).paddingVertical(8),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoadingFolders) {
|
||||
return _buildLoadingSkeleton();
|
||||
}
|
||||
|
||||
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
|
||||
|
||||
if (isMultiFolder) {
|
||||
return _buildMultiFolder();
|
||||
} else {
|
||||
return _buildSingleFolder();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSingleFolder() {
|
||||
var isFavorite = localIsFavorite ?? false;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text("Network Favorites".tl),
|
||||
const SizedBox(width: 8),
|
||||
if (isFavorite)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _HoverButton(
|
||||
isFavorite: isFavorite,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget
|
||||
.comicSource
|
||||
.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
|
||||
if (res.success) {
|
||||
setState(() {
|
||||
localIsFavorite = !isFavorite;
|
||||
});
|
||||
widget.onFavorite(!isFavorite);
|
||||
App.rootContext.showMessage(
|
||||
message: isFavorite ? "Removed".tl : "Added".tl,
|
||||
);
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMultiFolder() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Network Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...folders!.entries.map((entry) {
|
||||
var name = entry.value;
|
||||
var id = entry.key;
|
||||
var isAdded = addedFolders.contains(id);
|
||||
// When `singleFolderForSingleComic` is `false`, all add and remove buttons are clickable.
|
||||
// When `singleFolderForSingleComic` is `true`, the remove button is always clickable,
|
||||
// while the add button is only clickable if the comic has not been added to any list.
|
||||
var enabled = !(widget.comicSource.favoriteData!.singleFolderForSingleComic && addedFolders.isNotEmpty && !isAdded);
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(name),
|
||||
const SizedBox(width: 8),
|
||||
if (isAdded)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: (_itemLoading[id] ?? false)
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _HoverButton(
|
||||
isFavorite: isAdded,
|
||||
enabled: enabled,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
_itemLoading[id] = true;
|
||||
});
|
||||
var res = await widget
|
||||
.comicSource
|
||||
.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, id, !isAdded, null);
|
||||
if (res.success) {
|
||||
// Invalidate network cache so folders/pages reload with fresh data
|
||||
NetworkCacheManager().clear();
|
||||
setState(() {
|
||||
if (isAdded) {
|
||||
addedFolders.remove(id);
|
||||
} else {
|
||||
addedFolders.add(id);
|
||||
}
|
||||
// sync local flag for single-folder-per-comic logic and parent
|
||||
localIsFavorite = addedFolders.isNotEmpty;
|
||||
});
|
||||
// notify parent so page state updates when closing and reopening panel
|
||||
widget.onFavorite(addedFolders.isNotEmpty);
|
||||
context.showMessage(message: "Success".tl);
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
_itemLoading[id] = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalSection extends StatefulWidget {
|
||||
const _LocalSection({
|
||||
required this.cid,
|
||||
required this.type,
|
||||
required this.favoriteItem,
|
||||
this.updateTime,
|
||||
required this.localFolders,
|
||||
required this.added,
|
||||
required this.onFavorite,
|
||||
});
|
||||
|
||||
final String cid;
|
||||
final ComicType type;
|
||||
final FavoriteItem favoriteItem;
|
||||
final String? updateTime;
|
||||
final List<String> localFolders;
|
||||
final List<String> added;
|
||||
final void Function(bool) onFavorite;
|
||||
|
||||
@override
|
||||
State<_LocalSection> createState() => _LocalSectionState();
|
||||
}
|
||||
|
||||
class _LocalSectionState extends State<_LocalSection> {
|
||||
late List<String> localFolders;
|
||||
late Set<String> localAdded;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
localFolders = widget.localFolders;
|
||||
localAdded = widget.added.toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
"Local Favorites".tl,
|
||||
style: ts.s14.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
...localFolders.map((folder) {
|
||||
var isAdded = localAdded.contains(folder);
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(folder),
|
||||
const SizedBox(width: 8),
|
||||
if (isAdded)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: _HoverButton(
|
||||
isFavorite: isAdded,
|
||||
onTap: () {
|
||||
if (isAdded) {
|
||||
LocalFavoritesManager().deleteComicWithId(
|
||||
folder,
|
||||
widget.cid,
|
||||
widget.type,
|
||||
);
|
||||
setState(() {
|
||||
localAdded.remove(folder);
|
||||
});
|
||||
widget.onFavorite(false);
|
||||
} else {
|
||||
LocalFavoritesManager().addComic(
|
||||
folder,
|
||||
widget.favoriteItem,
|
||||
null,
|
||||
widget.updateTime,
|
||||
);
|
||||
setState(() {
|
||||
localAdded.add(folder);
|
||||
});
|
||||
widget.onFavorite(true);
|
||||
}
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
// New folder button
|
||||
ListTile(
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.add, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text("New Folder".tl),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
newFolder().then((v) {
|
||||
setState(() {
|
||||
localFolders = LocalFavoritesManager().folderNames;
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HoverButton extends StatefulWidget {
|
||||
const _HoverButton({
|
||||
required this.isFavorite,
|
||||
required this.onTap,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final bool isFavorite;
|
||||
final VoidCallback onTap;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
State<_HoverButton> createState() => _HoverButtonState();
|
||||
}
|
||||
|
||||
class _HoverButtonState extends State<_HoverButton> {
|
||||
bool isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final removeColor = context.colorScheme.error;
|
||||
final removeHoverColor = Color.lerp(removeColor, Colors.black, 0.2)!;
|
||||
final addColor = context.colorScheme.primary;
|
||||
final addHoverColor = Color.lerp(addColor, Colors.black, 0.2)!;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: widget.enabled ? (_) => setState(() => isHovered = true) : null,
|
||||
onExit: widget.enabled ? (_) => setState(() => isHovered = false) : null,
|
||||
child: GestureDetector(
|
||||
onTap: widget.enabled ? widget.onTap : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.enabled
|
||||
? (widget.isFavorite
|
||||
? (isHovered ? removeHoverColor : removeColor)
|
||||
: (isHovered ? addHoverColor : addColor))
|
||||
: context.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.isFavorite ? "Remove".tl : "Add".tl,
|
||||
style: ts.s12.copyWith(
|
||||
color: widget.enabled
|
||||
? context.colorScheme.onPrimary
|
||||
: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/network/download.dart';
|
||||
import 'package:venera/network/cache.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';
|
||||
|
||||
@@ -36,6 +36,8 @@ Future<bool> _deleteComic(
|
||||
favId,
|
||||
);
|
||||
if (res.success) {
|
||||
// Invalidate network cache so next loads fetch fresh data
|
||||
NetworkCacheManager().clear();
|
||||
context.showMessage(message: "Deleted".tl);
|
||||
result = true;
|
||||
context.pop();
|
||||
@@ -115,6 +117,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
// Force refresh bypassing cache
|
||||
NetworkCacheManager().clear();
|
||||
comicListKey.currentState!.refresh();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -514,51 +514,53 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
...List.generate(importMethods.length, (index) {
|
||||
return RadioListTile(
|
||||
title: Text(importMethods[index]),
|
||||
value: index,
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value as int;
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
if (type != 4)
|
||||
ListTile(
|
||||
title: Text("Add to favorites".tl),
|
||||
trailing: Select(
|
||||
current: selectedFolder,
|
||||
values: folders,
|
||||
minWidth: 112,
|
||||
onTap: (v) {
|
||||
setState(() {
|
||||
selectedFolder = folders[v];
|
||||
});
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
||||
CheckboxListTile(
|
||||
enabled: true,
|
||||
title: Text("Copy to app local path".tl),
|
||||
value: copyToLocalFolder,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
copyToLocalFolder = !copyToLocalFolder;
|
||||
});
|
||||
}).paddingHorizontal(8),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
: RadioGroup<int>(
|
||||
groupValue: type,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
type = value ?? type;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
key: key,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 600),
|
||||
...List.generate(importMethods.length, (index) {
|
||||
return RadioListTile<int>(
|
||||
title: Text(importMethods[index]),
|
||||
value: index,
|
||||
);
|
||||
}),
|
||||
if (type != 4)
|
||||
ListTile(
|
||||
title: Text("Add to favorites".tl),
|
||||
trailing: Select(
|
||||
current: selectedFolder,
|
||||
values: folders,
|
||||
minWidth: 112,
|
||||
onTap: (v) {
|
||||
setState(() {
|
||||
selectedFolder = folders[v];
|
||||
});
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
||||
CheckboxListTile(
|
||||
enabled: true,
|
||||
title: Text("Copy to app local path".tl),
|
||||
value: copyToLocalFolder,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
copyToLocalFolder = !copyToLocalFolder;
|
||||
});
|
||||
}).paddingHorizontal(8),
|
||||
const SizedBox(height: 8),
|
||||
Text(info).paddingHorizontal(24),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
Button.text(
|
||||
child: Row(
|
||||
|
||||
@@ -404,21 +404,23 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
|
||||
children: [
|
||||
tabBar,
|
||||
TabViewBody(children: [
|
||||
Column(
|
||||
children: ImageFavoriteSortType.values
|
||||
.map(
|
||||
(e) => RadioListTile<ImageFavoriteSortType>(
|
||||
title: Text(e.value.tl),
|
||||
value: e,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
RadioGroup<ImageFavoriteSortType>(
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v ?? sortType;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: ImageFavoriteSortType.values
|
||||
.map(
|
||||
(e) => RadioListTile<ImageFavoriteSortType>(
|
||||
title: Text(e.value.tl),
|
||||
value: e,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
|
||||
@@ -70,39 +70,29 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Sort".tl,
|
||||
content: Column(
|
||||
children: [
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Name".tl),
|
||||
value: LocalSortType.name,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date".tl),
|
||||
value: LocalSortType.timeAsc,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date Desc".tl),
|
||||
value: LocalSortType.timeDesc,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
content: RadioGroup<LocalSortType>(
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v ?? sortType;
|
||||
});
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Name".tl),
|
||||
value: LocalSortType.name,
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date".tl),
|
||||
value: LocalSortType.timeAsc,
|
||||
),
|
||||
RadioListTile<LocalSortType>(
|
||||
title: Text("Date Desc".tl),
|
||||
value: LocalSortType.timeDesc,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
|
||||
@@ -140,12 +140,12 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
int get totalPages {
|
||||
if (!reader.showSingleImageOnFirstPage()) {
|
||||
return (reader.images!.length /
|
||||
reader.imagesPerPage())
|
||||
reader.imagesPerPage)
|
||||
.ceil();
|
||||
} else {
|
||||
return 1 +
|
||||
((reader.images!.length - 1) /
|
||||
reader.imagesPerPage())
|
||||
reader.imagesPerPage)
|
||||
.ceil();
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
/// Get the range of images for the given page. [page] is 1-based.
|
||||
(int start, int end) getPageImagesRange(int page) {
|
||||
var imagesPerPage = reader.imagesPerPage();
|
||||
var imagesPerPage = reader.imagesPerPage;
|
||||
if (reader.showSingleImageOnFirstPage()) {
|
||||
if (page == 1) {
|
||||
return (0, 1);
|
||||
@@ -191,6 +191,16 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the image indices for current page. Returns null if no images.
|
||||
/// Returns a single index if only one image, or a range if multiple images.
|
||||
(int, int)? getCurrentPageImageRange() {
|
||||
if (reader.images == null || reader.images!.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
var (startIndex, endIndex) = getPageImagesRange(reader.page);
|
||||
return (startIndex, endIndex);
|
||||
}
|
||||
|
||||
/// [cache] is used to cache the images.
|
||||
/// The count of images to cache is determined by the [preCacheCount] setting.
|
||||
/// For previous page and next page, it will do a memory cache.
|
||||
@@ -259,7 +269,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
photoViewControllers[index] ??= PhotoViewController();
|
||||
|
||||
if (reader.imagesPerPage() == 1 ||
|
||||
if (reader.imagesPerPage == 1 ||
|
||||
pageImages.length == 1) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
filterQuality: FilterQuality.medium,
|
||||
@@ -533,17 +543,27 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
@override
|
||||
String? getImageKeyByOffset(Offset offset) {
|
||||
String? imageKey;
|
||||
if (reader.imagesPerPage() == 1) {
|
||||
imageKey = reader.images![reader.page - 1];
|
||||
} else {
|
||||
for (var imageState in imageStates) {
|
||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||
var range = getCurrentPageImageRange();
|
||||
if (range == null) return null;
|
||||
|
||||
var (startIndex, endIndex) = range;
|
||||
int actualImageCount = endIndex - startIndex;
|
||||
|
||||
if (actualImageCount == 1) {
|
||||
return reader.images![startIndex];
|
||||
}
|
||||
|
||||
for (var imageState in imageStates) {
|
||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
||||
var imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
||||
int index = reader.images!.indexOf(imageKey);
|
||||
if (index >= startIndex && index < endIndex) {
|
||||
return imageKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageKey;
|
||||
|
||||
return reader.images![startIndex];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,9 +116,9 @@ class _ReaderState extends State<Reader>
|
||||
return 1;
|
||||
}
|
||||
if (!showSingleImageOnFirstPage()) {
|
||||
return (images!.length / imagesPerPage()).ceil();
|
||||
return (images!.length / imagesPerPage).ceil();
|
||||
} else {
|
||||
return 1 + ((images!.length - 1) / imagesPerPage()).ceil();
|
||||
return 1 + ((images!.length - 1) / imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,13 +277,13 @@ class _ReaderState extends State<Reader>
|
||||
history!.page = images?.length ?? 1;
|
||||
} else {
|
||||
/// Record the first image of the page
|
||||
if (!showSingleImageOnFirstPage() || imagesPerPage() == 1) {
|
||||
history!.page = (page - 1) * imagesPerPage() + 1;
|
||||
if (!showSingleImageOnFirstPage() || imagesPerPage == 1) {
|
||||
history!.page = (page - 1) * imagesPerPage + 1;
|
||||
} else {
|
||||
if (page == 1) {
|
||||
history!.page = 1;
|
||||
} else {
|
||||
history!.page = (page - 2) * imagesPerPage() + 2;
|
||||
history!.page = (page - 2) * imagesPerPage + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,13 +371,13 @@ abstract mixin class _ImagePerPageHandler {
|
||||
ComicType get type;
|
||||
|
||||
void initImagesPerPage(int initialPage) {
|
||||
_lastImagesPerPage = imagesPerPage();
|
||||
_lastImagesPerPage = imagesPerPage;
|
||||
_lastOrientation = isPortrait;
|
||||
if (imagesPerPage() != 1) {
|
||||
if (imagesPerPage != 1) {
|
||||
if (showSingleImageOnFirstPage()) {
|
||||
page = ((initialPage - 1) / imagesPerPage()).ceil() + 1;
|
||||
page = ((initialPage - 1) / imagesPerPage).ceil() + 1;
|
||||
} else {
|
||||
page = (initialPage / imagesPerPage()).ceil();
|
||||
page = (initialPage / imagesPerPage).ceil();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,7 @@ abstract mixin class _ImagePerPageHandler {
|
||||
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
|
||||
|
||||
/// The number of images displayed on one screen
|
||||
int imagesPerPage() {
|
||||
int get imagesPerPage {
|
||||
if (mode.isContinuous) return 1;
|
||||
if (isPortrait) {
|
||||
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
|
||||
@@ -397,7 +397,7 @@ abstract mixin class _ImagePerPageHandler {
|
||||
|
||||
/// Check if the number of images per page has changed
|
||||
void _checkImagesPerPageChange() {
|
||||
int currentImagesPerPage = imagesPerPage();
|
||||
int currentImagesPerPage = imagesPerPage;
|
||||
bool currentOrientation = isPortrait;
|
||||
|
||||
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
|
||||
|
||||
@@ -599,22 +599,24 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
|
||||
void saveCurrentImage() async {
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
var result = await selectImageToData();
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
var (imageIndex, data) = result;
|
||||
var fileType = detectFileType(data);
|
||||
var filename = "${context.reader.page}${fileType.ext}";
|
||||
var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
saveFile(data: data, filename: filename);
|
||||
}
|
||||
|
||||
void share() async {
|
||||
var data = await selectImageToData();
|
||||
if (data == null) {
|
||||
var result = await selectImageToData();
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
var (imageIndex, data) = result;
|
||||
var fileType = detectFileType(data);
|
||||
var filename = "${context.reader.page}${fileType.ext}";
|
||||
var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
Share.shareFile(data: data, filename: filename, mime: fileType.mime);
|
||||
}
|
||||
|
||||
@@ -719,8 +721,29 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
Future<int?> selectImage() async {
|
||||
var reader = context.reader;
|
||||
var imageViewController = context.reader._imageViewController;
|
||||
if (imageViewController is _GalleryModeState && reader.imagesPerPage == 1) {
|
||||
return reader.page - 1;
|
||||
|
||||
bool needsSelection = false;
|
||||
int? singleImageIndex;
|
||||
|
||||
if (imageViewController is _GalleryModeState) {
|
||||
var range = imageViewController.getCurrentPageImageRange();
|
||||
if (range != null) {
|
||||
var (startIndex, endIndex) = range;
|
||||
int actualImageCount = endIndex - startIndex;
|
||||
if (actualImageCount == 1) {
|
||||
needsSelection = false;
|
||||
singleImageIndex = startIndex;
|
||||
} else {
|
||||
needsSelection = true;
|
||||
}
|
||||
}
|
||||
} else if (imageViewController is _ContinuousModeState) {
|
||||
needsSelection = false;
|
||||
singleImageIndex = reader.page - 1;
|
||||
}
|
||||
|
||||
if (!needsSelection && singleImageIndex != null) {
|
||||
return singleImageIndex;
|
||||
} else {
|
||||
var location = await _showSelectImageOverlay();
|
||||
if (location == null) {
|
||||
@@ -734,20 +757,23 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [selectImage], but return the image data.
|
||||
Future<Uint8List?> selectImageToData() async {
|
||||
/// Same as [selectImage], but return the image data with its index.
|
||||
/// Returns (imageIndex, imageData) or null if cancelled.
|
||||
Future<(int, Uint8List)?> selectImageToData() async {
|
||||
var i = await selectImage();
|
||||
if (i == null) {
|
||||
return null;
|
||||
}
|
||||
var imageKey = context.reader.images![i];
|
||||
Uint8List data;
|
||||
if (imageKey.startsWith("file://")) {
|
||||
return await File(imageKey.substring(7)).readAsBytes();
|
||||
data = await File(imageKey.substring(7)).readAsBytes();
|
||||
} else {
|
||||
return (await CacheManager().findCache(
|
||||
data = await (await CacheManager().findCache(
|
||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||
))!.readAsBytes();
|
||||
}
|
||||
return (i, data);
|
||||
}
|
||||
|
||||
Future<Offset?> _showSelectImageOverlay() {
|
||||
|
||||
@@ -428,30 +428,26 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Text("Operation".tl),
|
||||
Radio<bool>(
|
||||
groupValue: upload,
|
||||
value: true,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text("Upload".tl),
|
||||
Radio<bool>(
|
||||
groupValue: upload,
|
||||
value: false,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text("Download".tl),
|
||||
],
|
||||
RadioGroup<bool>(
|
||||
groupValue: upload,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
upload = value ?? upload;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Text("Operation".tl),
|
||||
Radio<bool>(
|
||||
value: true,
|
||||
),
|
||||
Text("Upload".tl),
|
||||
Radio<bool>(
|
||||
value: false,
|
||||
),
|
||||
Text("Download".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AnimatedSize(
|
||||
|
||||
@@ -13,6 +13,14 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(title: Text("Local Favorites".tl)),
|
||||
_SwitchSetting(
|
||||
title: "Show local favorites before network favorites".tl,
|
||||
settingKey: "localFavoritesFirst",
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Auto close favorite panel after operation".tl,
|
||||
settingKey: "autoCloseFavoritePanel",
|
||||
).toSliver(),
|
||||
SelectSetting(
|
||||
title: "Add new favorite to".tl,
|
||||
settingKey: "newFavoriteAddTo",
|
||||
|
||||
@@ -111,44 +111,34 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
|
||||
return PopUpWidgetScaffold(
|
||||
title: "Proxy".tl,
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: Text("Direct".tl),
|
||||
value: 'direct',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
},
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: Text("System".tl),
|
||||
value: 'system',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text("Manual".tl),
|
||||
value: 'manual',
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (type == 'manual') buildManualProxy(),
|
||||
],
|
||||
child: RadioGroup<String>(
|
||||
groupValue: type,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
type = v ?? type;
|
||||
});
|
||||
if (type != 'manual') {
|
||||
appdata.settings['proxy'] = toProxyStr();
|
||||
appdata.saveData();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile<String>(
|
||||
title: Text("Direct".tl),
|
||||
value: 'direct',
|
||||
),
|
||||
RadioListTile<String>(
|
||||
title: Text("System".tl),
|
||||
value: 'system',
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text("Manual".tl),
|
||||
value: 'manual',
|
||||
),
|
||||
if (type == 'manual') buildManualProxy(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
58
lib/utils/channel.dart
Normal file
58
lib/utils/channel.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
class Channel<T> {
|
||||
final Queue<T> _queue;
|
||||
|
||||
final int size;
|
||||
|
||||
Channel(this.size) : _queue = Queue<T>();
|
||||
|
||||
Completer? _releaseCompleter;
|
||||
|
||||
Completer? _pushCompleter;
|
||||
|
||||
var currentSize = 0;
|
||||
|
||||
var isClosed = false;
|
||||
|
||||
Future<void> push(T item) async {
|
||||
if (currentSize >= size) {
|
||||
_releaseCompleter ??= Completer();
|
||||
return _releaseCompleter!.future.then((_) {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
_queue.addLast(item);
|
||||
currentSize++;
|
||||
});
|
||||
}
|
||||
_queue.addLast(item);
|
||||
currentSize++;
|
||||
_pushCompleter?.complete();
|
||||
_pushCompleter = null;
|
||||
}
|
||||
|
||||
Future<T?> pop() async {
|
||||
while (_queue.isEmpty) {
|
||||
if (isClosed) {
|
||||
return null;
|
||||
}
|
||||
_pushCompleter ??= Completer();
|
||||
await _pushCompleter!.future;
|
||||
}
|
||||
var item = _queue.removeFirst();
|
||||
currentSize--;
|
||||
if (_releaseCompleter != null && currentSize < size) {
|
||||
_releaseCompleter!.complete();
|
||||
_releaseCompleter = null;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
void close() {
|
||||
isClosed = true;
|
||||
_pushCompleter?.complete();
|
||||
_releaseCompleter?.complete();
|
||||
}
|
||||
}
|
||||
28
patch/font.dart
Normal file
28
patch/font.dart
Normal file
@@ -0,0 +1,28 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
void main() async {
|
||||
const harmonySansLink = "https://developer.huawei.com/images/download/general/HarmonyOS-Sans.zip";
|
||||
|
||||
var dio = Dio();
|
||||
await dio.download(harmonySansLink, "HarmonyOS-Sans.zip");
|
||||
await extractFileToDisk("HarmonyOS-Sans.zip", "./assets/");
|
||||
File("HarmonyOS-Sans.zip").deleteSync();
|
||||
|
||||
var pubspec = await File("pubspec.yaml").readAsString();
|
||||
pubspec = pubspec.replaceFirst("# fonts:",
|
||||
""" fonts:
|
||||
- family: HarmonyOS Sans
|
||||
fonts:
|
||||
- asset: assets/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf
|
||||
""");
|
||||
await File("pubspec.yaml").writeAsString(pubspec);
|
||||
|
||||
var mainDart = await File("lib/main.dart").readAsString();
|
||||
mainDart = mainDart.replaceFirst("Noto Sans CJK", "HarmonyOS Sans");
|
||||
await File("lib/main.dart").writeAsString(mainDart);
|
||||
|
||||
print("Successfully patched font.");
|
||||
}
|
||||
22
pubspec.lock
22
pubspec.lock
@@ -33,6 +33,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: archive
|
||||
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -408,10 +416,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_memory_info
|
||||
sha256: "1f112f1d7503aa1681fc8e923f6cd0e847bb2fbeec3753ed021cf1e5f7e9cd74"
|
||||
sha256: eacfd0dd01ff596b4e5bf022442769a1807a73f2af43d62802436f0a5de99137
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.1"
|
||||
version: "0.0.3"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -770,6 +778,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: posix
|
||||
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
rhttp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1116,4 +1132,4 @@ packages:
|
||||
version: "0.0.12"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.35.2"
|
||||
flutter: ">=3.35.5"
|
||||
|
||||
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.5.1+151
|
||||
version: 1.5.3+153
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.35.3
|
||||
flutter: 3.35.5
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -75,7 +75,7 @@ dependencies:
|
||||
ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
|
||||
dynamic_color: ^1.7.0
|
||||
shimmer_animation: ^2.1.0
|
||||
flutter_memory_info: ^0.0.1
|
||||
flutter_memory_info: ^0.0.3
|
||||
syntax_highlight: ^0.4.0
|
||||
flutter_7zip:
|
||||
git:
|
||||
@@ -94,6 +94,7 @@ dev_dependencies:
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_to_arch: ^1.0.1
|
||||
flutter_to_debian: ^2.0.2
|
||||
archive: any
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
@@ -104,6 +105,7 @@ flutter:
|
||||
- assets/tags.json
|
||||
- assets/tags_tw.json
|
||||
- assets/opencc.txt
|
||||
# fonts:
|
||||
|
||||
flutter_to_arch:
|
||||
name: Venera
|
||||
|
||||
115
test/channel_test.dart
Normal file
115
test/channel_test.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:venera/utils/channel.dart';
|
||||
|
||||
void main() {
|
||||
test("1-1-1", () async {
|
||||
var channel = Channel<int>(1);
|
||||
await channel.push(1);
|
||||
var item = await channel.pop();
|
||||
expect(item, 1);
|
||||
});
|
||||
|
||||
test("1-3-1", () async {
|
||||
var channel = Channel<int>(1);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(2);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(3);
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var item = await channel.pop();
|
||||
if (item != null) {
|
||||
results.add(item);
|
||||
}
|
||||
}
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("2-3-1", () async {
|
||||
var channel = Channel<int>(2);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(2);
|
||||
}();
|
||||
() async {
|
||||
await channel.push(3);
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
var item = await channel.pop();
|
||||
if (item != null) {
|
||||
results.add(item);
|
||||
}
|
||||
}
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("1-1-3", () async {
|
||||
var channel = Channel<int>(1);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
print("push 1");
|
||||
await channel.push(1);
|
||||
print("push 2");
|
||||
await channel.push(2);
|
||||
print("push 3");
|
||||
await channel.push(3);
|
||||
print("push done");
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// consumer
|
||||
var consumers = <Future>[];
|
||||
var results = <int>[];
|
||||
for (var i = 0; i < 3; i++) {
|
||||
consumers.add(() async {
|
||||
while (true) {
|
||||
var item = await channel.pop();
|
||||
if (item == null) {
|
||||
break;
|
||||
}
|
||||
print("pop $item");
|
||||
results.add(item);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
await Future.wait(consumers);
|
||||
expect(results.length, 3);
|
||||
});
|
||||
|
||||
test("close", () async {
|
||||
var channel = Channel<int>(2);
|
||||
|
||||
// producer
|
||||
() async {
|
||||
await channel.push(1);
|
||||
await channel.push(2);
|
||||
await channel.push(3);
|
||||
channel.close();
|
||||
}();
|
||||
|
||||
// consumer
|
||||
await channel.pop();
|
||||
await channel.pop();
|
||||
await channel.pop();
|
||||
var item4 = await channel.pop();
|
||||
expect(item4, null);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user