mirror of
https://github.com/venera-app/venera.git
synced 2025-12-16 07:01:16 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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: |
|
run: |
|
||||||
choco install yq -y
|
choco install yq -y
|
||||||
pip install httpx
|
pip install httpx
|
||||||
|
- name: Install Inno Setup
|
||||||
|
run: choco install innosetup --no-progress
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
@@ -170,6 +172,9 @@ jobs:
|
|||||||
sudo apt-get update -y
|
sudo apt-get update -y
|
||||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||||
dart pub global activate flutter_to_debian
|
dart pub global activate flutter_to_debian
|
||||||
|
- name: "Patch font"
|
||||||
|
run: |
|
||||||
|
dart run patch/font.dart
|
||||||
- run: python3 debian/build.py arm64
|
- run: python3 debian/build.py arm64
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
35
.github/workflows/update_alt_store.yml
vendored
35
.github/workflows/update_alt_store.yml
vendored
@@ -31,19 +31,30 @@ jobs:
|
|||||||
- name: Update AltStore source
|
- name: Update AltStore source
|
||||||
id: update_source
|
id: update_source
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
python update_alt_store.py
|
python update_alt_store.py
|
||||||
git config --global user.name 'GitHub Action'
|
git config --global user.name 'GitHub Action'
|
||||||
git config --global user.email 'action@github.com'
|
git config --global user.email 'action@github.com'
|
||||||
git add alt_store.json
|
git add alt_store.json
|
||||||
if git diff --staged --quiet; then
|
if git diff --staged --quiet; then
|
||||||
echo "changes=false" >> $GITHUB_OUTPUT
|
echo "changes=false" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
git commit -m "Updated source with latest release"
|
# Create a new branch for the PR
|
||||||
git push
|
branch_name="update-altstore-$(date +%Y%m%d-%H%M%S)"
|
||||||
echo "changes=true" >> $GITHUB_OUTPUT
|
git checkout -b "$branch_name"
|
||||||
fi
|
git commit -m "Updated source with latest release"
|
||||||
|
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 main
|
||||||
|
|
||||||
|
echo "changes=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Calculate job duration
|
- name: Calculate job duration
|
||||||
id: duration
|
id: duration
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
collection_methods_unrelated_type: false
|
collection_methods_unrelated_type: false
|
||||||
use_build_context_synchronously: 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
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
|
|||||||
@@ -84,9 +84,8 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// Temporarily solution to fix crash
|
minifyEnabled true
|
||||||
minifyEnabled false
|
shrinkResources true
|
||||||
shrinkResources false
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,10 @@
|
|||||||
"New Folder": "新建文件夹",
|
"New Folder": "新建文件夹",
|
||||||
"Reading": "阅读中",
|
"Reading": "阅读中",
|
||||||
"Appearance": "外观",
|
"Appearance": "外观",
|
||||||
|
"Network Favorites": "网络收藏",
|
||||||
"Local Favorites": "本地收藏",
|
"Local Favorites": "本地收藏",
|
||||||
|
"Show local favorites before network favorites": "在网络收藏之前显示本地收藏",
|
||||||
|
"Auto close favorite panel after operation": "自动关闭收藏面板",
|
||||||
"APP": "应用",
|
"APP": "应用",
|
||||||
"About": "关于",
|
"About": "关于",
|
||||||
"Display mode of comic tile": "漫画缩略图的显示模式",
|
"Display mode of comic tile": "漫画缩略图的显示模式",
|
||||||
@@ -497,7 +500,10 @@
|
|||||||
"New Folder": "建立資料夾",
|
"New Folder": "建立資料夾",
|
||||||
"Reading": "閱讀中",
|
"Reading": "閱讀中",
|
||||||
"Appearance": "外觀",
|
"Appearance": "外觀",
|
||||||
|
"Network Favorites": "網路收藏",
|
||||||
"Local Favorites": "本機收藏",
|
"Local Favorites": "本機收藏",
|
||||||
|
"Show local favorites before network favorites": "在網路收藏之前顯示本機收藏",
|
||||||
|
"Auto close favorite panel after operation": "自動關閉收藏面板",
|
||||||
"APP": "應用",
|
"APP": "應用",
|
||||||
"About": "關於",
|
"About": "關於",
|
||||||
"Display mode of comic tile": "漫畫縮圖的顯示模式",
|
"Display mode of comic tile": "漫畫縮圖的顯示模式",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
|||||||
export "context.dart";
|
export "context.dart";
|
||||||
|
|
||||||
class _App {
|
class _App {
|
||||||
final version = "1.5.1";
|
final version = "1.5.3";
|
||||||
|
|
||||||
bool get isAndroid => Platform.isAndroid;
|
bool get isAndroid => Platform.isAndroid;
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
|||||||
context,
|
context,
|
||||||
animation,
|
animation,
|
||||||
secondaryAnimation,
|
secondaryAnimation,
|
||||||
enableIOSGesture
|
enableIOSGesture && App.isIOS
|
||||||
? IOSBackGestureDetector(
|
? IOSBackGestureDetector(
|
||||||
gestureWidth: _kBackGestureWidth,
|
gestureWidth: _kBackGestureWidth,
|
||||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||||
@@ -302,7 +302,7 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
|||||||
assert(mounted);
|
assert(mounted);
|
||||||
assert(_backGestureController != null);
|
assert(_backGestureController != null);
|
||||||
_backGestureController!.dragUpdate(
|
_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>>{},
|
'comicSpecificSettings': <String, Map<String, dynamic>>{},
|
||||||
'ignoreBadCertificate': false,
|
'ignoreBadCertificate': false,
|
||||||
'readerScrollSpeed': 1.0, // 0.5 - 3.0
|
'readerScrollSpeed': 1.0, // 0.5 - 3.0
|
||||||
|
'localFavoritesFirst': true,
|
||||||
|
'autoCloseFavoritePanel': false,
|
||||||
};
|
};
|
||||||
|
|
||||||
operator [](String key) {
|
operator [](String key) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:collection';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:ffi';
|
import 'dart:ffi';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
@@ -213,12 +214,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
|
|
||||||
late Map<String, int> counts;
|
late Map<String, int> counts;
|
||||||
|
|
||||||
|
var _hashedIds = <int, int>{};
|
||||||
|
|
||||||
int get totalComics {
|
int get totalComics {
|
||||||
int total = 0;
|
return _hashedIds.length;
|
||||||
for (var t in counts.values) {
|
|
||||||
total += t;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int folderComics(String folder) {
|
int folderComics(String folder) {
|
||||||
@@ -280,6 +279,48 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
for (var folder in folderNames) {
|
for (var folder in folderNames) {
|
||||||
counts[folder] = count(folder);
|
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) {
|
List<String> find(String id, ComicType type) {
|
||||||
@@ -559,7 +600,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
/// return true if success, false if already exists
|
/// return true if success, false if already exists
|
||||||
bool addComic(String folder, FavoriteItem comic,
|
bool addComic(String folder, FavoriteItem comic,
|
||||||
[int? order, String? updateTime]) {
|
[int? order, String? updateTime]) {
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
if (!existsFolder(folder)) {
|
if (!existsFolder(folder)) {
|
||||||
throw Exception("Folder does not exists");
|
throw Exception("Folder does not exists");
|
||||||
}
|
}
|
||||||
@@ -614,14 +654,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
counts[folder] = counts[folder]! + 1;
|
counts[folder] = counts[folder]! + 1;
|
||||||
}
|
}
|
||||||
|
var hash = comic.id.hashCode ^ comic.type.value;
|
||||||
|
_hashedIds[hash] = (_hashedIds[hash] ?? 0) + 1;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void moveFavorite(
|
void moveFavorite(
|
||||||
String sourceFolder, String targetFolder, String id, ComicType type) {
|
String sourceFolder, String targetFolder, String id, ComicType type) {
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
|
|
||||||
if (!existsFolder(sourceFolder)) {
|
if (!existsFolder(sourceFolder)) {
|
||||||
throw Exception("Source folder does not exist");
|
throw Exception("Source folder does not exist");
|
||||||
}
|
}
|
||||||
@@ -655,8 +695,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
|
|
||||||
void batchMoveFavorites(
|
void batchMoveFavorites(
|
||||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
|
|
||||||
if (!existsFolder(sourceFolder)) {
|
if (!existsFolder(sourceFolder)) {
|
||||||
throw Exception("Source folder does not exist");
|
throw Exception("Source folder does not exist");
|
||||||
}
|
}
|
||||||
@@ -691,25 +729,15 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
_db.execute("COMMIT");
|
_db.execute("COMMIT");
|
||||||
|
|
||||||
// Update counts
|
// Update counts
|
||||||
if (counts[targetFolder] == null) {
|
counts[targetFolder] = count(targetFolder);
|
||||||
counts[targetFolder] = count(targetFolder);
|
counts[sourceFolder] = count(sourceFolder);
|
||||||
} else {
|
refreshHashedIds();
|
||||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (counts[sourceFolder] != null) {
|
|
||||||
counts[sourceFolder] = counts[sourceFolder]! - items.length;
|
|
||||||
} else {
|
|
||||||
counts[sourceFolder] = count(sourceFolder);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void batchCopyFavorites(
|
void batchCopyFavorites(
|
||||||
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
|
|
||||||
if (!existsFolder(sourceFolder)) {
|
if (!existsFolder(sourceFolder)) {
|
||||||
throw Exception("Source folder does not exist");
|
throw Exception("Source folder does not exist");
|
||||||
}
|
}
|
||||||
@@ -740,18 +768,14 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
_db.execute("COMMIT");
|
_db.execute("COMMIT");
|
||||||
|
|
||||||
// Update counts
|
// Update counts
|
||||||
if (counts[targetFolder] == null) {
|
counts[targetFolder] = count(targetFolder);
|
||||||
counts[targetFolder] = count(targetFolder);
|
refreshHashedIds();
|
||||||
} else {
|
|
||||||
counts[targetFolder] = counts[targetFolder]! + items.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// delete a folder
|
/// delete a folder
|
||||||
void deleteFolder(String name) {
|
void deleteFolder(String name) {
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
_db.execute("""
|
_db.execute("""
|
||||||
drop table "$name";
|
drop table "$name";
|
||||||
""");
|
""");
|
||||||
@@ -760,11 +784,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
where folder_name == ?;
|
where folder_name == ?;
|
||||||
""", [name]);
|
""", [name]);
|
||||||
counts.remove(name);
|
counts.remove(name);
|
||||||
|
refreshHashedIds();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteComicWithId(String folder, String id, ComicType type) {
|
void deleteComicWithId(String folder, String id, ComicType type) {
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
LocalFavoriteImageProvider.delete(id, type.value);
|
LocalFavoriteImageProvider.delete(id, type.value);
|
||||||
_db.execute("""
|
_db.execute("""
|
||||||
delete from "$folder"
|
delete from "$folder"
|
||||||
@@ -775,11 +799,11 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
} else {
|
} else {
|
||||||
counts[folder] = count(folder);
|
counts[folder] = count(folder);
|
||||||
}
|
}
|
||||||
|
reduceHashedId(id, type.value);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
|
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
_db.execute("BEGIN TRANSACTION");
|
_db.execute("BEGIN TRANSACTION");
|
||||||
try {
|
try {
|
||||||
for (var comic in comics) {
|
for (var comic in comics) {
|
||||||
@@ -800,11 +824,13 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_db.execute("COMMIT");
|
_db.execute("COMMIT");
|
||||||
|
for (var comic in comics) {
|
||||||
|
reduceHashedId(comic.id, comic.type.value);
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
|
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
_db.execute("BEGIN TRANSACTION");
|
_db.execute("BEGIN TRANSACTION");
|
||||||
var folderNames = _getFolderNamesWithDB();
|
var folderNames = _getFolderNamesWithDB();
|
||||||
try {
|
try {
|
||||||
@@ -824,6 +850,10 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
initCounts();
|
initCounts();
|
||||||
_db.execute("COMMIT");
|
_db.execute("COMMIT");
|
||||||
|
for (var comic in comics) {
|
||||||
|
var hash = comic.id.hashCode ^ comic.type.value;
|
||||||
|
_hashedIds.remove(hash);
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,7 +938,6 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
markAsRead(id, type);
|
markAsRead(id, type);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_modifiedAfterLastCache = true;
|
|
||||||
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
|
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
|
||||||
for (final folder in folderNames) {
|
for (final folder in folderNames) {
|
||||||
var rows = _db.select("""
|
var rows = _db.select("""
|
||||||
@@ -1029,28 +1058,9 @@ class LocalFavoritesManager with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
final _cachedFavoritedIds = <String, bool>{};
|
|
||||||
|
|
||||||
bool isExist(String id, ComicType type) {
|
bool isExist(String id, ComicType type) {
|
||||||
if (_modifiedAfterLastCache) {
|
var hash = id.hashCode ^ type.value;
|
||||||
_cacheFavoritedIds();
|
return _hashedIds.containsKey(hash);
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
|
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:venera/foundation/favorites.dart';
|
import 'package:venera/foundation/favorites.dart';
|
||||||
import 'package:venera/foundation/log.dart';
|
import 'package:venera/foundation/log.dart';
|
||||||
|
import 'package:venera/utils/channel.dart';
|
||||||
|
|
||||||
class ComicUpdateResult {
|
class ComicUpdateResult {
|
||||||
final bool updated;
|
final bool updated;
|
||||||
@@ -62,6 +63,7 @@ Future<ComicUpdateResult> updateComic(
|
|||||||
return ComicUpdateResult(updated, null);
|
return ComicUpdateResult(updated, null);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
Log.error("Check Updates", e, s);
|
Log.error("Check Updates", e, s);
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
retries--;
|
retries--;
|
||||||
if (retries == 0) {
|
if (retries == 0) {
|
||||||
return ComicUpdateResult(false, e.toString());
|
return ComicUpdateResult(false, e.toString());
|
||||||
@@ -114,23 +116,50 @@ void updateFolderBase(
|
|||||||
current = 0;
|
current = 0;
|
||||||
stream.add(UpdateProgress(total, current, errors, updated));
|
stream.add(UpdateProgress(total, current, errors, updated));
|
||||||
|
|
||||||
var futures = <Future>[];
|
var channel = Channel<FavoriteItemWithUpdateInfo>(10);
|
||||||
for (var comic in comicsToUpdate) {
|
|
||||||
var future = updateComic(comic, folder).then((result) {
|
// Producer
|
||||||
current++;
|
() async {
|
||||||
if (result.updated) {
|
var c = 0;
|
||||||
updated++;
|
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));
|
updateFutures.add(f);
|
||||||
});
|
|
||||||
futures.add(future);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Future.wait(futures);
|
await Future.wait(updateFutures);
|
||||||
|
|
||||||
if (updated > 0) {
|
if (updated > 0) {
|
||||||
LocalFavoritesManager().notifyChanges();
|
LocalFavoritesManager().notifyChanges();
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ class LocalManager with ChangeNotifier {
|
|||||||
if (comic.hasChapters) {
|
if (comic.hasChapters) {
|
||||||
var cid =
|
var cid =
|
||||||
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
|
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
|
||||||
|
cid = getChapterDirectoryName(cid);
|
||||||
directory = Directory(FilePath.join(directory.path, cid));
|
directory = Directory(FilePath.join(directory.path, cid));
|
||||||
}
|
}
|
||||||
var files = <File>[];
|
var files = <File>[];
|
||||||
@@ -600,7 +601,10 @@ class LocalManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
var shouldRemovedDirs = <Directory>[];
|
var shouldRemovedDirs = <Directory>[];
|
||||||
for (var chapter in chapters) {
|
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()) {
|
if (dir.existsSync()) {
|
||||||
shouldRemovedDirs.add(dir);
|
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 {
|
enum LocalSortType {
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
tertiary = light.tertiary;
|
tertiary = light.tertiary;
|
||||||
}
|
}
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
title: "venera",
|
||||||
home: home,
|
home: home,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: getTheme(primary, secondary, tertiary, Brightness.light),
|
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
|
/// https://github.com/flutter/flutter/issues/161086
|
||||||
var isPaddingCheckError =
|
var isPaddingCheckError =
|
||||||
MediaQuery.of(context).viewPadding.top <= 0 ||
|
MediaQuery.of(context).viewPadding.top <= 0 ||
|
||||||
MediaQuery.of(context).viewPadding.top > 50;
|
MediaQuery.of(context).viewPadding.top > 200;
|
||||||
|
|
||||||
if (isPaddingCheckError && Platform.isAndroid) {
|
if (isPaddingCheckError && Platform.isAndroid) {
|
||||||
widget = MediaQuery(
|
widget = MediaQuery(
|
||||||
|
|||||||
@@ -107,7 +107,21 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
|||||||
var local = LocalManager().find(id, comicType);
|
var local = LocalManager().find(id, comicType);
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
if (local == 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) {
|
} else if (chapters != null) {
|
||||||
for (var c in chapters!) {
|
for (var c in chapters!) {
|
||||||
var dir = Directory(FilePath.join(path!, c));
|
var dir = Directory(FilePath.join(path!, c));
|
||||||
@@ -197,7 +211,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
|||||||
if (comic!.chapters != null) {
|
if (comic!.chapters != null) {
|
||||||
saveTo = Directory(FilePath.join(
|
saveTo = Directory(FilePath.join(
|
||||||
path!,
|
path!,
|
||||||
_images!.keys.elementAt(_chapter),
|
LocalManager.getChapterDirectoryName(
|
||||||
|
_images!.keys.elementAt(_chapter),
|
||||||
|
),
|
||||||
));
|
));
|
||||||
if (!saveTo.existsSync()) {
|
if (!saveTo.existsSync()) {
|
||||||
saveTo.createSync(recursive: true);
|
saveTo.createSync(recursive: true);
|
||||||
|
|||||||
@@ -17,39 +17,50 @@ class CategoriesPage extends StatefulWidget {
|
|||||||
State<CategoriesPage> createState() => _CategoriesPageState();
|
State<CategoriesPage> createState() => _CategoriesPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CategoriesPageState extends State<CategoriesPage> {
|
class _CategoriesPageState extends State<CategoriesPage>
|
||||||
|
with
|
||||||
|
TickerProviderStateMixin,
|
||||||
|
AutomaticKeepAliveClientMixin<CategoriesPage> {
|
||||||
var categories = <String>[];
|
var categories = <String>[];
|
||||||
|
|
||||||
|
late TabController controller;
|
||||||
|
|
||||||
void onSettingsChanged() {
|
void onSettingsChanged() {
|
||||||
var categories =
|
var categories = List.from(
|
||||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
appdata.settings["categories"],
|
||||||
|
).whereType<String>().toList();
|
||||||
var allCategories = ComicSource.all()
|
var allCategories = ComicSource.all()
|
||||||
.map((e) => e.categoryData?.key)
|
.map((e) => e.categoryData?.key)
|
||||||
.where((element) => element != null)
|
.where((element) => element != null)
|
||||||
.map((e) => e!)
|
.map((e) => e!)
|
||||||
.toList();
|
.toList();
|
||||||
categories =
|
categories = categories
|
||||||
categories.where((element) => allCategories.contains(element)).toList();
|
.where((element) => allCategories.contains(element))
|
||||||
|
.toList();
|
||||||
if (!categories.isEqualTo(this.categories)) {
|
if (!categories.isEqualTo(this.categories)) {
|
||||||
setState(() {
|
setState(() {
|
||||||
this.categories = categories;
|
this.categories = categories;
|
||||||
});
|
});
|
||||||
|
controller = TabController(length: categories.length, vsync: this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
var categories =
|
var categories = List.from(
|
||||||
List.from(appdata.settings["categories"]).whereType<String>().toList();
|
appdata.settings["categories"],
|
||||||
|
).whereType<String>().toList();
|
||||||
var allCategories = ComicSource.all()
|
var allCategories = ComicSource.all()
|
||||||
.map((e) => e.categoryData?.key)
|
.map((e) => e.categoryData?.key)
|
||||||
.where((element) => element != null)
|
.where((element) => element != null)
|
||||||
.map((e) => e!)
|
.map((e) => e!)
|
||||||
.toList();
|
.toList();
|
||||||
this.categories =
|
this.categories = categories
|
||||||
categories.where((element) => allCategories.contains(element)).toList();
|
.where((element) => allCategories.contains(element))
|
||||||
|
.toList();
|
||||||
appdata.settings.addListener(onSettingsChanged);
|
appdata.settings.addListener(onSettingsChanged);
|
||||||
|
controller = TabController(length: categories.length, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
void addPage() {
|
void addPage() {
|
||||||
@@ -59,6 +70,7 @@ class _CategoriesPageState extends State<CategoriesPage> {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
controller.dispose();
|
||||||
appdata.settings.removeListener(onSettingsChanged);
|
appdata.settings.removeListener(onSettingsChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,46 +97,45 @@ class _CategoriesPageState extends State<CategoriesPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
if (categories.isEmpty) {
|
if (categories.isEmpty) {
|
||||||
return buildEmpty();
|
return buildEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
child: DefaultTabController(
|
child: Column(
|
||||||
length: categories.length,
|
children: [
|
||||||
key: Key(categories.toString()),
|
AppTabBar(
|
||||||
child: Column(
|
controller: controller,
|
||||||
children: [
|
key: PageStorageKey(categories.toString()),
|
||||||
AppTabBar(
|
tabs: categories.map((e) {
|
||||||
key: PageStorageKey(categories.toString()),
|
String title = e;
|
||||||
tabs: categories.map((e) {
|
try {
|
||||||
String title = e;
|
title = getCategoryDataWithKey(e).title;
|
||||||
try {
|
} catch (e) {
|
||||||
title = getCategoryDataWithKey(e).title;
|
//
|
||||||
} catch (e) {
|
}
|
||||||
//
|
return Tab(text: title, key: Key(e));
|
||||||
}
|
}).toList(),
|
||||||
return Tab(
|
actionButton: TabActionButton(
|
||||||
text: title,
|
icon: const Icon(Icons.add),
|
||||||
key: Key(e),
|
text: "Add".tl,
|
||||||
);
|
onPressed: addPage,
|
||||||
}).toList(),
|
),
|
||||||
actionButton: TabActionButton(
|
).paddingTop(context.padding.top),
|
||||||
icon: const Icon(Icons.add),
|
Expanded(
|
||||||
text: "Add".tl,
|
child: TabBarView(
|
||||||
onPressed: addPage,
|
controller: controller,
|
||||||
),
|
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||||
).paddingTop(context.padding.top),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: TabBarView(
|
],
|
||||||
children: categories.map((e) => _CategoryPage(e)).toList(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef ClickTagCallback = void Function(String, String?);
|
typedef ClickTagCallback = void Function(String, String?);
|
||||||
@@ -150,38 +161,42 @@ class _CategoryPage extends StatelessWidget {
|
|||||||
var children = <Widget>[];
|
var children = <Widget>[];
|
||||||
if (data.enableRankingPage || data.buttons.isNotEmpty) {
|
if (data.enableRankingPage || data.buttons.isNotEmpty) {
|
||||||
children.add(buildTitle(data.title));
|
children.add(buildTitle(data.title));
|
||||||
children.add(Padding(
|
children.add(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
Padding(
|
||||||
child: Wrap(
|
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||||
children: [
|
child: Wrap(
|
||||||
if (data.enableRankingPage)
|
children: [
|
||||||
buildTag("Ranking".tl, () {
|
if (data.enableRankingPage)
|
||||||
context.to(() => RankingPage(categoryKey: data.key));
|
buildTag("Ranking".tl, () {
|
||||||
}),
|
context.to(() => RankingPage(categoryKey: data.key));
|
||||||
for (var buttonData in data.buttons)
|
}),
|
||||||
buildTag(buttonData.label.tl, buttonData.onTap)
|
for (var buttonData in data.buttons)
|
||||||
],
|
buildTag(buttonData.label.tl, buttonData.onTap),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (var part in data.categories) {
|
for (var part in data.categories) {
|
||||||
if (part.enableRandom) {
|
if (part.enableRandom) {
|
||||||
children.add(StatefulBuilder(builder: (context, updater) {
|
children.add(
|
||||||
return Column(
|
StatefulBuilder(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, updater) {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
buildTitleWithRefresh(part.title, () => updater(() {})),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
buildTags(part.categories)
|
children: [
|
||||||
],
|
buildTitleWithRefresh(part.title, () => updater(() {})),
|
||||||
);
|
buildTags(part.categories),
|
||||||
}));
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
children.add(buildTitle(part.title));
|
children.add(buildTitle(part.title));
|
||||||
children.add(
|
children.add(buildTags(part.categories));
|
||||||
buildTags(part.categories),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
@@ -195,8 +210,10 @@ class _CategoryPage extends StatelessWidget {
|
|||||||
Widget buildTitle(String title) {
|
Widget buildTitle(String title) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 10, 5, 10),
|
padding: const EdgeInsets.fromLTRB(16, 10, 5, 10),
|
||||||
child: Text(title.tl,
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500)),
|
title.tl,
|
||||||
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,21 +224,16 @@ class _CategoryPage extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title.tl,
|
title.tl,
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500),
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh))
|
IconButton(onPressed: onRefresh, icon: const Icon(Icons.refresh)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildTags(
|
Widget buildTags(List<CategoryItem> categories) {
|
||||||
List<CategoryItem> categories,
|
|
||||||
) {
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
padding: const EdgeInsets.fromLTRB(10, 0, 10, 16),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
|
|||||||
@@ -56,8 +56,12 @@ abstract mixin class _ComicPageActions {
|
|||||||
type: comic.comicType,
|
type: comic.comicType,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
onFavorite: (local, network) {
|
onFavorite: (local, network) {
|
||||||
isFavorite = network ?? isFavorite;
|
if (network != null) {
|
||||||
isAddToLocalFav = local ?? isAddToLocalFav;
|
isFavorite = network;
|
||||||
|
}
|
||||||
|
if (local != null) {
|
||||||
|
isAddToLocalFav = local;
|
||||||
|
}
|
||||||
update();
|
update();
|
||||||
},
|
},
|
||||||
favoriteItem: _toFavoriteItem(),
|
favoriteItem: _toFavoriteItem(),
|
||||||
@@ -151,64 +155,60 @@ abstract mixin class _ComicPageActions {
|
|||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
return ContentDialog(
|
return ContentDialog(
|
||||||
title: "Download".tl,
|
title: "Download".tl,
|
||||||
content: Column(
|
content: RadioGroup<int>(
|
||||||
mainAxisSize: MainAxisSize.min,
|
groupValue: selected,
|
||||||
children: [
|
onChanged: (v) {
|
||||||
RadioListTile<int>(
|
setState(() {
|
||||||
value: -1,
|
selected = v ?? selected;
|
||||||
groupValue: selected,
|
});
|
||||||
title: Text("Normal".tl),
|
},
|
||||||
onChanged: (v) {
|
child: Column(
|
||||||
setState(() {
|
mainAxisSize: MainAxisSize.min,
|
||||||
selected = v!;
|
children: [
|
||||||
});
|
RadioListTile<int>(
|
||||||
},
|
value: -1,
|
||||||
),
|
title: Text("Normal".tl),
|
||||||
ExpansionTile(
|
|
||||||
title: Text("Archive".tl),
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.zero,
|
|
||||||
),
|
),
|
||||||
collapsedShape: const RoundedRectangleBorder(
|
ExpansionTile(
|
||||||
borderRadius: BorderRadius.zero,
|
title: Text("Archive".tl),
|
||||||
),
|
shape: const RoundedRectangleBorder(
|
||||||
onExpansionChanged: (b) {
|
borderRadius: BorderRadius.zero,
|
||||||
if (!isLoading && b && archives == null) {
|
),
|
||||||
isLoading = true;
|
collapsedShape: const RoundedRectangleBorder(
|
||||||
comicSource.archiveDownloader!
|
borderRadius: BorderRadius.zero,
|
||||||
.getArchives(comic.id)
|
),
|
||||||
.then((value) {
|
onExpansionChanged: (b) {
|
||||||
if (value.success) {
|
if (!isLoading && b && archives == null) {
|
||||||
archives = value.data;
|
isLoading = true;
|
||||||
} else {
|
comicSource.archiveDownloader!
|
||||||
App.rootContext
|
.getArchives(comic.id)
|
||||||
.showMessage(message: value.errorMessage!);
|
.then((value) {
|
||||||
}
|
if (value.success) {
|
||||||
setState(() {
|
archives = value.data;
|
||||||
isLoading = false;
|
} else {
|
||||||
|
App.rootContext
|
||||||
|
.showMessage(message: value.errorMessage!);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
},
|
||||||
},
|
children: [
|
||||||
children: [
|
if (archives == null)
|
||||||
if (archives == null)
|
const ListLoadingIndicator().toCenter()
|
||||||
const ListLoadingIndicator().toCenter()
|
else
|
||||||
else
|
for (int i = 0; i < archives!.length; i++)
|
||||||
for (int i = 0; i < archives!.length; i++)
|
RadioListTile<int>(
|
||||||
RadioListTile<int>(
|
value: i,
|
||||||
value: i,
|
title: Text(archives![i].title),
|
||||||
groupValue: selected,
|
subtitle: Text(archives![i].description),
|
||||||
onChanged: (v) {
|
)
|
||||||
setState(() {
|
],
|
||||||
selected = v!;
|
)
|
||||||
});
|
],
|
||||||
},
|
),
|
||||||
title: Text(archives![i].title),
|
|
||||||
subtitle: Text(archives![i].description),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
Button.filled(
|
Button.filled(
|
||||||
@@ -233,10 +233,12 @@ abstract mixin class _ComicPageActions {
|
|||||||
isGettingLink = false;
|
isGettingLink = false;
|
||||||
});
|
});
|
||||||
} else if (context.mounted) {
|
} else if (context.mounted) {
|
||||||
LocalManager()
|
if (res.data.isNotEmpty) {
|
||||||
|
LocalManager()
|
||||||
.addTask(ArchiveDownloadTask(res.data, comic));
|
.addTask(ArchiveDownloadTask(res.data, comic));
|
||||||
App.rootContext
|
App.rootContext
|
||||||
.showMessage(message: "Download started".tl);
|
.showMessage(message: "Download started".tl);
|
||||||
|
}
|
||||||
context.pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:shimmer_animation/shimmer_animation.dart';
|
import 'package:shimmer_animation/shimmer_animation.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
import 'package:sliver_tools/sliver_tools.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.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/local.dart';
|
||||||
import 'package:venera/foundation/res.dart';
|
import 'package:venera/foundation/res.dart';
|
||||||
import 'package:venera/network/download.dart';
|
import 'package:venera/network/download.dart';
|
||||||
|
import 'package:venera/network/cache.dart';
|
||||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||||
import 'package:venera/pages/reader/reader.dart';
|
import 'package:venera/pages/reader/reader.dart';
|
||||||
import 'package:venera/utils/app_links.dart';
|
import 'package:venera/utils/app_links.dart';
|
||||||
import 'package:venera/utils/ext.dart';
|
import 'package:venera/utils/ext.dart';
|
||||||
|
import 'package:venera/utils/file_type.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
import 'package:venera/utils/tags_translation.dart';
|
import 'package:venera/utils/tags_translation.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
@@ -38,6 +43,8 @@ part 'comments_preview.dart';
|
|||||||
|
|
||||||
part 'actions.dart';
|
part 'actions.dart';
|
||||||
|
|
||||||
|
part 'cover_viewer.dart';
|
||||||
|
|
||||||
class ComicPage extends StatefulWidget {
|
class ComicPage extends StatefulWidget {
|
||||||
const ComicPage({
|
const ComicPage({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -256,6 +263,18 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
Future<void> onDataLoaded() async {
|
Future<void> onDataLoaded() async {
|
||||||
isLiked = comic.isLiked ?? false;
|
isLiked = comic.isLiked ?? false;
|
||||||
isFavorite = comic.isFavorite ?? 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) {
|
if (comic.chapters == null) {
|
||||||
isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
|
isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
|
||||||
}
|
}
|
||||||
@@ -283,31 +302,35 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Hero(
|
GestureDetector(
|
||||||
tag: "cover${widget.heroID}",
|
onTap: () => _viewCover(context),
|
||||||
child: Container(
|
onLongPress: () => _saveCover(context),
|
||||||
decoration: BoxDecoration(
|
child: Hero(
|
||||||
color: context.colorScheme.primaryContainer,
|
tag: "cover${widget.heroID}",
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: Container(
|
||||||
boxShadow: [
|
decoration: BoxDecoration(
|
||||||
BoxShadow(
|
color: context.colorScheme.primaryContainer,
|
||||||
color: context.colorScheme.outlineVariant,
|
borderRadius: BorderRadius.circular(8),
|
||||||
blurRadius: 1,
|
boxShadow: [
|
||||||
offset: const Offset(0, 1),
|
BoxShadow(
|
||||||
),
|
color: context.colorScheme.outlineVariant,
|
||||||
],
|
blurRadius: 1,
|
||||||
),
|
offset: const Offset(0, 1),
|
||||||
height: 144,
|
),
|
||||||
width: 144 * 0.72,
|
],
|
||||||
clipBehavior: Clip.antiAlias,
|
),
|
||||||
child: AnimatedImage(
|
height: 144,
|
||||||
image: CachedImageProvider(
|
width: 144 * 0.72,
|
||||||
widget.cover ?? comic.cover,
|
clipBehavior: Clip.antiAlias,
|
||||||
sourceKey: comic.sourceKey,
|
child: AnimatedImage(
|
||||||
cid: comic.id,
|
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);
|
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 {
|
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 {
|
with SingleTickerProviderStateMixin {
|
||||||
late ComicSource comicSource;
|
late ComicSource comicSource;
|
||||||
|
|
||||||
late TabController tabController;
|
|
||||||
|
|
||||||
late bool hasNetwork;
|
late bool hasNetwork;
|
||||||
|
|
||||||
|
late List<String> localFolders;
|
||||||
|
|
||||||
|
late List<String> added;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
comicSource = widget.type.comicSource!;
|
comicSource = widget.type.comicSource!;
|
||||||
localFolders = LocalFavoritesManager().folderNames;
|
localFolders = LocalFavoritesManager().folderNames;
|
||||||
added = LocalFavoritesManager().find(widget.cid, widget.type);
|
added = LocalFavoritesManager().find(widget.cid, widget.type);
|
||||||
hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
|
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();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
var currentIndex = tabController.index;
|
|
||||||
appdata.implicitData['favoritePanelIndex'] = currentIndex;
|
|
||||||
appdata.writeImplicitData();
|
|
||||||
tabController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: Appbar(
|
appBar: Appbar(title: Text("Favorite".tl)),
|
||||||
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 {
|
class _FavoriteList extends StatefulWidget {
|
||||||
const _NetworkFavorites({
|
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.cid,
|
||||||
required this.comicSource,
|
required this.comicSource,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
@@ -232,82 +156,55 @@ class _NetworkFavorites extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
final String cid;
|
final String cid;
|
||||||
|
|
||||||
final ComicSource comicSource;
|
final ComicSource comicSource;
|
||||||
|
|
||||||
final bool? isFavorite;
|
final bool? isFavorite;
|
||||||
|
|
||||||
final void Function(bool) onFavorite;
|
final void Function(bool) onFavorite;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
|
State<_NetworkSection> createState() => _NetworkSectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
class _NetworkSectionState extends State<_NetworkSection> {
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
|
|
||||||
|
|
||||||
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isLoading = false;
|
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;
|
Map<String, String>? folders;
|
||||||
|
|
||||||
var addedFolders = <String>{};
|
var addedFolders = <String>{};
|
||||||
|
|
||||||
var isLoadingFolders = true;
|
var isLoadingFolders = true;
|
||||||
|
bool? localIsFavorite;
|
||||||
|
final Map<String, bool> _itemLoading = {};
|
||||||
|
late List<double> _skeletonWidths;
|
||||||
|
|
||||||
// for network favorites, only one selection is allowed
|
@override
|
||||||
String? selected;
|
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 {
|
void loadFolders() async {
|
||||||
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
|
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
context.showMessage(message: res.errorMessage!);
|
context.showMessage(message: res.errorMessage!);
|
||||||
|
setState(() {
|
||||||
|
isLoadingFolders = false;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
folders = res.data;
|
folders = res.data;
|
||||||
if (res.subData is List) {
|
if (res.subData is List) {
|
||||||
addedFolders = List<String>.from(res.subData).toSet();
|
final list = List<String>.from(res.subData);
|
||||||
|
if (list.isNotEmpty) {
|
||||||
|
addedFolders = {list.first};
|
||||||
|
} else {
|
||||||
|
addedFolders.clear();
|
||||||
|
}
|
||||||
|
localIsFavorite = addedFolders.isNotEmpty;
|
||||||
|
} else {
|
||||||
|
addedFolders.clear();
|
||||||
|
localIsFavorite = false;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoadingFolders = false;
|
isLoadingFolders = false;
|
||||||
@@ -315,118 +212,470 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildMultiFolder() {
|
Widget _buildLoadingSkeleton() {
|
||||||
if (widget.isFavorite == true &&
|
return Column(
|
||||||
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
return Column(
|
children: [
|
||||||
children: [
|
Padding(
|
||||||
Expanded(
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
child: Center(
|
child: Text(
|
||||||
child: Text("Added to favorites".tl),
|
"Network Favorites".tl,
|
||||||
|
style: ts.s14.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
),
|
||||||
child: Button.filled(
|
Shimmer(
|
||||||
isLoading: isLoading,
|
child: Column(
|
||||||
onPressed: () async {
|
children: List.generate(3, (index) {
|
||||||
setState(() {
|
return ListTile(
|
||||||
isLoading = true;
|
title: Container(
|
||||||
});
|
height: 20,
|
||||||
|
width: double.infinity,
|
||||||
var res = await widget.comicSource.favoriteData!
|
margin: const EdgeInsets.only(right: 16),
|
||||||
.addOrDelFavorite!(widget.cid, '', false, null);
|
child: FractionallySizedBox(
|
||||||
if (res.success) {
|
widthFactor: _skeletonWidths[index],
|
||||||
widget.onFavorite(false);
|
alignment: Alignment.centerLeft,
|
||||||
context.pop();
|
child: Container(
|
||||||
App.rootContext.showMessage(message: "Removed".tl);
|
decoration: BoxDecoration(
|
||||||
} else {
|
color: context.colorScheme.surfaceContainerLow,
|
||||||
setState(() {
|
borderRadius: BorderRadius.circular(4),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
value: selected == id,
|
),
|
||||||
onChanged: (v) {
|
trailing: Container(
|
||||||
setState(() {
|
height: 28,
|
||||||
selected = id;
|
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;
|
@override
|
||||||
}
|
Widget build(BuildContext context) {
|
||||||
setState(() {
|
if (isLoadingFolders) {
|
||||||
isLoading = true;
|
return _buildLoadingSkeleton();
|
||||||
});
|
}
|
||||||
var res =
|
|
||||||
await widget.comicSource.favoriteData!.addOrDelFavorite!(
|
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
|
||||||
widget.cid,
|
|
||||||
selected!,
|
if (isMultiFolder) {
|
||||||
!addedFolders.contains(selected!),
|
return _buildMultiFolder();
|
||||||
null,
|
} else {
|
||||||
);
|
return _buildSingleFolder();
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if (localIsFavorite == true &&
|
||||||
|
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
|
||||||
|
return ListTile(
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Text("Network Favorites".tl),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
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: true,
|
||||||
|
onTap: () async {
|
||||||
|
setState(() {
|
||||||
|
isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var res = await widget
|
||||||
|
.comicSource
|
||||||
|
.favoriteData!
|
||||||
|
.addOrDelFavorite!(widget.cid, '', false, null);
|
||||||
|
if (res.success) {
|
||||||
|
// Invalidate network cache so subsequent loads see latest
|
||||||
|
NetworkCacheManager().clear();
|
||||||
|
setState(() {
|
||||||
|
localIsFavorite = false;
|
||||||
|
});
|
||||||
|
widget.onFavorite(false);
|
||||||
|
App.rootContext.showMessage(message: "Removed".tl);
|
||||||
|
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context.showMessage(message: res.errorMessage!);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
isLoading = 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...folders!.entries.map((entry) {
|
||||||
|
var name = entry.value;
|
||||||
|
var id = entry.key;
|
||||||
|
var isAdded = addedFolders.contains(id);
|
||||||
|
var hasSelection = addedFolders.isNotEmpty;
|
||||||
|
var enabled = !hasSelection || 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.clear();
|
||||||
|
} else {
|
||||||
|
addedFolders
|
||||||
|
..clear()
|
||||||
|
..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/log.dart';
|
||||||
import 'package:venera/foundation/res.dart';
|
import 'package:venera/foundation/res.dart';
|
||||||
import 'package:venera/network/download.dart';
|
import 'package:venera/network/download.dart';
|
||||||
|
import 'package:venera/network/cache.dart';
|
||||||
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
import 'package:venera/pages/comic_details_page/comic_page.dart';
|
||||||
import 'package:venera/pages/reader/reader.dart';
|
import 'package:venera/pages/reader/reader.dart';
|
||||||
import 'package:venera/pages/settings/settings_page.dart';
|
import 'package:venera/pages/settings/settings_page.dart';
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Future<bool> _deleteComic(
|
|||||||
favId,
|
favId,
|
||||||
);
|
);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
// Invalidate network cache so next loads fetch fresh data
|
||||||
|
NetworkCacheManager().clear();
|
||||||
context.showMessage(message: "Deleted".tl);
|
context.showMessage(message: "Deleted".tl);
|
||||||
result = true;
|
result = true;
|
||||||
context.pop();
|
context.pop();
|
||||||
@@ -115,6 +117,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
|
|||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
// Force refresh bypassing cache
|
||||||
|
NetworkCacheManager().clear();
|
||||||
comicListKey.currentState!.refresh();
|
comicListKey.currentState!.refresh();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -514,51 +514,53 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
|||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Column(
|
: RadioGroup<int>(
|
||||||
key: key,
|
groupValue: type,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onChanged: (value) {
|
||||||
children: [
|
setState(() {
|
||||||
const SizedBox(width: 600),
|
type = value ?? type;
|
||||||
...List.generate(importMethods.length, (index) {
|
});
|
||||||
return RadioListTile(
|
},
|
||||||
title: Text(importMethods[index]),
|
child: Column(
|
||||||
value: index,
|
key: key,
|
||||||
groupValue: type,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
onChanged: (value) {
|
children: [
|
||||||
setState(() {
|
const SizedBox(width: 600),
|
||||||
type = value as int;
|
...List.generate(importMethods.length, (index) {
|
||||||
});
|
return RadioListTile<int>(
|
||||||
},
|
title: Text(importMethods[index]),
|
||||||
);
|
value: index,
|
||||||
}),
|
);
|
||||||
if (type != 4)
|
}),
|
||||||
ListTile(
|
if (type != 4)
|
||||||
title: Text("Add to favorites".tl),
|
ListTile(
|
||||||
trailing: Select(
|
title: Text("Add to favorites".tl),
|
||||||
current: selectedFolder,
|
trailing: Select(
|
||||||
values: folders,
|
current: selectedFolder,
|
||||||
minWidth: 112,
|
values: folders,
|
||||||
onTap: (v) {
|
minWidth: 112,
|
||||||
setState(() {
|
onTap: (v) {
|
||||||
selectedFolder = folders[v];
|
setState(() {
|
||||||
});
|
selectedFolder = folders[v];
|
||||||
},
|
});
|
||||||
),
|
},
|
||||||
).paddingHorizontal(8),
|
),
|
||||||
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
).paddingHorizontal(8),
|
||||||
CheckboxListTile(
|
if (!App.isIOS && !App.isMacOS && type != 2 && type != 3)
|
||||||
enabled: true,
|
CheckboxListTile(
|
||||||
title: Text("Copy to app local path".tl),
|
enabled: true,
|
||||||
value: copyToLocalFolder,
|
title: Text("Copy to app local path".tl),
|
||||||
onChanged: (v) {
|
value: copyToLocalFolder,
|
||||||
setState(() {
|
onChanged: (v) {
|
||||||
copyToLocalFolder = !copyToLocalFolder;
|
setState(() {
|
||||||
});
|
copyToLocalFolder = !copyToLocalFolder;
|
||||||
}).paddingHorizontal(8),
|
});
|
||||||
const SizedBox(height: 8),
|
}).paddingHorizontal(8),
|
||||||
Text(info).paddingHorizontal(24),
|
const SizedBox(height: 8),
|
||||||
],
|
Text(info).paddingHorizontal(24),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
Button.text(
|
Button.text(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -404,21 +404,23 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
|
|||||||
children: [
|
children: [
|
||||||
tabBar,
|
tabBar,
|
||||||
TabViewBody(children: [
|
TabViewBody(children: [
|
||||||
Column(
|
RadioGroup<ImageFavoriteSortType>(
|
||||||
children: ImageFavoriteSortType.values
|
groupValue: sortType,
|
||||||
.map(
|
onChanged: (v) {
|
||||||
(e) => RadioListTile<ImageFavoriteSortType>(
|
setState(() {
|
||||||
title: Text(e.value.tl),
|
sortType = v ?? sortType;
|
||||||
value: e,
|
});
|
||||||
groupValue: sortType,
|
},
|
||||||
onChanged: (v) {
|
child: Column(
|
||||||
setState(() {
|
children: ImageFavoriteSortType.values
|
||||||
sortType = v!;
|
.map(
|
||||||
});
|
(e) => RadioListTile<ImageFavoriteSortType>(
|
||||||
},
|
title: Text(e.value.tl),
|
||||||
),
|
value: e,
|
||||||
)
|
),
|
||||||
.toList(),
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -70,39 +70,29 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
|||||||
return StatefulBuilder(builder: (context, setState) {
|
return StatefulBuilder(builder: (context, setState) {
|
||||||
return ContentDialog(
|
return ContentDialog(
|
||||||
title: "Sort".tl,
|
title: "Sort".tl,
|
||||||
content: Column(
|
content: RadioGroup<LocalSortType>(
|
||||||
children: [
|
groupValue: sortType,
|
||||||
RadioListTile<LocalSortType>(
|
onChanged: (v) {
|
||||||
title: Text("Name".tl),
|
setState(() {
|
||||||
value: LocalSortType.name,
|
sortType = v ?? sortType;
|
||||||
groupValue: sortType,
|
});
|
||||||
onChanged: (v) {
|
},
|
||||||
setState(() {
|
child: Column(
|
||||||
sortType = v!;
|
children: [
|
||||||
});
|
RadioListTile<LocalSortType>(
|
||||||
},
|
title: Text("Name".tl),
|
||||||
),
|
value: LocalSortType.name,
|
||||||
RadioListTile<LocalSortType>(
|
),
|
||||||
title: Text("Date".tl),
|
RadioListTile<LocalSortType>(
|
||||||
value: LocalSortType.timeAsc,
|
title: Text("Date".tl),
|
||||||
groupValue: sortType,
|
value: LocalSortType.timeAsc,
|
||||||
onChanged: (v) {
|
),
|
||||||
setState(() {
|
RadioListTile<LocalSortType>(
|
||||||
sortType = v!;
|
title: Text("Date Desc".tl),
|
||||||
});
|
value: LocalSortType.timeDesc,
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
RadioListTile<LocalSortType>(
|
),
|
||||||
title: Text("Date Desc".tl),
|
|
||||||
value: LocalSortType.timeDesc,
|
|
||||||
groupValue: sortType,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() {
|
|
||||||
sortType = v!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
FilledButton(
|
FilledButton(
|
||||||
|
|||||||
@@ -140,12 +140,12 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
int get totalPages {
|
int get totalPages {
|
||||||
if (!reader.showSingleImageOnFirstPage()) {
|
if (!reader.showSingleImageOnFirstPage()) {
|
||||||
return (reader.images!.length /
|
return (reader.images!.length /
|
||||||
reader.imagesPerPage())
|
reader.imagesPerPage)
|
||||||
.ceil();
|
.ceil();
|
||||||
} else {
|
} else {
|
||||||
return 1 +
|
return 1 +
|
||||||
((reader.images!.length - 1) /
|
((reader.images!.length - 1) /
|
||||||
reader.imagesPerPage())
|
reader.imagesPerPage)
|
||||||
.ceil();
|
.ceil();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
|
|
||||||
/// Get the range of images for the given page. [page] is 1-based.
|
/// Get the range of images for the given page. [page] is 1-based.
|
||||||
(int start, int end) getPageImagesRange(int page) {
|
(int start, int end) getPageImagesRange(int page) {
|
||||||
var imagesPerPage = reader.imagesPerPage();
|
var imagesPerPage = reader.imagesPerPage;
|
||||||
if (reader.showSingleImageOnFirstPage()) {
|
if (reader.showSingleImageOnFirstPage()) {
|
||||||
if (page == 1) {
|
if (page == 1) {
|
||||||
return (0, 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.
|
/// [cache] is used to cache the images.
|
||||||
/// The count of images to cache is determined by the [preCacheCount] setting.
|
/// The count of images to cache is determined by the [preCacheCount] setting.
|
||||||
/// For previous page and next page, it will do a memory cache.
|
/// For previous page and next page, it will do a memory cache.
|
||||||
@@ -259,7 +269,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
|
|
||||||
photoViewControllers[index] ??= PhotoViewController();
|
photoViewControllers[index] ??= PhotoViewController();
|
||||||
|
|
||||||
if (reader.imagesPerPage() == 1 ||
|
if (reader.imagesPerPage == 1 ||
|
||||||
pageImages.length == 1) {
|
pageImages.length == 1) {
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
filterQuality: FilterQuality.medium,
|
filterQuality: FilterQuality.medium,
|
||||||
@@ -533,17 +543,27 @@ class _GalleryModeState extends State<_GalleryMode>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String? getImageKeyByOffset(Offset offset) {
|
String? getImageKeyByOffset(Offset offset) {
|
||||||
String? imageKey;
|
var range = getCurrentPageImageRange();
|
||||||
if (reader.imagesPerPage() == 1) {
|
if (range == null) return null;
|
||||||
imageKey = reader.images![reader.page - 1];
|
|
||||||
} else {
|
var (startIndex, endIndex) = range;
|
||||||
for (var imageState in imageStates) {
|
int actualImageCount = endIndex - startIndex;
|
||||||
if ((imageState as _ComicImageState).containsPoint(offset)) {
|
|
||||||
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey;
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
if (!showSingleImageOnFirstPage()) {
|
if (!showSingleImageOnFirstPage()) {
|
||||||
return (images!.length / imagesPerPage()).ceil();
|
return (images!.length / imagesPerPage).ceil();
|
||||||
} else {
|
} 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;
|
history!.page = images?.length ?? 1;
|
||||||
} else {
|
} else {
|
||||||
/// Record the first image of the page
|
/// Record the first image of the page
|
||||||
if (!showSingleImageOnFirstPage() || imagesPerPage() == 1) {
|
if (!showSingleImageOnFirstPage() || imagesPerPage == 1) {
|
||||||
history!.page = (page - 1) * imagesPerPage() + 1;
|
history!.page = (page - 1) * imagesPerPage + 1;
|
||||||
} else {
|
} else {
|
||||||
if (page == 1) {
|
if (page == 1) {
|
||||||
history!.page = 1;
|
history!.page = 1;
|
||||||
} else {
|
} else {
|
||||||
history!.page = (page - 2) * imagesPerPage() + 2;
|
history!.page = (page - 2) * imagesPerPage + 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,13 +371,13 @@ abstract mixin class _ImagePerPageHandler {
|
|||||||
ComicType get type;
|
ComicType get type;
|
||||||
|
|
||||||
void initImagesPerPage(int initialPage) {
|
void initImagesPerPage(int initialPage) {
|
||||||
_lastImagesPerPage = imagesPerPage();
|
_lastImagesPerPage = imagesPerPage;
|
||||||
_lastOrientation = isPortrait;
|
_lastOrientation = isPortrait;
|
||||||
if (imagesPerPage() != 1) {
|
if (imagesPerPage != 1) {
|
||||||
if (showSingleImageOnFirstPage()) {
|
if (showSingleImageOnFirstPage()) {
|
||||||
page = ((initialPage - 1) / imagesPerPage()).ceil() + 1;
|
page = ((initialPage - 1) / imagesPerPage).ceil() + 1;
|
||||||
} else {
|
} else {
|
||||||
page = (initialPage / imagesPerPage()).ceil();
|
page = (initialPage / imagesPerPage).ceil();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,7 +386,7 @@ abstract mixin class _ImagePerPageHandler {
|
|||||||
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
|
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
|
||||||
|
|
||||||
/// The number of images displayed on one screen
|
/// The number of images displayed on one screen
|
||||||
int imagesPerPage() {
|
int get imagesPerPage {
|
||||||
if (mode.isContinuous) return 1;
|
if (mode.isContinuous) return 1;
|
||||||
if (isPortrait) {
|
if (isPortrait) {
|
||||||
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
|
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
|
/// Check if the number of images per page has changed
|
||||||
void _checkImagesPerPageChange() {
|
void _checkImagesPerPageChange() {
|
||||||
int currentImagesPerPage = imagesPerPage();
|
int currentImagesPerPage = imagesPerPage;
|
||||||
bool currentOrientation = isPortrait;
|
bool currentOrientation = isPortrait;
|
||||||
|
|
||||||
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
|
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
|
||||||
|
|||||||
@@ -599,22 +599,24 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void saveCurrentImage() async {
|
void saveCurrentImage() async {
|
||||||
var data = await selectImageToData();
|
var result = await selectImageToData();
|
||||||
if (data == null) {
|
if (result == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var (imageIndex, data) = result;
|
||||||
var fileType = detectFileType(data);
|
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);
|
saveFile(data: data, filename: filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
void share() async {
|
void share() async {
|
||||||
var data = await selectImageToData();
|
var result = await selectImageToData();
|
||||||
if (data == null) {
|
if (result == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var (imageIndex, data) = result;
|
||||||
var fileType = detectFileType(data);
|
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);
|
Share.shareFile(data: data, filename: filename, mime: fileType.mime);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,8 +721,29 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
Future<int?> selectImage() async {
|
Future<int?> selectImage() async {
|
||||||
var reader = context.reader;
|
var reader = context.reader;
|
||||||
var imageViewController = context.reader._imageViewController;
|
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 {
|
} else {
|
||||||
var location = await _showSelectImageOverlay();
|
var location = await _showSelectImageOverlay();
|
||||||
if (location == null) {
|
if (location == null) {
|
||||||
@@ -734,20 +757,23 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as [selectImage], but return the image data.
|
/// Same as [selectImage], but return the image data with its index.
|
||||||
Future<Uint8List?> selectImageToData() async {
|
/// Returns (imageIndex, imageData) or null if cancelled.
|
||||||
|
Future<(int, Uint8List)?> selectImageToData() async {
|
||||||
var i = await selectImage();
|
var i = await selectImage();
|
||||||
if (i == null) {
|
if (i == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
var imageKey = context.reader.images![i];
|
var imageKey = context.reader.images![i];
|
||||||
|
Uint8List data;
|
||||||
if (imageKey.startsWith("file://")) {
|
if (imageKey.startsWith("file://")) {
|
||||||
return await File(imageKey.substring(7)).readAsBytes();
|
data = await File(imageKey.substring(7)).readAsBytes();
|
||||||
} else {
|
} else {
|
||||||
return (await CacheManager().findCache(
|
data = await (await CacheManager().findCache(
|
||||||
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}",
|
||||||
))!.readAsBytes();
|
))!.readAsBytes();
|
||||||
}
|
}
|
||||||
|
return (i, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Offset?> _showSelectImageOverlay() {
|
Future<Offset?> _showSelectImageOverlay() {
|
||||||
|
|||||||
@@ -428,30 +428,26 @@ class _WebdavSettingState extends State<_WebdavSetting> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
RadioGroup<bool>(
|
||||||
children: [
|
groupValue: upload,
|
||||||
Text("Operation".tl),
|
onChanged: (value) {
|
||||||
Radio<bool>(
|
setState(() {
|
||||||
groupValue: upload,
|
upload = value ?? upload;
|
||||||
value: true,
|
});
|
||||||
onChanged: (value) {
|
},
|
||||||
setState(() {
|
child: Row(
|
||||||
upload = value!;
|
children: [
|
||||||
});
|
Text("Operation".tl),
|
||||||
},
|
Radio<bool>(
|
||||||
),
|
value: true,
|
||||||
Text("Upload".tl),
|
),
|
||||||
Radio<bool>(
|
Text("Upload".tl),
|
||||||
groupValue: upload,
|
Radio<bool>(
|
||||||
value: false,
|
value: false,
|
||||||
onChanged: (value) {
|
),
|
||||||
setState(() {
|
Text("Download".tl),
|
||||||
upload = value!;
|
],
|
||||||
});
|
),
|
||||||
},
|
|
||||||
),
|
|
||||||
Text("Download".tl),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
|
|||||||
return SmoothCustomScrollView(
|
return SmoothCustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppbar(title: Text("Local Favorites".tl)),
|
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(
|
SelectSetting(
|
||||||
title: "Add new favorite to".tl,
|
title: "Add new favorite to".tl,
|
||||||
settingKey: "newFavoriteAddTo",
|
settingKey: "newFavoriteAddTo",
|
||||||
|
|||||||
@@ -111,44 +111,34 @@ class _ProxySettingViewState extends State<_ProxySettingView> {
|
|||||||
return PopUpWidgetScaffold(
|
return PopUpWidgetScaffold(
|
||||||
title: "Proxy".tl,
|
title: "Proxy".tl,
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Column(
|
child: RadioGroup<String>(
|
||||||
children: [
|
groupValue: type,
|
||||||
RadioListTile<String>(
|
onChanged: (v) {
|
||||||
title: Text("Direct".tl),
|
setState(() {
|
||||||
value: 'direct',
|
type = v ?? type;
|
||||||
groupValue: type,
|
});
|
||||||
onChanged: (v) {
|
if (type != 'manual') {
|
||||||
setState(() {
|
appdata.settings['proxy'] = toProxyStr();
|
||||||
type = v!;
|
appdata.saveData();
|
||||||
});
|
}
|
||||||
appdata.settings['proxy'] = toProxyStr();
|
},
|
||||||
appdata.saveData();
|
child: Column(
|
||||||
},
|
children: [
|
||||||
),
|
RadioListTile<String>(
|
||||||
RadioListTile<String>(
|
title: Text("Direct".tl),
|
||||||
title: Text("System".tl),
|
value: 'direct',
|
||||||
value: 'system',
|
),
|
||||||
groupValue: type,
|
RadioListTile<String>(
|
||||||
onChanged: (v) {
|
title: Text("System".tl),
|
||||||
setState(() {
|
value: 'system',
|
||||||
type = v!;
|
),
|
||||||
});
|
RadioListTile(
|
||||||
appdata.settings['proxy'] = toProxyStr();
|
title: Text("Manual".tl),
|
||||||
appdata.saveData();
|
value: 'manual',
|
||||||
},
|
),
|
||||||
),
|
if (type == 'manual') buildManualProxy(),
|
||||||
RadioListTile(
|
],
|
||||||
title: Text("Manual".tl),
|
),
|
||||||
value: 'manual',
|
|
||||||
groupValue: type,
|
|
||||||
onChanged: (v) {
|
|
||||||
setState(() {
|
|
||||||
type = v!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
|
archive:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: archive
|
||||||
|
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.7"
|
||||||
args:
|
args:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -408,10 +416,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_memory_info
|
name: flutter_memory_info
|
||||||
sha256: "1f112f1d7503aa1681fc8e923f6cd0e847bb2fbeec3753ed021cf1e5f7e9cd74"
|
sha256: eacfd0dd01ff596b4e5bf022442769a1807a73f2af43d62802436f0a5de99137
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.1"
|
version: "0.0.3"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -770,6 +778,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
posix:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: posix
|
||||||
|
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.3"
|
||||||
rhttp:
|
rhttp:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1116,4 +1132,4 @@ packages:
|
|||||||
version: "0.0.12"
|
version: "0.0.12"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.8.0 <4.0.0"
|
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."
|
description: "A comic app."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.5.1+151
|
version: 1.5.3+153
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.8.0 <4.0.0'
|
sdk: '>=3.8.0 <4.0.0'
|
||||||
flutter: 3.35.3
|
flutter: 3.35.5
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
@@ -75,7 +75,7 @@ dependencies:
|
|||||||
ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
|
ref: fe182cdf40e5fa6230f451bc1d643b860f610d13
|
||||||
dynamic_color: ^1.7.0
|
dynamic_color: ^1.7.0
|
||||||
shimmer_animation: ^2.1.0
|
shimmer_animation: ^2.1.0
|
||||||
flutter_memory_info: ^0.0.1
|
flutter_memory_info: ^0.0.3
|
||||||
syntax_highlight: ^0.4.0
|
syntax_highlight: ^0.4.0
|
||||||
flutter_7zip:
|
flutter_7zip:
|
||||||
git:
|
git:
|
||||||
@@ -94,6 +94,7 @@ dev_dependencies:
|
|||||||
flutter_lints: ^5.0.0
|
flutter_lints: ^5.0.0
|
||||||
flutter_to_arch: ^1.0.1
|
flutter_to_arch: ^1.0.1
|
||||||
flutter_to_debian: ^2.0.2
|
flutter_to_debian: ^2.0.2
|
||||||
|
archive: any
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
@@ -104,6 +105,7 @@ flutter:
|
|||||||
- assets/tags.json
|
- assets/tags.json
|
||||||
- assets/tags_tw.json
|
- assets/tags_tw.json
|
||||||
- assets/opencc.txt
|
- assets/opencc.txt
|
||||||
|
# fonts:
|
||||||
|
|
||||||
flutter_to_arch:
|
flutter_to_arch:
|
||||||
name: Venera
|
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