Compare commits

...

28 Commits

Author SHA1 Message Date
nyne
5a76a10fb2 Merge pull request #537 from lings03/master
Fix some issue when save or share image in reader.
2025-10-07 15:21:50 +08:00
角砂糖
f09e766a8a Fix some issue when save or share image in reader.
1. Change the image name with comic name and real index
2. Fix wrong equal check
3. Fix wrong selection when image per page > 1 and show single image in first page
2025-10-07 01:19:59 +08:00
nyne
b7f79476c8 Merge pull request #534 from lings03/v1.5.1-dev
favorite page && cover page
2025-10-05 16:17:14 +08:00
角砂糖
44bcce4385 Add a page to view cover 2025-10-03 02:32:36 +08:00
角砂糖
6ce6066de2 Update comic details favorite page style 2025-10-03 02:32:31 +08:00
nyne
7fa48cec29 Merge pull request #515 from venera-app/v1.5.1-dev
V1.5.1
2025-09-14 18:56:12 +08:00
e549a18dbf flutter 3.35.3 2025-09-14 18:54:26 +08:00
c17c4abb5b Reduce size of scroll bar. 2025-09-14 18:43:11 +08:00
af57bc31b1 Update version code. 2025-09-14 18:33:19 +08:00
16449a1440 Change page transition animation for Android. 2025-09-14 18:30:54 +08:00
a7c1983f35 Fallback to local cover if loading fails for favorite comic. 2025-09-14 17:19:23 +08:00
4c257d7178 Show read button if loading fails. 2025-09-14 17:05:45 +08:00
3a9d634edf Update android build script. 2025-09-14 10:21:14 +08:00
nyne
e179c8f67f Change padding check condition for Android platform (#503) 2025-09-05 17:52:33 +08:00
nyne
c4b85471c1 Merge pull request #499 from KarlZeo/fix-ios-padding-check
fix padding check error on ios
2025-09-05 17:42:49 +08:00
KarlZeo
a898b57d96 fix padding check error on ios 2025-09-04 20:04:28 +08:00
50c6bec4cd Disable minify 2025-09-04 00:30:01 +08:00
nyne
8c44f83d6c Update Xcode version in GitHub Actions workflow 2025-09-03 22:50:32 +08:00
nyne
103b6b2832 Merge pull request #497 from venera-app/v1.5.0-dev
V1.5.0
2025-09-03 22:12:00 +08:00
4129349c70 Improve js api onResponse 2025-09-03 22:09:07 +08:00
77a9aa5457 Update version code. 2025-09-03 22:05:04 +08:00
97940b9492 Refactor category options. 2025-09-03 22:03:54 +08:00
7945c0e54f Improve compute api. 2025-09-03 20:31:42 +08:00
dfee65c3af Add compute api to js engine. 2025-09-02 22:15:54 +08:00
fa2dbd79f6 Fix invalid js stacktrace. 2025-09-02 20:35:47 +08:00
9a9f539906 Disable cache when updating comic source. 2025-09-02 20:16:13 +08:00
d7331f36e9 flutter 3.35.2 2025-09-01 21:13:57 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
d0b76de465 Use badge from shields.io (#455)
* Use badge from shields.io

* AUR
2025-09-01 20:55:45 +08:00
40 changed files with 1898 additions and 840 deletions

View File

@@ -15,7 +15,7 @@ jobs:
channel: "stable" channel: "stable"
flutter-version-file: pubspec.yaml flutter-version-file: pubspec.yaml
architecture: x64 architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app - run: sudo xcode-select --switch /Applications/Xcode_16.4.app
- run: flutter pub get - run: flutter pub get
# Step 1: Decode and install the certificate # Step 1: Decode and install the certificate
- name: Decode and install certificate - name: Decode and install certificate
@@ -63,7 +63,7 @@ jobs:
channel: "stable" channel: "stable"
flutter-version-file: pubspec.yaml flutter-version-file: pubspec.yaml
architecture: x64 architecture: x64
- run: sudo xcode-select --switch /Applications/Xcode_16.0.app - run: sudo xcode-select --switch /Applications/Xcode_16.4.app
- run: flutter pub get - run: flutter pub get
- run: flutter build ios --release --no-codesign - run: flutter build ios --release --no-codesign
- run: | - run: |

View File

@@ -1,15 +1,14 @@
# venera # venera
[![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/) [![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/)
[![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE) [![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![stars](https://img.shields.io/github/stars/venera-app/venera?style=flat)](https://github.com/venera-app/venera/stargazers) [![stars](https://img.shields.io/github/stars/venera-app/venera?style=flat)](https://github.com/venera-app/venera/stargazers)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/venera_release) [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=flat&logo=telegram&logoColor=white)](https://t.me/venera_release)
A comic reader that support reading local and network comics. [![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
[![AUR Version](https://img.shields.io/aur/version/venera-bin)](https://aur.archlinux.org/packages/venera-bin)
[![F-Droid Version](https://img.shields.io/f-droid/v/com.github.wgh136.venera)](https://f-droid.org/packages/com.github.wgh136.venera/)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" A comic reader that support reading local and network comics.
alt="Get it on F-Droid"
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
## Features ## Features
- Read local comics - Read local comics

View File

@@ -34,6 +34,12 @@ android {
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
ndkVersion "28.0.13004108" ndkVersion "28.0.13004108"
packaging {
jniLibs {
useLegacyPackaging true
}
}
splits{ splits{
abi { abi {
reset() reset()
@@ -78,6 +84,9 @@ android {
buildTypes { buildTypes {
release { release {
// Temporarily solution to fix crash
minifyEnabled false
shrinkResources false
ndk { ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64" abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
} }

View File

@@ -16,6 +16,7 @@
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
@@ -58,8 +59,6 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- [flutter 3.27.1] Impeller is still worse than skia, disable it -->
<meta-data android:name="io.flutter.embedding.android.EnableImpeller" android:value="false"/>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -19,7 +19,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.9.0' apply false id "com.android.application" version '8.9.0' apply false
id "org.jetbrains.kotlin.android" version "1.8.10" apply false id "org.jetbrains.kotlin.android" version "2.1.0" apply false
} }
include ":app" include ":app"

View File

@@ -4,6 +4,18 @@ Venera JavaScript Library
This library provides a set of APIs for interacting with the Venera app. This library provides a set of APIs for interacting with the Venera app.
*/ */
/**
* @function sendMessage
* @global
* @param {Object} message
* @returns {any}
*/
/**
* Set a timeout to execute a callback function after a specified delay.
* @param callback {Function}
* @param delay {number} - delay in milliseconds
*/
function setTimeout(callback, delay) { function setTimeout(callback, delay) {
sendMessage({ sendMessage({
method: 'delay', method: 'delay',
@@ -1412,3 +1424,18 @@ function getClipboard() {
method: 'getClipboard' method: 'getClipboard'
}) })
} }
/**
* Compute a function with arguments. The function will be executed in the engine pool which is not in the main thread.
* @param func {string} - A js code string which can be evaluated to a function. The function will receive the args as its only argument.
* @param args {any[]} - The arguments to pass to the function.
* @returns {Promise<any>} - The result of the function.
* @since 1.5.0
*/
function compute(func, ...args) {
return sendMessage({
method: 'compute',
function: func,
args: args
})
}

View File

@@ -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": "漫畫縮圖的顯示模式",

View File

@@ -17,6 +17,7 @@ ImageProvider? _findImageProvider(Comic comic) {
comic.cover, comic.cover,
sourceKey: comic.sourceKey, sourceKey: comic.sourceKey,
cid: comic.id, cid: comic.id,
fallbackToLocalCover: comic is FavoriteItem,
); );
} }
return image; return image;

View File

@@ -7,6 +7,7 @@ class NetworkError extends StatelessWidget {
this.retry, this.retry,
this.withAppbar = true, this.withAppbar = true,
this.buttonText, this.buttonText,
this.action,
}); });
final String message; final String message;
@@ -17,6 +18,8 @@ class NetworkError extends StatelessWidget {
final String? buttonText; final String? buttonText;
final Widget? action;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var cfe = CloudflareException.fromString(message); var cfe = CloudflareException.fromString(message);
@@ -67,12 +70,19 @@ class NetworkError extends StatelessWidget {
child: Text('Verify'.tl), child: Text('Verify'.tl),
) )
else else
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (action != null)
action!.paddingRight(8),
FilledButton( FilledButton(
onPressed: retry, onPressed: retry,
child: Text(buttonText ?? 'Retry'.tl), child: Text(buttonText ?? 'Retry'.tl),
), ),
], ],
), ),
],
),
); );
if (withAppbar) { if (withAppbar) {
body = Column( body = Column(

View File

@@ -7,8 +7,11 @@ class PaneItemEntry {
IconData activeIcon; IconData activeIcon;
PaneItemEntry( PaneItemEntry({
{required this.label, required this.icon, required this.activeIcon}); required this.label,
required this.icon,
required this.activeIcon,
});
} }
class PaneActionEntry { class PaneActionEntry {
@@ -18,20 +21,24 @@ class PaneActionEntry {
VoidCallback onTap; VoidCallback onTap;
PaneActionEntry( PaneActionEntry({
{required this.label, required this.icon, required this.onTap}); required this.label,
required this.icon,
required this.onTap,
});
} }
class NaviPane extends StatefulWidget { class NaviPane extends StatefulWidget {
const NaviPane( const NaviPane({
{required this.paneItems, required this.paneItems,
required this.paneActions, required this.paneActions,
required this.pageBuilder, required this.pageBuilder,
this.initialPage = 0, this.initialPage = 0,
this.onPageChanged, this.onPageChanged,
required this.observer, required this.observer,
required this.navigatorKey, required this.navigatorKey,
super.key}); super.key,
});
final List<PaneItemEntry> paneItems; final List<PaneItemEntry> paneItems;
@@ -187,7 +194,8 @@ class NaviPaneState extends State<NaviPane>
child: buildLeft(), child: buildLeft(),
), ),
Positioned.fill( Positioned.fill(
left: _kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) + left:
_kFoldedSideBarWidth * ((value - 1).clamp(0, 1)) +
(_kSideBarWidth - _kFoldedSideBarWidth) * (_kSideBarWidth - _kFoldedSideBarWidth) *
((value - 2).clamp(0, 1)), ((value - 2).clamp(0, 1)),
child: buildMainView(), child: buildMainView(),
@@ -202,6 +210,10 @@ class NaviPaneState extends State<NaviPane>
Widget buildMainView() { Widget buildMainView() {
return HeroControllerScope( return HeroControllerScope(
controller: MaterialApp.createMaterialHeroController(), controller: MaterialApp.createMaterialHeroController(),
child: NavigatorPopHandler(
onPopWithResult: (result) {
widget.navigatorKey.currentState?.maybePop(result);
},
child: Navigator( child: Navigator(
observers: [widget.observer], observers: [widget.observer],
key: widget.navigatorKey, key: widget.navigatorKey,
@@ -212,6 +224,7 @@ class NaviPaneState extends State<NaviPane>
}, },
), ),
), ),
),
); );
} }
@@ -239,7 +252,7 @@ class NaviPaneState extends State<NaviPane>
icon: Icon(action.icon), icon: Icon(action.icon),
onPressed: action.onTap, onPressed: action.onTap,
), ),
) ),
], ],
), ),
), ),
@@ -261,9 +274,7 @@ class NaviPaneState extends State<NaviPane>
), ),
), ),
child: Row( child: Row(
children: List<Widget>.generate( children: List<Widget>.generate(widget.paneItems.length, (index) {
widget.paneItems.length,
(index) {
return Expanded( return Expanded(
child: _SingleBottomNaviWidget( child: _SingleBottomNaviWidget(
enabled: currentPage == index, enabled: currentPage == index,
@@ -274,8 +285,7 @@ class NaviPaneState extends State<NaviPane>
key: ValueKey(index), key: ValueKey(index),
), ),
); );
}, }),
),
), ),
), ),
); );
@@ -286,7 +296,8 @@ class NaviPaneState extends State<NaviPane>
const paddingHorizontal = 12.0; const paddingHorizontal = 12.0;
return Material( return Material(
child: Container( child: Container(
width: _kFoldedSideBarWidth + width:
_kFoldedSideBarWidth +
(_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)), (_kSideBarWidth - _kFoldedSideBarWidth) * ((value - 2).clamp(0, 1)),
height: double.infinity, height: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal), padding: const EdgeInsets.symmetric(horizontal: paddingHorizontal),
@@ -323,9 +334,7 @@ class NaviPaneState extends State<NaviPane>
key: ValueKey(index + widget.paneItems.length), key: ValueKey(index + widget.paneItems.length),
), ),
), ),
const SizedBox( const SizedBox(height: 16),
height: 16,
)
], ],
), ),
), ),
@@ -334,12 +343,13 @@ class NaviPaneState extends State<NaviPane>
} }
class _SideNaviWidget extends StatelessWidget { class _SideNaviWidget extends StatelessWidget {
const _SideNaviWidget( const _SideNaviWidget({
{required this.enabled, required this.enabled,
required this.entry, required this.entry,
required this.onTap, required this.onTap,
required this.showTitle, required this.showTitle,
super.key}); super.key,
});
final bool enabled; final bool enabled;
@@ -368,18 +378,18 @@ class _SideNaviWidget extends StatelessWidget {
? Row( ? Row(
children: [icon, const SizedBox(width: 12), Text(entry.label)], children: [icon, const SizedBox(width: 12), Text(entry.label)],
) )
: Align( : Align(alignment: Alignment.centerLeft, child: icon),
alignment: Alignment.centerLeft,
child: icon,
),
), ),
).paddingVertical(4); ).paddingVertical(4);
} }
} }
class _PaneActionWidget extends StatelessWidget { class _PaneActionWidget extends StatelessWidget {
const _PaneActionWidget( const _PaneActionWidget({
{required this.entry, required this.showTitle, super.key}); required this.entry,
required this.showTitle,
super.key,
});
final PaneActionEntry entry; final PaneActionEntry entry;
@@ -399,21 +409,19 @@ class _PaneActionWidget extends StatelessWidget {
? Row( ? Row(
children: [icon, const SizedBox(width: 12), Text(entry.label)], children: [icon, const SizedBox(width: 12), Text(entry.label)],
) )
: Align( : Align(alignment: Alignment.centerLeft, child: icon),
alignment: Alignment.centerLeft,
child: icon,
),
), ),
).paddingVertical(4); ).paddingVertical(4);
} }
} }
class _SingleBottomNaviWidget extends StatefulWidget { class _SingleBottomNaviWidget extends StatefulWidget {
const _SingleBottomNaviWidget( const _SingleBottomNaviWidget({
{required this.enabled, required this.enabled,
required this.entry, required this.entry,
required this.onTap, required this.onTap,
super.key}); super.key,
});
final bool enabled; final bool enabled;
@@ -482,8 +490,9 @@ class _SingleBottomNaviWidgetState extends State<_SingleBottomNaviWidget>
Widget buildContent() { Widget buildContent() {
final value = controller.value; final value = controller.value;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final icon = final icon = Icon(
Icon(widget.enabled ? widget.entry.activeIcon : widget.entry.icon); widget.enabled ? widget.entry.activeIcon : widget.entry.icon,
);
return Center( return Center(
child: Container( child: Container(
width: 64, width: 64,
@@ -570,8 +579,11 @@ class NaviObserver extends NavigatorObserver implements Listenable {
} }
class _NaviPopScope extends StatelessWidget { class _NaviPopScope extends StatelessWidget {
const _NaviPopScope( const _NaviPopScope({
{required this.child, this.popGesture = false, required this.action}); required this.child,
this.popGesture = false,
required this.action,
});
final Widget child; final Widget child;
final bool popGesture; final bool popGesture;
@@ -581,15 +593,7 @@ class _NaviPopScope extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget res = App.isIOS Widget res = child;
? child
: PopScope(
canPop: App.isAndroid ? false : true,
onPopInvokedWithResult: (value, result) {
action();
},
child: child,
);
if (popGesture) { if (popGesture) {
res = GestureDetector( res = GestureDetector(
onPanStart: (details) { onPanStart: (details) {
@@ -606,7 +610,8 @@ class _NaviPopScope extends StatelessWidget {
} }
panStartAtEdge = false; panStartAtEdge = false;
}, },
child: res); child: res,
);
} }
return res; return res;
} }

View File

@@ -237,7 +237,7 @@ class _AppScrollBarState extends State<AppScrollBar> {
double viewHeight = 0; double viewHeight = 0;
final _scrollIndicatorSize = App.isDesktop ? 42.0 : 64.0; final _scrollIndicatorSize = App.isDesktop ? 36.0 : 54.0;
late final VerticalDragGestureRecognizer _dragGestureRecognizer; late final VerticalDragGestureRecognizer _dragGestureRecognizer;
@@ -354,7 +354,7 @@ class _ScrollIndicatorPainter extends CustomPainter {
Offset(size.width, 0), Offset(size.width, 0),
radius: Radius.circular(size.width), radius: Radius.circular(size.width),
); );
canvas.drawShadow(path, shadowColor, 4, true); canvas.drawShadow(path, shadowColor, 2, true);
var backgroundPaint = Paint() var backgroundPaint = Paint()
..color = backgroundColor ..color = backgroundColor
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.4.6"; final version = "1.5.1";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;
@@ -30,6 +30,10 @@ class _App {
bool get isMobile => Platform.isAndroid || Platform.isIOS; bool get isMobile => Platform.isAndroid || Platform.isIOS;
// Whether the app has been initialized.
// If current Isolate is main Isolate, this value is always true.
bool isInitialized = false;
Locale get locale { Locale get locale {
Locale deviceLocale = PlatformDispatcher.instance.locale; Locale deviceLocale = PlatformDispatcher.instance.locale;
if (deviceLocale.languageCode == "zh" && if (deviceLocale.languageCode == "zh" &&
@@ -81,6 +85,7 @@ class _App {
if (isAndroid) { if (isAndroid) {
externalStoragePath = (await getExternalStorageDirectory())!.path; externalStoragePath = (await getExternalStorageDirectory())!.path;
} }
isInitialized = true;
} }
Future<void> initComponents() async { Future<void> initComponents() async {

View File

@@ -2,6 +2,7 @@ import 'dart:math';
import 'dart:ui'; 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:venera/foundation/app.dart';
const double _kBackGestureWidth = 20.0; const double _kBackGestureWidth = 20.0;
const int _kMaxDroppedSwipePageForwardAnimationTime = 800; const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
@@ -115,7 +116,14 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
@override @override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) { Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return SlidePageTransitionBuilder().buildTransitions( PageTransitionsBuilder builder;
if (App.isAndroid) {
builder = PredictiveBackPageTransitionsBuilder();
} else {
builder = SlidePageTransitionBuilder();
}
return builder.buildTransitions(
this, this,
context, context,
animation, animation,

View File

@@ -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) {

View File

@@ -401,9 +401,14 @@ class SearchOptions {
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function( typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
String category, String? param, List<String> options, int page); String category, String? param, List<String> options, int page);
typedef CategoryOptionsLoader = Future<Res<List<CategoryComicsOptions>>> Function(
String category, String? param);
class CategoryComicsData { class CategoryComicsData {
/// options /// options
final List<CategoryComicsOptions> options; final List<CategoryComicsOptions>? options;
final CategoryOptionsLoader? optionsLoader;
/// [category] is the one clicked by the user on the category page. /// [category] is the one clicked by the user on the category page.
/// ///
@@ -414,7 +419,7 @@ class CategoryComicsData {
final RankingData? rankingData; final RankingData? rankingData;
const CategoryComicsData(this.options, this.load, {this.rankingData}); const CategoryComicsData({this.options, this.optionsLoader, required this.load, this.rankingData});
} }
class RankingData { class RankingData {
@@ -429,6 +434,9 @@ class RankingData {
} }
class CategoryComicsOptions { class CategoryComicsOptions {
// The label will not be displayed if it is empty.
final String label;
/// Use a [LinkedHashMap] to describe an option list. /// Use a [LinkedHashMap] to describe an option list.
/// key is for loading comics, value is the name displayed on screen. /// key is for loading comics, value is the name displayed on screen.
/// Default value will be the first of the Map. /// Default value will be the first of the Map.
@@ -439,7 +447,7 @@ class CategoryComicsOptions {
final List<String>? showWhen; final List<String>? showWhen;
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen); const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen);
} }
class LinkHandler { class LinkHandler {

View File

@@ -64,8 +64,13 @@ class ComicSourceParser {
if (file.existsSync()) { if (file.existsSync()) {
int i = 0; int i = 0;
while (file.existsSync()) { while (file.existsSync()) {
file = File(FilePath.join(App.dataPath, "comic_source", file = File(
"${fileName.split('.').first}($i).js")); FilePath.join(
App.dataPath,
"comic_source",
"${fileName.split('.').first}($i).js",
),
);
i++; i++;
} }
} }
@@ -80,8 +85,9 @@ class ComicSourceParser {
Future<ComicSource> parse(String js, String filePath) async { Future<ComicSource> parse(String js, String filePath) async {
js = js.replaceAll("\r\n", "\n"); js = js.replaceAll("\r\n", "\n");
var line1 = var line1 = js
js.split('\n').firstWhereOrNull((e) => e.trim().startsWith("class ")); .split('\n')
.firstWhereOrNull((e) => e.trim().startsWith("class "));
if (line1 == null || if (line1 == null ||
!line1.startsWith("class ") || !line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) { !line1.contains("extends ComicSource")) {
@@ -89,24 +95,27 @@ class ComicSourceParser {
} }
var className = line1.split("class")[1].split("extends ComicSource").first; var className = line1.split("class")[1].split("extends ComicSource").first;
className = className.trim(); className = className.trim();
JsEngine().runCode(""" JsEngine().runCode("""(() => { $js
(() => { $js
this['temp'] = new $className() this['temp'] = new $className()
}).call() }).call()
""", className); """, className);
_name = JsEngine().runCode("this['temp'].name") ?? _name =
JsEngine().runCode("this['temp'].name") ??
(throw ComicSourceParseException('name is required')); (throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") ?? var key =
JsEngine().runCode("this['temp'].key") ??
(throw ComicSourceParseException('key is required')); (throw ComicSourceParseException('key is required'));
var version = JsEngine().runCode("this['temp'].version") ?? var version =
JsEngine().runCode("this['temp'].version") ??
(throw ComicSourceParseException('version is required')); (throw ComicSourceParseException('version is required'));
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion"); var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
var url = JsEngine().runCode("this['temp'].url"); var url = JsEngine().runCode("this['temp'].url");
if (minAppVersion != null) { if (minAppVersion != null) {
if (compareSemVer(minAppVersion, App.version.split('-').first)) { if (compareSemVer(minAppVersion, App.version.split('-').first)) {
throw ComicSourceParseException( throw ComicSourceParseException(
"minAppVersion @version is required" "minAppVersion @version is required".tlParams({
.tlParams({"version": minAppVersion}), "version": minAppVersion,
}),
); );
} }
} }
@@ -175,8 +184,10 @@ class ComicSourceParser {
} }
bool _checkExists(String index) { bool _checkExists(String index) {
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null " return JsEngine().runCode(
"&& ComicSource.sources.$_key.$index !== undefined"); "ComicSource.sources.$_key.$index !== null "
"&& ComicSource.sources.$_key.$index !== undefined",
);
} }
dynamic _getValue(String index) { dynamic _getValue(String index) {
@@ -277,16 +288,24 @@ class ComicSourceParser {
if (type == "singlePageWithMultiPart") { if (type == "singlePageWithMultiPart") {
loadMultiPart = () async { loadMultiPart = () async {
try { try {
var res = await JsEngine() var res = await JsEngine().runCode(
.runCode("ComicSource.sources.$_key.explore[$i].load()"); "ComicSource.sources.$_key.explore[$i].load()",
return Res(List.from(res.keys );
.map((e) => ExplorePagePart( return Res(
List.from(
res.keys
.map(
(e) => ExplorePagePart(
e, e,
(res[e] as List) (res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!)) .map<Comic>((e) => Comic.fromJson(e, _key!))
.toList(), .toList(),
null)) null,
.toList())); ),
)
.toList(),
),
);
} catch (e, s) { } catch (e, s) {
Log.error("Data Analysis", "$e\n$s"); Log.error("Data Analysis", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -297,11 +316,15 @@ class ComicSourceParser {
loadPage = (int page) async { loadPage = (int page) async {
try { try {
var res = await JsEngine().runCode( var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})"); "ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})",
);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -311,10 +334,13 @@ class ComicSourceParser {
loadNext = (next) async { loadNext = (next) async {
try { try {
var res = await JsEngine().runCode( var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})"); "ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})",
);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"], subData: res["next"],
); );
} catch (e, s) { } catch (e, s) {
@@ -326,8 +352,9 @@ class ComicSourceParser {
} else if (type == "multiPartPage") { } else if (type == "multiPartPage") {
loadMultiPart = () async { loadMultiPart = () async {
try { try {
var res = await JsEngine() var res = await JsEngine().runCode(
.runCode("ComicSource.sources.$_key.explore[$i].load()"); "ComicSource.sources.$_key.explore[$i].load()",
);
return Res( return Res(
List.from( List.from(
(res as List).map((e) { (res as List).map((e) {
@@ -350,19 +377,22 @@ class ComicSourceParser {
loadMixed = (index) async { loadMixed = (index) async {
try { try {
var res = await JsEngine().runCode( var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})"); "ComicSource.sources.$_key.explore[$i].load(${jsonEncode(index)})",
);
var list = <Object>[]; var list = <Object>[];
for (var data in (res['data'] as List)) { for (var data in (res['data'] as List)) {
if (data is List) { if (data is List) {
list.add(data.map((e) => Comic.fromJson(e, _key!)).toList()); list.add(data.map((e) => Comic.fromJson(e, _key!)).toList());
} else if (data is Map) { } else if (data is Map) {
list.add(ExplorePagePart( list.add(
ExplorePagePart(
data['title'], data['title'],
(data['comics'] as List).map((e) { (data['comics'] as List).map((e) {
return Comic.fromJson(e, _key!); return Comic.fromJson(e, _key!);
}).toList(), }).toList(),
data['viewMore'], data['viewMore'],
)); ),
);
} }
} }
return Res(list, subData: res['maxPage']); return Res(list, subData: res['maxPage']);
@@ -372,21 +402,25 @@ class ComicSourceParser {
} }
}; };
} }
pages.add(ExplorePageData( pages.add(
ExplorePageData(
title, title,
switch (type) { switch (type) {
"singlePageWithMultiPart" => ExplorePageType.singlePageWithMultiPart, "singlePageWithMultiPart" =>
ExplorePageType.singlePageWithMultiPart,
"multiPartPage" => ExplorePageType.singlePageWithMultiPart, "multiPartPage" => ExplorePageType.singlePageWithMultiPart,
"multiPageComicList" => ExplorePageType.multiPageComicList, "multiPageComicList" => ExplorePageType.multiPageComicList,
"mixed" => ExplorePageType.mixed, "mixed" => ExplorePageType.mixed,
_ => _ => throw ComicSourceParseException(
throw ComicSourceParseException("Unknown explore page type $type") "Unknown explore page type $type",
),
}, },
loadPage, loadPage,
loadNext, loadNext,
loadMultiPart, loadMultiPart,
loadMixed, loadMixed,
)); ),
);
} }
return pages; return pages;
} }
@@ -426,18 +460,17 @@ class ComicSourceParser {
if (type == "fixed") { if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs!)); categoryParts.add(FixedCategoryPart(name, cs!));
} else if (type == "random") { } else if (type == "random") {
categoryParts categoryParts.add(
.add(RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1)); RandomCategoryPart(name, cs!, c["randomNumber"] ?? 1),
);
} else if (type == "dynamic" && categories == null) { } else if (type == "dynamic" && categories == null) {
var loader = c["loader"]; var loader = c["loader"];
if (loader is! JSInvokable) { if (loader is! JSInvokable) {
throw "DynamicCategoryPart loader must be a function"; throw "DynamicCategoryPart loader must be a function";
} }
categoryParts.add(DynamicCategoryPart( categoryParts.add(
name, DynamicCategoryPart(name, JSAutoFreeFunction(loader), _key!),
JSAutoFreeFunction(loader), );
_key!,
));
} }
} else { } else {
// old format // old format
@@ -454,30 +487,16 @@ class ComicSourceParser {
for (int i = 0; i < tags.length; i++) { for (int i = 0; i < tags.length; i++) {
PageJumpTarget target; PageJumpTarget target;
if (itemType == 'category') { if (itemType == 'category') {
target = PageJumpTarget( target = PageJumpTarget(_key!, 'category', {
_key!,
'category',
{
"category": tags[i], "category": tags[i],
"param": categoryParams?.elementAtOrNull(i), "param": categoryParams?.elementAtOrNull(i),
}, });
);
} else if (itemType == 'search') { } else if (itemType == 'search') {
target = PageJumpTarget( target = PageJumpTarget(_key!, 'search', {"keyword": tags[i]});
_key!,
'search',
{
"keyword": tags[i],
},
);
} else if (itemType == 'search_with_namespace') { } else if (itemType == 'search_with_namespace') {
target = PageJumpTarget( target = PageJumpTarget(_key!, 'search', {
_key!,
'search',
{
"keyword": "$name:$tags[i]", "keyword": "$name:$tags[i]",
}, });
);
} else { } else {
target = PageJumpTarget(_key!, itemType, null); target = PageJumpTarget(_key!, itemType, null);
} }
@@ -486,8 +505,9 @@ class ComicSourceParser {
if (type == "fixed") { if (type == "fixed") {
categoryParts.add(FixedCategoryPart(name, cs)); categoryParts.add(FixedCategoryPart(name, cs));
} else if (type == "random") { } else if (type == "random") {
categoryParts categoryParts.add(
.add(RandomCategoryPart(name, cs, c["randomNumber"] ?? 1)); RandomCategoryPart(name, cs, c["randomNumber"] ?? 1),
);
} }
} }
} }
@@ -496,12 +516,16 @@ class ComicSourceParser {
title: title, title: title,
categories: categoryParts, categories: categoryParts,
enableRankingPage: enableRankingPage ?? false, enableRankingPage: enableRankingPage ?? false,
key: title); key: title,
);
} }
CategoryComicsData? _loadCategoryComicsData() { CategoryComicsData? _loadCategoryComicsData() {
if (!_checkExists("categoryComics")) return null; if (!_checkExists("categoryComics")) return null;
var options = <CategoryComicsOptions>[];
List<CategoryComicsOptions>? options;
if (_checkExists("categoryComics.optionList")) {
options = <CategoryComicsOptions>[];
for (var element in _getValue("categoryComics.optionList") ?? []) { for (var element in _getValue("categoryComics.optionList") ?? []) {
LinkedHashMap<String, String> map = LinkedHashMap<String, String>(); LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"]) { for (var option in element["options"]) {
@@ -513,11 +537,64 @@ class ComicSourceParser {
var value = split.join("-"); var value = split.join("-");
map[key] = value; map[key] = value;
} }
options.add(CategoryComicsOptions( options.add(
CategoryComicsOptions(
element["label"] ?? "",
map, map,
List.from(element["notShowWhen"] ?? []), List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"]))); element["showWhen"] == null ? null : List.from(element["showWhen"]),
),
);
} }
}
CategoryOptionsLoader? optionLoader;
if (_checkExists("categoryComics.optionLoader")) {
optionLoader = (category, param) async {
try {
dynamic res = JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.optionLoader(
${jsonEncode(category)}, ${jsonEncode(param)})
""");
if (res is Future) {
res = await res;
}
if (res is! List) {
return Res.error("Invalid data:\nExpected: List\nGot: ${res.runtimeType}");
}
var options = <CategoryComicsOptions>[];
for (var element in res) {
if (element is! Map) {
return Res.error("Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}");
}
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
for (var option in element["options"] ?? []) {
if (option.isEmpty || !option.contains("-")) {
continue;
}
var split = option.split("-");
var key = split.removeAt(0);
var value = split.join("-");
map[key] = value;
}
options.add(
CategoryComicsOptions(
element["label"] ?? "",
map,
List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"]),
),
);
}
return Res(options);
}
catch(e) {
Log.error("Data Analysis", "Failed to load category options.\n$e");
return Res.error(e.toString());
}
};
}
RankingData? rankingData; RankingData? rankingData;
if (_checkExists("categoryComics.ranking")) { if (_checkExists("categoryComics.ranking")) {
var options = <String, String>{}; var options = <String, String>{};
@@ -541,9 +618,12 @@ class ComicSourceParser {
${jsonEncode(option)}, ${jsonEncode(page)}) ${jsonEncode(option)}, ${jsonEncode(page)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -557,8 +637,10 @@ class ComicSourceParser {
${jsonEncode(option)}, ${jsonEncode(next)}) ${jsonEncode(option)}, ${jsonEncode(next)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"], subData: res["next"],
); );
} catch (e, s) { } catch (e, s) {
@@ -569,7 +651,15 @@ class ComicSourceParser {
} }
rankingData = RankingData(options, load, loadWithNext); rankingData = RankingData(options, load, loadWithNext);
} }
return CategoryComicsData(options, (category, param, options, page) async {
if (options == null && optionLoader == null) {
options = [];
}
return CategoryComicsData(
options: options,
optionsLoader: optionLoader,
load: (category, param, options, page) async {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.load( ComicSource.sources.$_key.categoryComics.load(
@@ -580,14 +670,19 @@ class ComicSourceParser {
) )
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
} }
}, rankingData: rankingData); },
rankingData: rankingData,
);
} }
SearchPageData? _loadSearchData() { SearchPageData? _loadSearchData() {
@@ -604,12 +699,14 @@ class ComicSourceParser {
var value = split.join("-"); var value = split.join("-");
map[key] = value; map[key] = value;
} }
options.add(SearchOptions( options.add(
SearchOptions(
map, map,
element["label"], element["label"],
element['type'] ?? 'select', element['type'] ?? 'select',
element['default'] == null ? null : jsonEncode(element['default']), element['default'] == null ? null : jsonEncode(element['default']),
)); ),
);
} }
SearchFunction? loadPage; SearchFunction? loadPage;
@@ -624,9 +721,12 @@ class ComicSourceParser {
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)}) ${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -640,8 +740,10 @@ class ComicSourceParser {
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)}) ${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(next)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"], subData: res["next"],
); );
} catch (e, s) { } catch (e, s) {
@@ -690,8 +792,9 @@ class ComicSourceParser {
final bool multiFolder = _getValue("favorites.multiFolder"); final bool multiFolder = _getValue("favorites.multiFolder");
final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort"); final bool? isOldToNewSort = _getValue("favorites.isOldToNewSort");
final bool? singleFolderForSingleComic = final bool? singleFolderForSingleComic = _getValue(
_getValue("favorites.singleFolderForSingleComic"); "favorites.singleFolderForSingleComic",
);
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async { Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async {
if (!ComicSource.find(_key!)!.isLogged) { if (!ComicSource.find(_key!)!.isLogged) {
@@ -744,9 +847,12 @@ class ComicSourceParser {
${jsonEncode(page)}, ${jsonEncode(folder)}) ${jsonEncode(page)}, ${jsonEncode(folder)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
subData: res["maxPage"]); (index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -766,8 +872,10 @@ class ComicSourceParser {
${jsonEncode(next)}, ${jsonEncode(folder)}) ${jsonEncode(next)}, ${jsonEncode(folder)})
"""); """);
return Res( return Res(
List.generate(res["comics"].length, List.generate(
(index) => Comic.fromJson(res["comics"][index], _key!)), res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!),
),
subData: res["next"], subData: res["next"],
); );
} catch (e, s) { } catch (e, s) {
@@ -859,7 +967,8 @@ class ComicSourceParser {
"""); """);
return Res( return Res(
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(), (res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
subData: res["maxPage"]); subData: res["maxPage"],
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
@@ -1114,7 +1223,8 @@ class ComicSourceParser {
ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)}) ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)})
"""); """);
return Res( return Res(
(res as List).map((e) => ArchiveInfo.fromJson(e)).toList()); (res as List).map((e) => ArchiveInfo.fromJson(e)).toList(),
);
} catch (e, s) { } catch (e, s) {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());

View File

@@ -1,6 +1,8 @@
import 'dart:async' show Future; import 'dart:async' show Future;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'base_image_provider.dart'; import 'base_image_provider.dart';
@@ -11,7 +13,12 @@ class CachedImageProvider
/// Image provider for normal image. /// Image provider for normal image.
/// ///
/// [url] is the url of the image. Local file path is also supported. /// [url] is the url of the image. Local file path is also supported.
const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid}); const CachedImageProvider(this.url, {
this.headers,
this.sourceKey,
this.cid,
this.fallbackToLocalCover = false,
});
final String url; final String url;
@@ -21,6 +28,9 @@ class CachedImageProvider
final String? cid; final String? cid;
// Use local cover if network image fails to load.
final bool fallbackToLocalCover;
static int loadingCount = 0; static int loadingCount = 0;
static const _kMaxLoadingCount = 8; static const _kMaxLoadingCount = 8;
@@ -49,6 +59,24 @@ class CachedImageProvider
} }
throw "Error: Empty response body."; throw "Error: Empty response body.";
} }
catch(e) {
if (fallbackToLocalCover && sourceKey != null && cid != null) {
final localComic = LocalManager().find(
cid!,
ComicType.fromKey(sourceKey!),
);
if (localComic != null) {
var file = localComic.coverFile;
if (await file.exists()) {
var data = await file.readAsBytes();
if (data.isNotEmpty) {
return data;
}
}
}
}
rethrow;
}
finally { finally {
loadingCount--; loadingCount--;
} }

View File

@@ -24,6 +24,7 @@ import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:venera/components/js_ui.dart'; import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/js_pool.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/network/proxy.dart'; import 'package:venera/network/proxy.dart';
@@ -68,6 +69,12 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
responseType: ResponseType.plain, validateStatus: (status) => true)); responseType: ResponseType.plain, validateStatus: (status) => true));
} }
static Uint8List? _jsInitCache;
static void cacheJsInit(Uint8List jsInit) {
_jsInitCache = jsInit;
}
@override @override
@protected @protected
Future<void> doInit() async { Future<void> doInit() async {
@@ -75,9 +82,11 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
return; return;
} }
try { try {
if (App.isInitialized) {
_cookieJar ??= await SingleInstanceCookieJar.createInstance();
}
_dio ??= AppDio(BaseOptions( _dio ??= AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true)); responseType: ResponseType.plain, validateStatus: (status) => true));
_cookieJar ??= SingleInstanceCookieJar.instance!;
_closed = false; _closed = false;
_engine = FlutterQjs(); _engine = FlutterQjs();
_engine!.dispatch(); _engine!.dispatch();
@@ -86,9 +95,15 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]); (setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
setGlobalFunc(["appVersion", App.version]); setGlobalFunc(["appVersion", App.version]);
setGlobalFunc.free(); setGlobalFunc.free();
var jsInit = await rootBundle.load("assets/init.js"); Uint8List jsInit;
if (_jsInitCache != null) {
jsInit = _jsInitCache!;
} else {
var buffer = await rootBundle.load("assets/init.js");
jsInit = buffer.buffer.asUint8List();
}
_engine! _engine!
.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>"); .evaluate(utf8.decode(jsInit), name: "<init>");
} catch (e, s) { } catch (e, s) {
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s'); Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
} }
@@ -97,6 +112,7 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
Object? _messageReceiver(dynamic message) { Object? _messageReceiver(dynamic message) {
try { try {
if (message is Map<dynamic, dynamic>) { if (message is Map<dynamic, dynamic>) {
if (message["method"] == null) return null;
String method = message["method"] as String; String method = message["method"] as String;
switch (method) { switch (method) {
case "log": case "log":
@@ -172,6 +188,20 @@ class JsEngine with _JSEngineApi, JsUiApi, Init {
var res = await Clipboard.getData(Clipboard.kTextPlain); var res = await Clipboard.getData(Clipboard.kTextPlain);
return res?.text; return res?.text;
}); });
case "compute":
final func = message["function"];
final args = message["args"];
if (func is JSInvokable) {
func.free();
throw "Function must be a string";
}
if (func is! String) {
throw "Function must be a string";
}
if (args != null && args is! List) {
throw "Args must be a list";
}
return JSPool().execute(func, args ?? []);
} }
} }
return null; return null;

163
lib/foundation/js_pool.dart Normal file
View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:flutter_qjs/flutter_qjs.dart';
import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/log.dart';
class JSPool {
static final int _maxInstances = 4;
final List<IsolateJsEngine> _instances = [];
bool _isInitializing = false;
static final JSPool _singleton = JSPool._internal();
factory JSPool() {
return _singleton;
}
JSPool._internal();
Future<void> init() async {
if (_isInitializing) return;
_isInitializing = true;
var jsInitBuffer = await rootBundle.load("assets/init.js");
var jsInit = jsInitBuffer.buffer.asUint8List();
for (int i = 0; i < _maxInstances; i++) {
_instances.add(IsolateJsEngine(jsInit));
}
_isInitializing = false;
}
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
await init();
var selectedInstance = _instances[0];
for (var instance in _instances) {
if (instance.pendingTasks < selectedInstance.pendingTasks) {
selectedInstance = instance;
}
}
return selectedInstance.execute(jsFunction, args);
}
}
class _IsolateJsEngineInitParam {
final SendPort sendPort;
final Uint8List jsInit;
_IsolateJsEngineInitParam(this.sendPort, this.jsInit);
}
class IsolateJsEngine {
Isolate? _isolate;
SendPort? _sendPort;
ReceivePort? _receivePort;
int _counter = 0;
final Map<int, Completer<dynamic>> _tasks = {};
bool _isClosed = false;
int get pendingTasks => _tasks.length;
IsolateJsEngine(Uint8List jsInit) {
_receivePort = ReceivePort();
_receivePort!.listen(_onMessage);
Isolate.spawn(_run, _IsolateJsEngineInitParam(_receivePort!.sendPort, jsInit));
}
void _onMessage(dynamic message) {
if (message is SendPort) {
_sendPort = message;
} else if (message is TaskResult) {
final completer = _tasks.remove(message.id);
if (completer != null) {
if (message.error != null) {
completer.completeError(message.error!);
} else {
completer.complete(message.result);
}
}
} else if (message is Exception) {
Log.error("IsolateJsEngine", message.toString());
for (var completer in _tasks.values) {
completer.completeError(message);
}
_tasks.clear();
close();
}
}
static void _run(_IsolateJsEngineInitParam params) async {
var sendPort = params.sendPort;
final port = ReceivePort();
sendPort.send(port.sendPort);
final engine = JsEngine();
try {
JsEngine.cacheJsInit(params.jsInit);
await engine.init();
}
catch(e, s) {
sendPort.send(Exception("Failed to initialize JS engine: $e\n$s"));
return;
}
await for (final message in port) {
if (message is Task) {
try {
final jsFunc = engine.runCode(message.jsFunction);
if (jsFunc is! JSInvokable) {
throw Exception("The provided code does not evaluate to a function.");
}
final result = jsFunc.invoke(message.args);
jsFunc.free();
sendPort.send(TaskResult(message.id, result, null));
} catch (e) {
sendPort.send(TaskResult(message.id, null, e.toString()));
}
}
}
}
Future<dynamic> execute(String jsFunction, List<dynamic> args) async {
if (_isClosed) {
throw Exception("IsolateJsEngine is closed.");
}
while (_sendPort == null) {
await Future.delayed(const Duration(milliseconds: 10));
}
final completer = Completer<dynamic>();
final taskId = _counter++;
_tasks[taskId] = completer;
final task = Task(taskId, jsFunction, args);
_sendPort?.send(task);
return completer.future;
}
void close() async {
if (!_isClosed) {
_isClosed = true;
while (_tasks.isNotEmpty) {
await Future.delayed(const Duration(milliseconds: 100));
}
_receivePort?.close();
_isolate?.kill(priority: Isolate.immediate);
_isolate = null;
}
}
}
class Task {
final int id;
final String jsFunction;
final List<dynamic> args;
const Task(this.id, this.jsFunction, this.args);
}
class TaskResult {
final int id;
final Object? result;
final String? error;
const TaskResult(this.id, this.result, this.error);
}

View File

@@ -42,7 +42,7 @@ class Log {
static void addLog(LogLevel level, String title, String content) { static void addLog(LogLevel level, String title, String content) {
if (isMuted) return; if (isMuted) return;
if (_file == null) { if (_file == null && App.isInitialized) {
Directory dir; Directory dir;
if (App.isAndroid) { if (App.isAndroid) {
dir = Directory(App.externalStoragePath!); dir = Directory(App.externalStoragePath!);

View File

@@ -248,7 +248,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
MediaQuery.of(context).viewPadding.top <= 0 || MediaQuery.of(context).viewPadding.top <= 0 ||
MediaQuery.of(context).viewPadding.top > 50; MediaQuery.of(context).viewPadding.top > 50;
if (isPaddingCheckError) { if (isPaddingCheckError && Platform.isAndroid) {
widget = MediaQuery( widget = MediaQuery(
data: MediaQuery.of(context).copyWith( data: MediaQuery.of(context).copyWith(
viewPadding: const EdgeInsets.only( viewPadding: const EdgeInsets.only(

View File

@@ -112,11 +112,13 @@ class AppDio with DioMixin {
AppDio([BaseOptions? options]) { AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
httpClientAdapter = RHttpAdapter(); httpClientAdapter = RHttpAdapter();
if (App.isInitialized) {
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager()); interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor()); interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor()); interceptors.add(MyLogInterceptor());
} }
}
static final Map<String, bool> _requests = {}; static final Map<String, bool> _requests = {};

View File

@@ -202,9 +202,13 @@ class SingleInstanceCookieJar extends CookieJarSql {
static SingleInstanceCookieJar? instance; static SingleInstanceCookieJar? instance;
static Future<void> createInstance() async { static Future<SingleInstanceCookieJar> createInstance() async {
if (instance != null) {
return instance!;
}
var dataPath = (await getApplicationSupportDirectory()).path; var dataPath = (await getApplicationSupportDirectory()).path;
instance = SingleInstanceCookieJar("$dataPath/cookie.db"); instance = SingleInstanceCookieJar("$dataPath/cookie.db");
return instance!;
} }
} }

View File

@@ -181,12 +181,17 @@ abstract class ImageDownloader {
} }
if (configs['onResponse'] is JSInvokable) { if (configs['onResponse'] is JSInvokable) {
buffer = (configs['onResponse'] as JSInvokable)([buffer]); buffer = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
(configs['onResponse'] as JSInvokable).free(); (configs['onResponse'] as JSInvokable).free();
} }
var data = Uint8List.fromList(buffer); Uint8List data;
if (buffer is Uint8List) {
data = buffer;
} else {
data = Uint8List.fromList(buffer);
buffer.clear(); buffer.clear();
}
if (configs['modifyImage'] != null) { if (configs['modifyImage'] != null) {
var newData = await modifyImageWithScript( var newData = await modifyImageWithScript(

View File

@@ -27,9 +27,11 @@ class CategoryComicsPage extends StatefulWidget {
class _CategoryComicsPageState extends State<CategoryComicsPage> { class _CategoryComicsPageState extends State<CategoryComicsPage> {
late final CategoryComicsData data; late final CategoryComicsData data;
late final List<CategoryComicsOptions> options; late List<CategoryComicsOptions>? options;
late final CategoryOptionsLoader? optionsLoader;
late List<String> optionsValue; late List<String> optionsValue;
late String sourceKey; late String sourceKey;
String? error;
void findData() { void findData() {
for (final source in ComicSource.all()) { for (final source in ComicSource.all()) {
@@ -38,7 +40,8 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
throw "The comic source ${source.name} does not support category comics"; throw "The comic source ${source.name} does not support category comics";
} }
data = source.categoryComicsData!; data = source.categoryComicsData!;
options = data.options.where((element) { if (data.options != null) {
options = data.options!.where((element) {
if (element.notShowWhen.contains(widget.category)) { if (element.notShowWhen.contains(widget.category)) {
return false; return false;
} else if (element.showWhen != null) { } else if (element.showWhen != null) {
@@ -46,16 +49,14 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
} }
return true; return true;
}).toList(); }).toList();
var defaultOptionsValue = } else {
options.map((e) => e.options.keys.first).toList(); options = null;
if (optionsValue.length != options.length) {
var newOptionsValue = List<String>.filled(options.length, "");
for (var i = 0; i < options.length; i++) {
newOptionsValue[i] =
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
} }
optionsValue = newOptionsValue; if (data.optionsLoader != null) {
optionsLoader = data.optionsLoader;
loadOptions();
} }
resetOptionsValue();
sourceKey = source.key; sourceKey = source.key;
return; return;
} }
@@ -63,6 +64,36 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
throw "${widget.categoryKey} Not found"; throw "${widget.categoryKey} Not found";
} }
void resetOptionsValue() {
if (options == null) return;
var defaultOptionsValue = options!
.map((e) => e.options.keys.first)
.toList();
if (optionsValue.length != options!.length) {
var newOptionsValue = List<String>.filled(options!.length, "");
for (var i = 0; i < options!.length; i++) {
newOptionsValue[i] =
optionsValue.elementAtOrNull(i) ?? defaultOptionsValue[i];
}
optionsValue = newOptionsValue;
}
}
void loadOptions() async {
final res = await optionsLoader!(widget.category, widget.param);
if (res.error) {
setState(() {
error = res.errorMessage;
});
} else {
setState(() {
options = res.data;
resetOptionsValue();
error = null;
});
}
}
@override @override
void initState() { void initState() {
if (widget.options != null) { if (widget.options != null) {
@@ -77,27 +108,44 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var topPadding = context.padding.top + 56.0; var topPadding = context.padding.top + 56.0;
Widget body;
if (options == null) {
body = Center(child: CircularProgressIndicator());
} else if (error != null) {
body = NetworkError(
message: error!,
retry: () {
setState(() {
error = null;
});
loadOptions();
},
);
} else {
body = ComicList(
key: Key(widget.category + optionsValue.toString()),
errorLeading: buildOptions().paddingTop(topPadding),
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
loadPage: (i) =>
data.load(widget.category, widget.param, optionsValue, i),
);
}
return Scaffold( return Scaffold(
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
appBar: Appbar( appBar: Appbar(title: Text(widget.category)),
title: Text(widget.category), body: body,
),
body: ComicList(
key: Key(widget.category + optionsValue.toString()),
errorLeading: SizedBox(height: topPadding),
leadingSliver: buildOptions().paddingTop(topPadding).toSliver(),
loadPage: (i) => data.load(
widget.category,
widget.param,
optionsValue,
i,
),
),
); );
} }
Widget buildOptionItem( Widget buildOptionItem(
String text, String value, int group, BuildContext context) { String text,
String value,
int group,
BuildContext context,
) {
return OptionChip( return OptionChip(
text: text.ts(sourceKey), text: text.ts(sourceKey),
isSelected: value == optionsValue[group], isSelected: value == optionsValue[group],
@@ -112,8 +160,26 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
Widget buildOptions() { Widget buildOptions() {
List<Widget> children = []; List<Widget> children = [];
for (var optionList in options) { var group = 0;
children.add(Wrap( for (var optionList in options!) {
if (optionList.label.isNotEmpty) {
children.add(Padding(
padding: const EdgeInsets.only(
bottom: 8.0,
left: 4.0,
),
child: Text(
optionList.label.ts(sourceKey),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
));
}
if (optionList.options.length <= 8) {
children.add(
Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
@@ -121,14 +187,30 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
buildOptionItem( buildOptionItem(
option.value.tl, option.value.tl,
option.key, option.key,
options.indexOf(optionList), group,
context, context,
) ),
], ],
),
);
} else {
var g = group;
children.add(Select(
current: optionList.options[optionsValue[g]],
values: optionList.options.values.toList(),
onTap: (i) {
var key = optionList.options.keys.elementAt(i);
if (key == optionsValue[g]) return;
setState(() {
optionsValue[g] = key;
});
},
)); ));
if (options.last != optionList) { }
if (options!.last != optionList) {
children.add(const SizedBox(height: 8)); children.add(const SizedBox(height: 8));
} }
group++;
} }
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@@ -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(),

View File

@@ -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,
@@ -77,8 +84,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
@override @override
void onReadEnd() { void onReadEnd() {
history ??= history ??= HistoryManager().find(
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode)); widget.id,
ComicType(widget.sourceKey.hashCode),
);
update(); update();
} }
@@ -93,6 +102,32 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
); );
} }
@override
Widget buildError() {
final isDownloaded = LocalManager().isDownloaded(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
Widget? action;
if (isDownloaded) {
action = FilledButton.tonal(
child: Text("Read".tl),
onPressed: () {
final localComic = LocalManager().find(
widget.id,
ComicType.fromKey(widget.sourceKey),
);
if (localComic == null) {
context.showMessage(message: "Local comic not found".tl);
return;
}
localComic.read();
},
);
}
return NetworkError(message: error!, retry: retry, action: action);
}
@override @override
void initState() { void initState() {
scrollController.addListener(onScroll); scrollController.addListener(onScroll);
@@ -114,7 +149,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ComicDetails get comic => data!; ComicDetails get comic => data!;
void onScroll() { void onScroll() {
var offset = scrollController.position.pixels - var offset =
scrollController.position.pixels -
scrollController.position.minScrollExtent; scrollController.position.minScrollExtent;
var showFAB = offset > 0; var showFAB = offset > 0;
if (showFAB != this.showFAB) { if (showFAB != this.showFAB) {
@@ -145,9 +181,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
floatingActionButton: showFAB floatingActionButton: showFAB
? FloatingActionButton( ? FloatingActionButton(
onPressed: () { onPressed: () {
scrollController.animateTo(0, scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.ease); curve: Curves.ease,
);
}, },
child: const Icon(Icons.arrow_upward), child: const Icon(Icons.arrow_upward),
) )
@@ -164,7 +202,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
buildThumbnails(), buildThumbnails(),
buildRecommend(), buildRecommend(),
SliverPadding( SliverPadding(
padding: EdgeInsets.only(bottom: context.padding.bottom + 80), // Add additional padding for FAB padding: EdgeInsets.only(
bottom: context.padding.bottom + 80,
), // Add additional padding for FAB
), ),
], ],
), ),
@@ -190,12 +230,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
initialPage: history?.page, initialPage: history?.page,
initialChapter: history?.ep, initialChapter: history?.ep,
initialChapterGroup: history?.group, initialChapterGroup: history?.group,
history: history ?? history:
History.fromModel( history ??
model: localComic, History.fromModel(model: localComic, ep: 0, page: 0),
ep: 0,
page: 0,
),
author: localComic.subTitle ?? '', author: localComic.subTitle ?? '',
tags: localComic.tags, tags: localComic.tags,
); );
@@ -215,8 +252,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
widget.id, widget.id,
ComicType(widget.sourceKey.hashCode), ComicType(widget.sourceKey.hashCode),
); );
history = history = HistoryManager().find(
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode)); widget.id,
ComicType(widget.sourceKey.hashCode),
);
return comicSource.loadComicInfo!(widget.id); return comicSource.loadComicInfo!(widget.id);
} }
@@ -224,12 +263,20 @@ 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( isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
comic.id,
comic.comicType,
0,
);
} }
} }
@@ -242,7 +289,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
), ),
actions: [ actions: [
IconButton( IconButton(
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz)) onPressed: showMoreActions,
icon: const Icon(Icons.more_horiz),
),
], ],
); );
@@ -253,7 +302,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 16), const SizedBox(width: 16),
Hero( GestureDetector(
onTap: () => _viewCover(context),
onLongPress: () => _saveCover(context),
child: Hero(
tag: "cover${widget.heroID}", tag: "cover${widget.heroID}",
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -281,6 +333,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
), ),
), ),
), ),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
@@ -288,8 +341,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
children: [ children: [
SelectableText(comic.title, style: ts.s18), SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null) if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14) SelectableText(
.paddingVertical(4), comic.subTitle!,
style: ts.s14,
).paddingVertical(4),
Text( Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '', (ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12, style: ts.s12,
@@ -338,7 +393,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
icon: const Icon(Icons.favorite_border), icon: const Icon(Icons.favorite_border),
activeIcon: const Icon(Icons.favorite), activeIcon: const Icon(Icons.favorite),
isActive: isLiked, isActive: isLiked,
text: ((data!.likesCount != null) text:
((data!.likesCount != null)
? (data!.likesCount! + (isLiked ? 1 : 0)) ? (data!.likesCount! + (isLiked ? 1 : 0))
: (isLiked ? 'Liked'.tl : 'Like'.tl)) : (isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(), .toString(),
@@ -383,9 +439,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
Expanded( Expanded(
child: hasHistory child: hasHistory
? FilledButton( ? FilledButton(
onPressed: continueRead, child: Text("Continue".tl)) onPressed: continueRead,
: FilledButton(onPressed: read, child: Text("Read".tl)), child: Text("Continue".tl),
) )
: FilledButton(onPressed: read, child: Text("Read".tl)),
),
], ],
).paddingHorizontal(16).paddingVertical(8), ).paddingHorizontal(16).paddingVertical(8),
if (history != null) if (history != null)
@@ -417,14 +475,15 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
math.min(ep - 1, comic.chapters!.length - 1), math.min(ep - 1, comic.chapters!.length - 1),
); );
} else { } else {
groupName = comic.chapters!.groups.elementAt(group - 1); groupName = comic.chapters!.groups.elementAt(
group - 1,
);
epName = comic.chapters! epName = comic.chapters!
.getGroupByIndex(group - 1) .getGroupByIndex(group - 1)
.values .values
.elementAt(ep - 1); .elementAt(ep - 1);
} }
} } catch (e) {
catch(e) {
// ignore // ignore
} }
text = groupName == null text = groupName == null
@@ -453,9 +512,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
return SliverLazyToBoxAdapter( return SliverLazyToBoxAdapter(
child: Column( child: Column(
children: [ children: [
ListTile( ListTile(title: Text("Description".tl)),
title: Text("Description".tl),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!).fixWidth(double.infinity), child: SelectableText(comic.description!).fixWidth(double.infinity),
@@ -539,10 +596,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
); );
} else { } else {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(color: color, borderRadius: borderRadius),
color: color,
borderRadius: borderRadius,
),
child: Text(text).padding(padding), child: Text(text).padding(padding),
); );
} }
@@ -552,13 +606,13 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (int.tryParse(time) != null) { if (int.tryParse(time) != null) {
var t = int.tryParse(time); var t = int.tryParse(time);
if (t! > 1000000000000) { if (t! > 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(t) return DateTime.fromMillisecondsSinceEpoch(
.toString() t,
.substring(0, 19); ).toString().substring(0, 19);
} else { } else {
return DateTime.fromMillisecondsSinceEpoch(t * 1000) return DateTime.fromMillisecondsSinceEpoch(
.toString() t * 1000,
.substring(0, 19); ).toString().substring(0, 19);
} }
} }
if (time.contains('T') || time.contains('Z')) { if (time.contains('T') || time.contains('Z')) {
@@ -583,17 +637,11 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ListTile( ListTile(title: Text("Information".tl)),
title: Text("Information".tl),
),
if (comic.stars != null) if (comic.stars != null)
Row( Row(
children: [ children: [
StarRating( StarRating(value: comic.stars!, size: 24, onTap: starRating),
value: comic.stars!,
size: 24,
onTap: starRating,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(comic.stars!.toStringAsFixed(2)), Text(comic.stars!.toStringAsFixed(2)),
], ],
@@ -671,24 +719,67 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.recommend == null || comic.recommend!.isEmpty) { if (comic.recommend == null || comic.recommend!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
return SliverMainAxisGroup(slivers: [ return SliverMainAxisGroup(
SliverToBoxAdapter( slivers: [
child: ListTile( SliverToBoxAdapter(child: ListTile(title: Text("Related".tl))),
title: Text("Related".tl),
),
),
SliverGridComics(comics: comic.recommend!), SliverGridComics(comics: comic.recommend!),
]); ],
);
} }
Widget buildComments() { Widget buildComments() {
if (comic.comments == null || comic.comments!.isEmpty) { if (comic.comments == null || comic.comments!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
return _CommentsPart( return _CommentsPart(comments: comic.comments!, showMore: showComments);
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);
}
}
} }
} }
@@ -793,8 +884,8 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
itemBuilder: (context, i) { itemBuilder: (context, i) {
return CheckboxListTile( return CheckboxListTile(
title: Text(widget.eps[i]), title: Text(widget.eps[i]),
value: selected.contains(i) || value:
widget.downloadedEps.contains(i), selected.contains(i) || widget.downloadedEps.contains(i),
onChanged: widget.downloadedEps.contains(i) onChanged: widget.downloadedEps.contains(i)
? null ? null
: (v) { : (v) {
@@ -805,7 +896,8 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
selected.add(i); selected.add(i);
} }
}); });
}); },
);
}, },
), ),
), ),
@@ -813,9 +905,7 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
height: 50, height: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: BorderSide( top: BorderSide(color: context.colorScheme.outlineVariant),
color: context.colorScheme.outlineVariant,
),
), ),
), ),
child: Row( child: Row(
@@ -880,8 +970,12 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget buildContainer(double? width, double? height, Widget buildContainer(
{Color? color, double? radius}) { double? width,
double? height, {
Color? color,
double? radius,
}) {
return Container( return Container(
height: height, height: height,
width: width, width: width,
@@ -923,13 +1017,9 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
if (context.width < changePoint) if (context.width < changePoint)
Row( Row(
children: [ children: [
Expanded( Expanded(child: buildContainer(null, 36, radius: 18)),
child: buildContainer(null, 36, radius: 18),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(child: buildContainer(null, 36, radius: 18)),
child: buildContainer(null, 36, radius: 18),
),
], ],
).paddingHorizontal(16), ).paddingHorizontal(16),
const Divider(), const Divider(),
@@ -938,7 +1028,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2.4, strokeWidth: 2.4,
).fixHeight(24).fixWidth(24), ).fixHeight(24).fixWidth(24),
) ),
], ],
), ),
); );
@@ -948,11 +1038,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
Widget child; Widget child;
if (cover != null) { if (cover != null) {
child = AnimatedImage( child = AnimatedImage(
image: CachedImageProvider( image: CachedImageProvider(cover!, sourceKey: sourceKey, cid: cid),
cover!,
sourceKey: sourceKey,
cid: cid,
),
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
fit: BoxFit.cover, fit: BoxFit.cover,

View 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);
}
}
}
}

View File

@@ -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(
),
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, cid: widget.cid,
type: widget.type,
isFavorite: widget.isFavorite,
onFavorite: widget.onFavorite,
favoriteItem: widget.favoriteItem,
updateTime: widget.updateTime,
comicSource: comicSource, comicSource: comicSource,
hasNetwork: hasNetwork,
localFolders: localFolders,
added: added,
),
);
}
}
class _FavoriteList extends StatefulWidget {
const _FavoriteList({
required this.cid,
required this.type,
required this.isFavorite,
required this.onFavorite,
required this.favoriteItem,
this.updateTime,
required this.comicSource,
required this.hasNetwork,
required this.localFolders,
required this.added,
});
final String cid;
final ComicType type;
final bool? isFavorite;
final void Function(bool?, bool?) onFavorite;
final FavoriteItem favoriteItem;
final String? updateTime;
final ComicSource comicSource;
final bool hasNetwork;
final List<String> localFolders;
final List<String> added;
@override
State<_FavoriteList> createState() => _FavoriteListState();
}
class _FavoriteListState extends State<_FavoriteList> {
@override
Widget build(BuildContext context) {
final localFavoritesFirst = appdata.settings['localFavoritesFirst'] ?? true;
final localSection = _LocalSection(
cid: widget.cid,
type: widget.type,
favoriteItem: widget.favoriteItem,
updateTime: widget.updateTime,
localFolders: widget.localFolders,
added: widget.added,
onFavorite: (local) {
widget.onFavorite(local, null);
},
);
final networkSection = widget.hasNetwork
? _NetworkSection(
cid: widget.cid,
comicSource: widget.comicSource,
isFavorite: widget.isFavorite, isFavorite: widget.isFavorite,
onFavorite: (network) { onFavorite: (network) {
widget.onFavorite(null, 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 _NetworkFavorites extends StatefulWidget { class _NetworkSection extends StatefulWidget {
const _NetworkFavorites({ 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> {
@override
Widget build(BuildContext context) {
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
} }
class _NetworkSectionState extends State<_NetworkSection> {
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,61 +212,91 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
} }
} }
Widget buildMultiFolder() { Widget _buildLoadingSkeleton() {
if (widget.isFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Padding(
child: Center( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text("Added to favorites".tl), child: Text(
"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!); ),
} ),
}, trailing: Container(
child: Text("Remove".tl), height: 28,
).paddingVertical(8), width: 60 + (index * 2),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
),
);
}),
),
), ),
], ],
); );
} }
@override
Widget build(BuildContext context) {
if (isLoadingFolders) { if (isLoadingFolders) {
loadFolders(); return _buildLoadingSkeleton();
return const Center(child: CircularProgressIndicator()); }
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
if (isMultiFolder) {
return _buildMultiFolder();
} else { } else {
return _buildSingleFolder();
}
}
Widget _buildSingleFolder() {
var isFavorite = localIsFavorite ?? false;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Padding(
child: ListView.builder( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
itemCount: folders!.length, child: Text(
itemBuilder: (context, index) { "Network Favorites".tl,
var name = folders!.values.elementAt(index); style: ts.s14.copyWith(
var id = folders!.keys.elementAt(index); fontWeight: FontWeight.w600,
return CheckboxListTile( color: context.colorScheme.primary,
),
),
),
ListTile(
title: Row( title: Row(
children: [ children: [
Text(name), Text("Network Favorites".tl),
const SizedBox(width: 8), const SizedBox(width: 8),
if (addedFolders.contains(id)) if (isFavorite)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
@@ -383,50 +310,372 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
), ),
], ],
), ),
value: selected == id, trailing: isLoading
onChanged: (v) { ? const SizedBox(
setState(() { width: 20,
selected = id; height: 20,
}); child: CircularProgressIndicator(strokeWidth: 2),
}, )
); : _HoverButton(
}, isFavorite: isFavorite,
), onTap: () async {
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
if (selected == null) {
return;
}
setState(() { setState(() {
isLoading = true; isLoading = true;
}); });
var res =
await widget.comicSource.favoriteData!.addOrDelFavorite!( var res = await widget
widget.cid, .comicSource
selected!, .favoriteData!
!addedFolders.contains(selected!), .addOrDelFavorite!(widget.cid, '', !isFavorite, null);
null,
);
if (res.success) { if (res.success) {
context.showMessage(message: "Success".tl); setState(() {
localIsFavorite = !isFavorite;
});
widget.onFavorite(!isFavorite);
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl,
);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop(); context.pop();
}
} else { } else {
context.showMessage(message: res.errorMessage!); context.showMessage(message: res.errorMessage!);
}
setState(() { setState(() {
isLoading = false; 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();
} }
}, },
child: selected != null && addedFolders.contains(selected!) ),
? Text("Remove".tl) );
: Text("Add".tl), }),
).paddingVertical(8), // 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,
),
),
),
),
);
}
} }

View File

@@ -43,7 +43,10 @@ class ComicSourcePage extends StatelessWidget {
try { try {
var res = await AppDio().get<String>( var res = await AppDio().get<String>(
source.url, source.url,
options: Options(responseType: ResponseType.plain), options: Options(
responseType: ResponseType.plain,
headers: {"cache-time": "no"},
),
); );
if (cancel) return; if (cancel) return;
controller?.close(); controller?.close();
@@ -200,10 +203,7 @@ class _BodyState extends State<_Body> {
); );
} }
void update( void update(ComicSource source, [bool showLoading = true]) {
ComicSource source, [
bool showLoading = true,
]) {
ComicSourcePage.update(source, showLoading); ComicSourcePage.update(source, showLoading);
} }
@@ -304,7 +304,10 @@ class _BodyState extends State<_Body> {
try { try {
var res = await AppDio().get<String>( var res = await AppDio().get<String>(
url, url,
options: Options(responseType: ResponseType.plain), options: Options(
responseType: ResponseType.plain,
headers: {"cache-time": "no"},
),
); );
if (cancel) return; if (cancel) return;
controller.close(); controller.close();
@@ -710,11 +713,13 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FilledButton.tonalIcon( return FilledButton.tonalIcon(
icon: isLoading ? SizedBox( icon: isLoading
? SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) : Icon(Icons.update), )
: Icon(Icons.update),
label: Text("Check updates".tl), label: Text("Check updates".tl),
onPressed: check, onPressed: check,
); );

View File

@@ -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';

View File

@@ -512,6 +512,18 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
); );
}, },
), ),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.chrome_reader_mode_outlined,
text: "Read".tl,
onClick: () {
final c = selectedComics.keys.first as FavoriteItem;
App.rootContext.to(() => ReaderWithLoading(
id: c.id,
sourceKey: c.sourceKey,
));
},
),
]), ]),
], ],
) )

View File

@@ -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();
}, },
), ),

View File

@@ -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,19 +543,29 @@ 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;
int actualImageCount = endIndex - startIndex;
if (actualImageCount == 1) {
return reader.images![startIndex];
}
for (var imageState in imageStates) { for (var imageState in imageStates) {
if ((imageState as _ComicImageState).containsPoint(offset)) { if ((imageState as _ComicImageState).containsPoint(offset)) {
imageKey = (imageState.widget.image as ReaderImageProvider).imageKey; 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];
}
}
const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{ const Set<PointerDeviceKind> _kTouchLikeDeviceTypes = <PointerDeviceKind>{
PointerDeviceKind.touch, PointerDeviceKind.touch,

View File

@@ -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) {

View File

@@ -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() {

View File

@@ -62,7 +62,7 @@ class DebugPageState extends State<DebugPage> {
TextButton( TextButton(
onPressed: () { onPressed: () {
try { try {
var res = JsEngine().runCode(controller.text); var res = JsEngine().runCode(controller.text, "<debug>");
setState(() { setState(() {
result = res.toString(); result = res.toString();
}); });

View File

@@ -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",

View File

@@ -556,26 +556,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.9" version: "11.0.1"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.9" version: "3.0.10"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_testing name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.1" version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -941,10 +941,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.4" version: "0.7.6"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1037,10 +1037,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -1116,4 +1116,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.32.6" flutter: ">=3.35.2"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.4.6+146 version: 1.5.1+151
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
flutter: 3.32.6 flutter: 3.35.3
dependencies: dependencies:
flutter: flutter: