Compare commits

...

22 Commits

Author SHA1 Message Date
e1fbdfbd50 Update version code 2025-10-07 16:10:14 +08:00
0a5b70b161 Add font patching script for linux arm64. Close #468 2025-10-07 16:09:30 +08:00
9173665afe Fix invalid total comics count. Close #524 2025-10-07 09:47:25 +08:00
e0ea449c17 Improve updating following and fix potential crash. 2025-10-06 10:17:01 +08:00
c438a84537 flutter 3.35.5 2025-10-05 17:31:57 +08:00
8c625e212a fix downloading issue when chapter name contains special characters. Close #533 2025-10-05 17:31:24 +08:00
ab786ed2ab fix padding check. Close #527 2025-10-05 16:58:41 +08:00
d9303aab2e fix activity name. Close #528 2025-10-05 16:56:32 +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
30 changed files with 1499 additions and 633 deletions

View File

@@ -170,6 +170,9 @@ jobs:
sudo apt-get update -y sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1 sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian dart pub global activate flutter_to_debian
- name: "Patch font"
run: |
dart run patch/font.dart
- run: python3 debian/build.py arm64 - run: python3 debian/build.py arm64
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:

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

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

@@ -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,9 +70,16 @@ class NetworkError extends StatelessWidget {
child: Text('Verify'.tl), child: Text('Verify'.tl),
) )
else else
FilledButton( Row(
onPressed: retry, mainAxisAlignment: MainAxisAlignment.center,
child: Text(buttonText ?? 'Retry'.tl), children: [
if (action != null)
action!.paddingRight(8),
FilledButton(
onPressed: retry,
child: Text(buttonText ?? 'Retry'.tl),
),
],
), ),
], ],
), ),

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,14 +210,19 @@ class NaviPaneState extends State<NaviPane>
Widget buildMainView() { Widget buildMainView() {
return HeroControllerScope( return HeroControllerScope(
controller: MaterialApp.createMaterialHeroController(), controller: MaterialApp.createMaterialHeroController(),
child: Navigator( child: NavigatorPopHandler(
observers: [widget.observer], onPopWithResult: (result) {
key: widget.navigatorKey, widget.navigatorKey.currentState?.maybePop(result);
onGenerateRoute: (settings) => AppPageRoute( },
preventRebuild: false, child: Navigator(
builder: (context) { observers: [widget.observer],
return _NaviMainView(state: this); key: widget.navigatorKey,
}, onGenerateRoute: (settings) => AppPageRoute(
preventRebuild: false,
builder: (context) {
return _NaviMainView(state: this);
},
),
), ),
), ),
); );
@@ -239,7 +252,7 @@ class NaviPaneState extends State<NaviPane>
icon: Icon(action.icon), icon: Icon(action.icon),
onPressed: action.onTap, onPressed: action.onTap,
), ),
) ),
], ],
), ),
), ),
@@ -261,21 +274,18 @@ class NaviPaneState extends State<NaviPane>
), ),
), ),
child: Row( child: Row(
children: List<Widget>.generate( children: List<Widget>.generate(widget.paneItems.length, (index) {
widget.paneItems.length, return Expanded(
(index) { child: _SingleBottomNaviWidget(
return Expanded( enabled: currentPage == index,
child: _SingleBottomNaviWidget( entry: widget.paneItems[index],
enabled: currentPage == index, onTap: () {
entry: widget.paneItems[index], updatePage(index);
onTap: () { },
updatePage(index); 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,32 +593,25 @@ 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) {
if (details.globalPosition.dx < 64) { if (details.globalPosition.dx < 64) {
panStartAtEdge = true; panStartAtEdge = true;
}
},
onPanEnd: (details) {
if (details.velocity.pixelsPerSecond.dx < 0 ||
details.velocity.pixelsPerSecond.dx > 0) {
if (panStartAtEdge) {
action();
} }
}, }
onPanEnd: (details) { panStartAtEdge = false;
if (details.velocity.pixelsPerSecond.dx < 0 || },
details.velocity.pixelsPerSecond.dx > 0) { child: res,
if (panStartAtEdge) { );
action();
}
}
panStartAtEdge = false;
},
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.5.0"; final version = "1.5.2";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;

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

@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:ffi'; import 'dart:ffi';
import 'dart:isolate'; import 'dart:isolate';
@@ -213,12 +214,10 @@ class LocalFavoritesManager with ChangeNotifier {
late Map<String, int> counts; late Map<String, int> counts;
var _hashedIds = <int, int>{};
int get totalComics { int get totalComics {
int total = 0; return _hashedIds.length;
for (var t in counts.values) {
total += t;
}
return total;
} }
int folderComics(String folder) { int folderComics(String folder) {
@@ -280,6 +279,48 @@ class LocalFavoritesManager with ChangeNotifier {
for (var folder in folderNames) { for (var folder in folderNames) {
counts[folder] = count(folder); counts[folder] = count(folder);
} }
_initHashedIds(folderNames, _db.handle).then((value) {
_hashedIds = value;
notifyListeners();
});
}
void refreshHashedIds() {
_initHashedIds(folderNames, _db.handle).then((value) {
_hashedIds = value;
notifyListeners();
});
}
void reduceHashedId(String id, int type) {
var hash = id.hashCode ^ type;
if (_hashedIds.containsKey(hash)) {
if (_hashedIds[hash]! > 1) {
_hashedIds[hash] = _hashedIds[hash]! - 1;
} else {
_hashedIds.remove(hash);
}
}
}
static Future<Map<int, int>> _initHashedIds(
List<String> folders, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var hashedIds = <int, int>{};
for (var folder in folders) {
var rows = db.select("""
select id, type from "$folder";
""");
for (var row in rows) {
var id = row["id"] as String;
var type = row["type"] as int;
var hash = id.hashCode ^ type;
hashedIds[hash] = (hashedIds[hash] ?? 0) + 1;
}
}
return hashedIds;
});
} }
List<String> find(String id, ComicType type) { List<String> find(String id, ComicType type) {
@@ -559,7 +600,6 @@ class LocalFavoritesManager with ChangeNotifier {
/// return true if success, false if already exists /// return true if success, false if already exists
bool addComic(String folder, FavoriteItem comic, bool addComic(String folder, FavoriteItem comic,
[int? order, String? updateTime]) { [int? order, String? updateTime]) {
_modifiedAfterLastCache = true;
if (!existsFolder(folder)) { if (!existsFolder(folder)) {
throw Exception("Folder does not exists"); throw Exception("Folder does not exists");
} }
@@ -614,14 +654,14 @@ class LocalFavoritesManager with ChangeNotifier {
} else { } else {
counts[folder] = counts[folder]! + 1; counts[folder] = counts[folder]! + 1;
} }
var hash = comic.id.hashCode ^ comic.type.value;
_hashedIds[hash] = (_hashedIds[hash] ?? 0) + 1;
notifyListeners(); notifyListeners();
return true; return true;
} }
void moveFavorite( void moveFavorite(
String sourceFolder, String targetFolder, String id, ComicType type) { String sourceFolder, String targetFolder, String id, ComicType type) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) { if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist"); throw Exception("Source folder does not exist");
} }
@@ -655,8 +695,6 @@ class LocalFavoritesManager with ChangeNotifier {
void batchMoveFavorites( void batchMoveFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) { String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) { if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist"); throw Exception("Source folder does not exist");
} }
@@ -691,25 +729,15 @@ class LocalFavoritesManager with ChangeNotifier {
_db.execute("COMMIT"); _db.execute("COMMIT");
// Update counts // Update counts
if (counts[targetFolder] == null) { counts[targetFolder] = count(targetFolder);
counts[targetFolder] = count(targetFolder); counts[sourceFolder] = count(sourceFolder);
} else { refreshHashedIds();
counts[targetFolder] = counts[targetFolder]! + items.length;
}
if (counts[sourceFolder] != null) {
counts[sourceFolder] = counts[sourceFolder]! - items.length;
} else {
counts[sourceFolder] = count(sourceFolder);
}
notifyListeners(); notifyListeners();
} }
void batchCopyFavorites( void batchCopyFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) { String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) { if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist"); throw Exception("Source folder does not exist");
} }
@@ -740,18 +768,14 @@ class LocalFavoritesManager with ChangeNotifier {
_db.execute("COMMIT"); _db.execute("COMMIT");
// Update counts // Update counts
if (counts[targetFolder] == null) { counts[targetFolder] = count(targetFolder);
counts[targetFolder] = count(targetFolder); refreshHashedIds();
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
notifyListeners(); notifyListeners();
} }
/// delete a folder /// delete a folder
void deleteFolder(String name) { void deleteFolder(String name) {
_modifiedAfterLastCache = true;
_db.execute(""" _db.execute("""
drop table "$name"; drop table "$name";
"""); """);
@@ -760,11 +784,11 @@ class LocalFavoritesManager with ChangeNotifier {
where folder_name == ?; where folder_name == ?;
""", [name]); """, [name]);
counts.remove(name); counts.remove(name);
refreshHashedIds();
notifyListeners(); notifyListeners();
} }
void deleteComicWithId(String folder, String id, ComicType type) { void deleteComicWithId(String folder, String id, ComicType type) {
_modifiedAfterLastCache = true;
LocalFavoriteImageProvider.delete(id, type.value); LocalFavoriteImageProvider.delete(id, type.value);
_db.execute(""" _db.execute("""
delete from "$folder" delete from "$folder"
@@ -775,11 +799,11 @@ class LocalFavoritesManager with ChangeNotifier {
} else { } else {
counts[folder] = count(folder); counts[folder] = count(folder);
} }
reduceHashedId(id, type.value);
notifyListeners(); notifyListeners();
} }
void batchDeleteComics(String folder, List<FavoriteItem> comics) { void batchDeleteComics(String folder, List<FavoriteItem> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION"); _db.execute("BEGIN TRANSACTION");
try { try {
for (var comic in comics) { for (var comic in comics) {
@@ -800,11 +824,13 @@ class LocalFavoritesManager with ChangeNotifier {
return; return;
} }
_db.execute("COMMIT"); _db.execute("COMMIT");
for (var comic in comics) {
reduceHashedId(comic.id, comic.type.value);
}
notifyListeners(); notifyListeners();
} }
void batchDeleteComicsInAllFolders(List<ComicID> comics) { void batchDeleteComicsInAllFolders(List<ComicID> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION"); _db.execute("BEGIN TRANSACTION");
var folderNames = _getFolderNamesWithDB(); var folderNames = _getFolderNamesWithDB();
try { try {
@@ -824,6 +850,10 @@ class LocalFavoritesManager with ChangeNotifier {
} }
initCounts(); initCounts();
_db.execute("COMMIT"); _db.execute("COMMIT");
for (var comic in comics) {
var hash = comic.id.hashCode ^ comic.type.value;
_hashedIds.remove(hash);
}
notifyListeners(); notifyListeners();
} }
@@ -908,7 +938,6 @@ class LocalFavoritesManager with ChangeNotifier {
markAsRead(id, type); markAsRead(id, type);
return; return;
} }
_modifiedAfterLastCache = true;
var followUpdatesFolder = appdata.settings['followUpdatesFolder']; var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
for (final folder in folderNames) { for (final folder in folderNames) {
var rows = _db.select(""" var rows = _db.select("""
@@ -1029,28 +1058,9 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
final _cachedFavoritedIds = <String, bool>{};
bool isExist(String id, ComicType type) { bool isExist(String id, ComicType type) {
if (_modifiedAfterLastCache) { var hash = id.hashCode ^ type.value;
_cacheFavoritedIds(); return _hashedIds.containsKey(hash);
}
return _cachedFavoritedIds.containsKey("$id@${type.value}");
}
bool _modifiedAfterLastCache = true;
void _cacheFavoritedIds() {
_modifiedAfterLastCache = false;
_cachedFavoritedIds.clear();
for (var folder in folderNames) {
var rows = _db.select("""
select id, type from "$folder";
""");
for (var row in rows) {
_cachedFavoritedIds["${row["id"]}@${row["type"]}"] = true;
}
}
} }
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) { void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/utils/channel.dart';
class ComicUpdateResult { class ComicUpdateResult {
final bool updated; final bool updated;
@@ -62,6 +63,7 @@ Future<ComicUpdateResult> updateComic(
return ComicUpdateResult(updated, null); return ComicUpdateResult(updated, null);
} catch (e, s) { } catch (e, s) {
Log.error("Check Updates", e, s); Log.error("Check Updates", e, s);
await Future.delayed(const Duration(seconds: 2));
retries--; retries--;
if (retries == 0) { if (retries == 0) {
return ComicUpdateResult(false, e.toString()); return ComicUpdateResult(false, e.toString());
@@ -114,23 +116,50 @@ void updateFolderBase(
current = 0; current = 0;
stream.add(UpdateProgress(total, current, errors, updated)); stream.add(UpdateProgress(total, current, errors, updated));
var futures = <Future>[]; var channel = Channel<FavoriteItemWithUpdateInfo>(10);
for (var comic in comicsToUpdate) {
var future = updateComic(comic, folder).then((result) { // Producer
current++; () async {
if (result.updated) { var c = 0;
updated++; for (var comic in comicsToUpdate) {
await channel.push(comic);
c++;
// Throttle
if (c % 5 == 0) {
var delay = c % 100 + 1;
if (delay > 10) {
delay = 10;
}
await Future.delayed(Duration(seconds: delay));
} }
if (result.errorMessage != null) { }
errors++; channel.close();
}();
// Consumers
var updateFutures = <Future>[];
for (var i = 0; i < 5; i++) {
var f = () async {
while (true) {
var comic = await channel.pop();
if (comic == null) {
break;
}
var result = await updateComic(comic, folder);
current++;
if (result.updated) {
updated++;
}
if (result.errorMessage != null) {
errors++;
}
stream.add(UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
} }
stream.add( }();
UpdateProgress(total, current, errors, updated, comic, result.errorMessage)); updateFutures.add(f);
});
futures.add(future);
} }
await Future.wait(futures); await Future.wait(updateFutures);
if (updated > 0) { if (updated > 0) {
LocalFavoritesManager().notifyChanges(); LocalFavoritesManager().notifyChanges();

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

@@ -423,6 +423,7 @@ class LocalManager with ChangeNotifier {
if (comic.hasChapters) { if (comic.hasChapters) {
var cid = var cid =
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String); ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
cid = getChapterDirectoryName(cid);
directory = Directory(FilePath.join(directory.path, cid)); directory = Directory(FilePath.join(directory.path, cid));
} }
var files = <File>[]; var files = <File>[];
@@ -600,7 +601,10 @@ class LocalManager with ChangeNotifier {
} }
var shouldRemovedDirs = <Directory>[]; var shouldRemovedDirs = <Directory>[];
for (var chapter in chapters) { for (var chapter in chapters) {
var dir = Directory(FilePath.join(c.baseDir, chapter)); var dir = Directory(FilePath.join(
c.baseDir,
getChapterDirectoryName(chapter),
));
if (dir.existsSync()) { if (dir.existsSync()) {
shouldRemovedDirs.add(dir); shouldRemovedDirs.add(dir);
} }
@@ -668,6 +672,21 @@ class LocalManager with ChangeNotifier {
} }
}); });
} }
static String getChapterDirectoryName(String name) {
var builder = StringBuffer();
for (var i = 0; i < name.length; i++) {
var char = name[i];
if (char == '/' || char == '\\' || char == ':' || char == '*' ||
char == '?'
|| char == '"' || char == '<' || char == '>' || char == '|') {
builder.write('_');
} else {
builder.write(char);
}
}
return builder.toString();
}
} }
enum LocalSortType { enum LocalSortType {

View File

@@ -199,6 +199,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
tertiary = light.tertiary; tertiary = light.tertiary;
} }
return MaterialApp( return MaterialApp(
title: "venera",
home: home, home: home,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: getTheme(primary, secondary, tertiary, Brightness.light), theme: getTheme(primary, secondary, tertiary, Brightness.light),
@@ -246,9 +247,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
/// https://github.com/flutter/flutter/issues/161086 /// https://github.com/flutter/flutter/issues/161086
var isPaddingCheckError = var isPaddingCheckError =
MediaQuery.of(context).viewPadding.top <= 0 || MediaQuery.of(context).viewPadding.top <= 0 ||
MediaQuery.of(context).viewPadding.top > 50; MediaQuery.of(context).viewPadding.top > 200;
if (isPaddingCheckError) { 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

@@ -107,7 +107,21 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
var local = LocalManager().find(id, comicType); var local = LocalManager().find(id, comicType);
if (path != null) { if (path != null) {
if (local == null) { if (local == null) {
Directory(path!).deleteIgnoreError(recursive: true); Future.sync(() async {
var tasks = this.tasks.values.toList();
for (var i = 0; i < tasks.length; i++) {
if (!tasks[i].isComplete) {
tasks[i].cancel();
await tasks[i].wait();
}
}
try {
await Directory(path!).delete(recursive: true);
}
catch(e) {
Log.error("Download", "Failed to delete directory: $e");
}
});
} else if (chapters != null) { } else if (chapters != null) {
for (var c in chapters!) { for (var c in chapters!) {
var dir = Directory(FilePath.join(path!, c)); var dir = Directory(FilePath.join(path!, c));
@@ -197,7 +211,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (comic!.chapters != null) { if (comic!.chapters != null) {
saveTo = Directory(FilePath.join( saveTo = Directory(FilePath.join(
path!, path!,
_images!.keys.elementAt(_chapter), LocalManager.getChapterDirectoryName(
_images!.keys.elementAt(_chapter),
),
)); ));
if (!saveTo.existsSync()) { if (!saveTo.existsSync()) {
saveTo.createSync(recursive: true); saveTo.createSync(recursive: true);

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(
duration: const Duration(milliseconds: 200), 0,
curve: Curves.ease); duration: const Duration(milliseconds: 200),
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,31 +302,35 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(width: 16), const SizedBox(width: 16),
Hero( GestureDetector(
tag: "cover${widget.heroID}", onTap: () => _viewCover(context),
child: Container( onLongPress: () => _saveCover(context),
decoration: BoxDecoration( child: Hero(
color: context.colorScheme.primaryContainer, tag: "cover${widget.heroID}",
borderRadius: BorderRadius.circular(8), child: Container(
boxShadow: [ decoration: BoxDecoration(
BoxShadow( color: context.colorScheme.primaryContainer,
color: context.colorScheme.outlineVariant, borderRadius: BorderRadius.circular(8),
blurRadius: 1, boxShadow: [
offset: const Offset(0, 1), BoxShadow(
), color: context.colorScheme.outlineVariant,
], blurRadius: 1,
), offset: const Offset(0, 1),
height: 144, ),
width: 144 * 0.72, ],
clipBehavior: Clip.antiAlias, ),
child: AnimatedImage( height: 144,
image: CachedImageProvider( width: 144 * 0.72,
widget.cover ?? comic.cover, clipBehavior: Clip.antiAlias,
sourceKey: comic.sourceKey, child: AnimatedImage(
cid: comic.id, image: CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
),
width: double.infinity,
height: double.infinity,
), ),
width: double.infinity,
height: double.infinity,
), ),
), ),
), ),
@@ -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,10 +393,11 @@ 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! + (isLiked ? 1 : 0)) ((data!.likesCount != null)
: (isLiked ? 'Liked'.tl : 'Like'.tl)) ? (data!.likesCount! + (isLiked ? 1 : 0))
.toString(), : (isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(),
isLoading: isLiking, isLoading: isLiking,
onPressed: likeOrUnlike, onPressed: likeOrUnlike,
iconColor: context.useTextColor(Colors.red), iconColor: context.useTextColor(Colors.red),
@@ -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,
child: Text("Continue".tl),
)
: FilledButton(onPressed: read, child: Text("Read".tl)), : FilledButton(onPressed: read, child: Text("Read".tl)),
) ),
], ],
).paddingHorizontal(16).paddingVertical(8), ).paddingHorizontal(16).paddingVertical(8),
if (history != null) if (history != null)
@@ -412,19 +470,20 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
var epName = "E$ep"; var epName = "E$ep";
String? groupName; String? groupName;
try { try {
if (group == null){ if (group == null) {
epName = comic.chapters!.titles.elementAt( epName = comic.chapters!.titles.elementAt(
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);
}
}
} }
} }
@@ -792,20 +883,21 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
itemCount: widget.eps.length, itemCount: widget.eps.length,
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) {
setState(() { setState(() {
if (selected.contains(i)) { if (selected.contains(i)) {
selected.remove(i); selected.remove(i);
} else { } else {
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(
cid: widget.cid,
type: widget.type,
isFavorite: widget.isFavorite,
onFavorite: widget.onFavorite,
favoriteItem: widget.favoriteItem,
updateTime: widget.updateTime,
comicSource: comicSource,
hasNetwork: hasNetwork,
localFolders: localFolders,
added: added,
), ),
body: Column(
children: [
TabBar(
controller: tabController,
tabs: [
Tab(text: "Local".tl),
if (hasNetwork) Tab(text: "Network".tl),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
buildLocal(),
if (hasNetwork) buildNetwork(),
],
),
),
],
),
);
}
late List<String> localFolders;
late List<String> added;
var selectedLocalFolders = <String>{};
Widget buildLocal() {
var isRemove = selectedLocalFolders.isNotEmpty &&
added.contains(selectedLocalFolders.first);
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: localFolders.length + 1,
itemBuilder: (context, index) {
if (index == localFolders.length) {
return SizedBox(
height: 36,
child: Center(
child: TextButton(
onPressed: () {
newFolder().then((v) {
setState(() {
localFolders = LocalFavoritesManager().folderNames;
});
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 4),
Text("New Folder".tl)
],
),
),
),
);
}
var folder = localFolders[index];
var disabled = false;
if (selectedLocalFolders.isNotEmpty) {
if (added.contains(folder) &&
!added.contains(selectedLocalFolders.first)) {
disabled = true;
} else if (!added.contains(folder) &&
added.contains(selectedLocalFolders.first)) {
disabled = true;
}
}
return CheckboxListTile(
title: Row(
children: [
Text(folder),
const SizedBox(width: 8),
if (added.contains(folder))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
value: selectedLocalFolders.contains(folder),
onChanged: disabled
? null
: (v) {
setState(() {
if (v!) {
selectedLocalFolders.add(folder);
} else {
selectedLocalFolders.remove(folder);
}
});
},
);
},
),
),
Center(
child: FilledButton(
onPressed: () {
if (selectedLocalFolders.isEmpty) {
return;
}
if (isRemove) {
for (var folder in selectedLocalFolders) {
LocalFavoritesManager()
.deleteComicWithId(folder, widget.cid, widget.type);
}
widget.onFavorite(false, null);
} else {
for (var folder in selectedLocalFolders) {
LocalFavoritesManager().addComic(
folder,
widget.favoriteItem,
null,
widget.updateTime,
);
}
widget.onFavorite(true, null);
}
context.pop();
},
child: isRemove ? Text("Remove".tl) : Text("Add".tl),
).paddingVertical(8),
),
],
);
}
Widget buildNetwork() {
return _NetworkFavorites(
cid: widget.cid,
comicSource: comicSource,
isFavorite: widget.isFavorite,
onFavorite: (network) {
widget.onFavorite(null, network);
},
); );
} }
} }
class _NetworkFavorites extends StatefulWidget { class _FavoriteList extends StatefulWidget {
const _NetworkFavorites({ const _FavoriteList({
required this.cid,
required this.type,
required this.isFavorite,
required this.onFavorite,
required this.favoriteItem,
this.updateTime,
required this.comicSource,
required this.hasNetwork,
required this.localFolders,
required this.added,
});
final String cid;
final ComicType type;
final bool? isFavorite;
final void Function(bool?, bool?) onFavorite;
final FavoriteItem favoriteItem;
final String? updateTime;
final ComicSource comicSource;
final bool hasNetwork;
final List<String> localFolders;
final List<String> added;
@override
State<_FavoriteList> createState() => _FavoriteListState();
}
class _FavoriteListState extends State<_FavoriteList> {
@override
Widget build(BuildContext context) {
final localFavoritesFirst = appdata.settings['localFavoritesFirst'] ?? true;
final localSection = _LocalSection(
cid: widget.cid,
type: widget.type,
favoriteItem: widget.favoriteItem,
updateTime: widget.updateTime,
localFolders: widget.localFolders,
added: widget.added,
onFavorite: (local) {
widget.onFavorite(local, null);
},
);
final networkSection = widget.hasNetwork
? _NetworkSection(
cid: widget.cid,
comicSource: widget.comicSource,
isFavorite: widget.isFavorite,
onFavorite: (network) {
widget.onFavorite(null, network);
},
)
: null;
final divider = widget.hasNetwork
? Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: context.colorScheme.outlineVariant.withValues(alpha: 0.3),
)
: null;
return ListView(
children: [
if (localFavoritesFirst) ...[
localSection,
if (widget.hasNetwork) ...[divider!, networkSection!],
] else ...[
if (widget.hasNetwork) ...[networkSection!, divider!],
localSection,
],
],
);
}
}
class _NetworkSection extends StatefulWidget {
const _NetworkSection({
required this.cid, required this.cid,
required this.comicSource, required this.comicSource,
required this.isFavorite, required this.isFavorite,
@@ -232,82 +156,55 @@ class _NetworkFavorites extends StatefulWidget {
}); });
final String cid; final String cid;
final ComicSource comicSource; final ComicSource comicSource;
final bool? isFavorite; final bool? isFavorite;
final void Function(bool) onFavorite; final void Function(bool) onFavorite;
@override @override
State<_NetworkFavorites> createState() => _NetworkFavoritesState(); State<_NetworkSection> createState() => _NetworkSectionState();
} }
class _NetworkFavoritesState extends State<_NetworkFavorites> { class _NetworkSectionState extends State<_NetworkSection> {
@override
Widget build(BuildContext context) {
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
}
bool isLoading = false; bool isLoading = false;
Widget buildSingleFolder() {
var isFavorite = widget.isFavorite ?? false;
return Column(
children: [
Expanded(
child: Center(
child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl),
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
setState(() {
isLoading = true;
});
var res = await widget.comicSource.favoriteData!
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
if (res.success) {
widget.onFavorite(!isFavorite);
context.pop();
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl);
} else {
setState(() {
isLoading = false;
});
context.showMessage(message: res.errorMessage!);
}
},
child: isFavorite ? Text("Remove".tl) : Text("Add".tl),
).paddingVertical(8),
),
],
);
}
Map<String, String>? folders; Map<String, String>? folders;
var addedFolders = <String>{}; var addedFolders = <String>{};
var isLoadingFolders = true; var isLoadingFolders = true;
bool? localIsFavorite;
final Map<String, bool> _itemLoading = {};
late List<double> _skeletonWidths;
// for network favorites, only one selection is allowed @override
String? selected; void initState() {
super.initState();
localIsFavorite = widget.isFavorite;
_skeletonWidths = List.generate(3, (_) => 0.3 + math.Random().nextDouble() * 0.5);
if (widget.comicSource.favoriteData!.loadFolders != null) {
loadFolders();
} else {
isLoadingFolders = false;
}
}
void loadFolders() async { void loadFolders() async {
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid); var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
if (res.error) { if (res.error) {
context.showMessage(message: res.errorMessage!); context.showMessage(message: res.errorMessage!);
setState(() {
isLoadingFolders = false;
});
} else { } else {
folders = res.data; folders = res.data;
if (res.subData is List) { if (res.subData is List) {
addedFolders = List<String>.from(res.subData).toSet(); final list = List<String>.from(res.subData);
if (list.isNotEmpty) {
addedFolders = {list.first};
} else {
addedFolders.clear();
}
localIsFavorite = addedFolders.isNotEmpty;
} else {
addedFolders.clear();
localIsFavorite = false;
} }
setState(() { setState(() {
isLoadingFolders = false; isLoadingFolders = false;
@@ -315,118 +212,470 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
} }
} }
Widget buildMultiFolder() { Widget _buildLoadingSkeleton() {
if (widget.isFavorite == true && return Column(
widget.comicSource.favoriteData!.singleFolderForSingleComic) { crossAxisAlignment: CrossAxisAlignment.start,
return Column( children: [
children: [ Padding(
Expanded( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Center( child: Text(
child: Text("Added to favorites".tl), "Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
), ),
), ),
Center( ),
child: Button.filled( Shimmer(
isLoading: isLoading, child: Column(
onPressed: () async { children: List.generate(3, (index) {
setState(() { return ListTile(
isLoading = true; title: Container(
}); height: 20,
width: double.infinity,
var res = await widget.comicSource.favoriteData! margin: const EdgeInsets.only(right: 16),
.addOrDelFavorite!(widget.cid, '', false, null); child: FractionallySizedBox(
if (res.success) { widthFactor: _skeletonWidths[index],
widget.onFavorite(false); alignment: Alignment.centerLeft,
context.pop(); child: Container(
App.rootContext.showMessage(message: "Removed".tl); decoration: BoxDecoration(
} else { color: context.colorScheme.surfaceContainerLow,
setState(() { borderRadius: BorderRadius.circular(4),
isLoading = false; ),
}); ),
context.showMessage(message: res.errorMessage!);
}
},
child: Text("Remove".tl),
).paddingVertical(8),
),
],
);
}
if (isLoadingFolders) {
loadFolders();
return const Center(child: CircularProgressIndicator());
} else {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: folders!.length,
itemBuilder: (context, index) {
var name = folders!.values.elementAt(index);
var id = folders!.keys.elementAt(index);
return CheckboxListTile(
title: Row(
children: [
Text(name),
const SizedBox(width: 8),
if (addedFolders.contains(id))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
), ),
value: selected == id, ),
onChanged: (v) { trailing: Container(
setState(() { height: 28,
selected = id; width: 60 + (index * 2),
}); decoration: BoxDecoration(
}, color: context.colorScheme.surfaceContainerLow,
); borderRadius: BorderRadius.circular(12),
}, ),
), ),
);
}),
), ),
Center( ),
child: Button.filled( ],
isLoading: isLoading, );
onPressed: () async { }
if (selected == null) {
return; @override
} Widget build(BuildContext context) {
setState(() { if (isLoadingFolders) {
isLoading = true; return _buildLoadingSkeleton();
}); }
var res =
await widget.comicSource.favoriteData!.addOrDelFavorite!( bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
widget.cid,
selected!, if (isMultiFolder) {
!addedFolders.contains(selected!), return _buildMultiFolder();
null, } else {
); return _buildSingleFolder();
if (res.success) {
context.showMessage(message: "Success".tl);
context.pop();
} else {
context.showMessage(message: res.errorMessage!);
setState(() {
isLoading = false;
});
}
},
child: selected != null && addedFolders.contains(selected!)
? Text("Remove".tl)
: Text("Add".tl),
).paddingVertical(8),
),
],
);
} }
} }
Widget _buildSingleFolder() {
var isFavorite = localIsFavorite ?? false;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
ListTile(
title: Row(
children: [
Text("Network Favorites".tl),
const SizedBox(width: 8),
if (isFavorite)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
trailing: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _HoverButton(
isFavorite: isFavorite,
onTap: () async {
setState(() {
isLoading = true;
});
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
if (res.success) {
setState(() {
localIsFavorite = !isFavorite;
});
widget.onFavorite(!isFavorite);
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl,
);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
isLoading = false;
});
},
),
),
],
);
}
Widget _buildMultiFolder() {
if (localIsFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
return ListTile(
title: Row(
children: [
Text("Network Favorites".tl),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
trailing: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _HoverButton(
isFavorite: true,
onTap: () async {
setState(() {
isLoading = true;
});
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, '', false, null);
if (res.success) {
// Invalidate network cache so subsequent loads see latest
NetworkCacheManager().clear();
setState(() {
localIsFavorite = false;
});
widget.onFavorite(false);
App.rootContext.showMessage(message: "Removed".tl);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
isLoading = false;
});
},
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
...folders!.entries.map((entry) {
var name = entry.value;
var id = entry.key;
var isAdded = addedFolders.contains(id);
var hasSelection = addedFolders.isNotEmpty;
var enabled = !hasSelection || isAdded;
return ListTile(
title: Row(
children: [
Text(name),
const SizedBox(width: 8),
if (isAdded)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
trailing: (_itemLoading[id] ?? false)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _HoverButton(
isFavorite: isAdded,
enabled: enabled,
onTap: () async {
setState(() {
_itemLoading[id] = true;
});
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, id, !isAdded, null);
if (res.success) {
// Invalidate network cache so folders/pages reload with fresh data
NetworkCacheManager().clear();
setState(() {
if (isAdded) {
addedFolders.clear();
} else {
addedFolders
..clear()
..add(id);
}
// sync local flag for single-folder-per-comic logic and parent
localIsFavorite = addedFolders.isNotEmpty;
});
// notify parent so page state updates when closing and reopening panel
widget.onFavorite(addedFolders.isNotEmpty);
context.showMessage(message: "Success".tl);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
_itemLoading[id] = false;
});
},
),
);
}),
],
);
}
}
class _LocalSection extends StatefulWidget {
const _LocalSection({
required this.cid,
required this.type,
required this.favoriteItem,
this.updateTime,
required this.localFolders,
required this.added,
required this.onFavorite,
});
final String cid;
final ComicType type;
final FavoriteItem favoriteItem;
final String? updateTime;
final List<String> localFolders;
final List<String> added;
final void Function(bool) onFavorite;
@override
State<_LocalSection> createState() => _LocalSectionState();
}
class _LocalSectionState extends State<_LocalSection> {
late List<String> localFolders;
late Set<String> localAdded;
@override
void initState() {
super.initState();
localFolders = widget.localFolders;
localAdded = widget.added.toSet();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Local Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
...localFolders.map((folder) {
var isAdded = localAdded.contains(folder);
return ListTile(
title: Row(
children: [
Text(folder),
const SizedBox(width: 8),
if (isAdded)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
trailing: _HoverButton(
isFavorite: isAdded,
onTap: () {
if (isAdded) {
LocalFavoritesManager().deleteComicWithId(
folder,
widget.cid,
widget.type,
);
setState(() {
localAdded.remove(folder);
});
widget.onFavorite(false);
} else {
LocalFavoritesManager().addComic(
folder,
widget.favoriteItem,
null,
widget.updateTime,
);
setState(() {
localAdded.add(folder);
});
widget.onFavorite(true);
}
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
},
),
);
}),
// New folder button
ListTile(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 4),
Text("New Folder".tl),
],
),
onTap: () {
newFolder().then((v) {
setState(() {
localFolders = LocalFavoritesManager().folderNames;
});
});
},
),
],
);
}
}
class _HoverButton extends StatefulWidget {
const _HoverButton({
required this.isFavorite,
required this.onTap,
this.enabled = true,
});
final bool isFavorite;
final VoidCallback onTap;
final bool enabled;
@override
State<_HoverButton> createState() => _HoverButtonState();
}
class _HoverButtonState extends State<_HoverButton> {
bool isHovered = false;
@override
Widget build(BuildContext context) {
final removeColor = context.colorScheme.error;
final removeHoverColor = Color.lerp(removeColor, Colors.black, 0.2)!;
final addColor = context.colorScheme.primary;
final addHoverColor = Color.lerp(addColor, Colors.black, 0.2)!;
return MouseRegion(
onEnter: widget.enabled ? (_) => setState(() => isHovered = true) : null,
onExit: widget.enabled ? (_) => setState(() => isHovered = false) : null,
child: GestureDetector(
onTap: widget.enabled ? widget.onTap : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: widget.enabled
? (widget.isFavorite
? (isHovered ? removeHoverColor : removeColor)
: (isHovered ? addHoverColor : addColor))
: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.isFavorite ? "Remove".tl : "Add".tl,
style: ts.s12.copyWith(
color: widget.enabled
? context.colorScheme.onPrimary
: context.colorScheme.onSurfaceVariant,
),
),
),
),
);
}
} }

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

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

58
lib/utils/channel.dart Normal file
View File

@@ -0,0 +1,58 @@
import 'dart:async';
import 'dart:collection';
class Channel<T> {
final Queue<T> _queue;
final int size;
Channel(this.size) : _queue = Queue<T>();
Completer? _releaseCompleter;
Completer? _pushCompleter;
var currentSize = 0;
var isClosed = false;
Future<void> push(T item) async {
if (currentSize >= size) {
_releaseCompleter ??= Completer();
return _releaseCompleter!.future.then((_) {
if (isClosed) {
return;
}
_queue.addLast(item);
currentSize++;
});
}
_queue.addLast(item);
currentSize++;
_pushCompleter?.complete();
_pushCompleter = null;
}
Future<T?> pop() async {
while (_queue.isEmpty) {
if (isClosed) {
return null;
}
_pushCompleter ??= Completer();
await _pushCompleter!.future;
}
var item = _queue.removeFirst();
currentSize--;
if (_releaseCompleter != null && currentSize < size) {
_releaseCompleter!.complete();
_releaseCompleter = null;
}
return item;
}
void close() {
isClosed = true;
_pushCompleter?.complete();
_releaseCompleter?.complete();
}
}

28
patch/font.dart Normal file
View File

@@ -0,0 +1,28 @@
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:dio/dio.dart';
void main() async {
const harmonySansLink = "https://developer.huawei.com/images/download/general/HarmonyOS-Sans.zip";
var dio = Dio();
await dio.download(harmonySansLink, "HarmonyOS-Sans.zip");
await extractFileToDisk("HarmonyOS-Sans.zip", "./assets/");
File("HarmonyOS-Sans.zip").deleteSync();
var pubspec = await File("pubspec.yaml").readAsString();
pubspec = pubspec.replaceFirst("# fonts:",
""" fonts:
- family: HarmonyOS Sans
fonts:
- asset: assets/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf
""");
await File("pubspec.yaml").writeAsString(pubspec);
var mainDart = await File("lib/main.dart").readAsString();
mainDart = mainDart.replaceFirst("Noto Sans CJK", "HarmonyOS Sans");
await File("lib/main.dart").writeAsString(mainDart);
print("Successfully patched font.");
}

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive:
dependency: "direct dev"
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -770,6 +778,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
rhttp: rhttp:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1116,4 +1132,4 @@ packages:
version: "0.0.12" version: "0.0.12"
sdks: sdks:
dart: ">=3.8.0 <4.0.0" dart: ">=3.8.0 <4.0.0"
flutter: ">=3.35.2" flutter: ">=3.35.5"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.5.0+150 version: 1.5.2+152
environment: environment:
sdk: '>=3.8.0 <4.0.0' sdk: '>=3.8.0 <4.0.0'
flutter: 3.35.2 flutter: 3.35.5
dependencies: dependencies:
flutter: flutter:
@@ -94,6 +94,7 @@ dev_dependencies:
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
flutter_to_arch: ^1.0.1 flutter_to_arch: ^1.0.1
flutter_to_debian: ^2.0.2 flutter_to_debian: ^2.0.2
archive: any
flutter: flutter:
uses-material-design: true uses-material-design: true
@@ -104,6 +105,7 @@ flutter:
- assets/tags.json - assets/tags.json
- assets/tags_tw.json - assets/tags_tw.json
- assets/opencc.txt - assets/opencc.txt
# fonts:
flutter_to_arch: flutter_to_arch:
name: Venera name: Venera

115
test/channel_test.dart Normal file
View File

@@ -0,0 +1,115 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:venera/utils/channel.dart';
void main() {
test("1-1-1", () async {
var channel = Channel<int>(1);
await channel.push(1);
var item = await channel.pop();
expect(item, 1);
});
test("1-3-1", () async {
var channel = Channel<int>(1);
// producer
() async {
await channel.push(1);
}();
() async {
await channel.push(2);
}();
() async {
await channel.push(3);
}();
// consumer
var results = <int>[];
for (var i = 0; i < 3; i++) {
var item = await channel.pop();
if (item != null) {
results.add(item);
}
}
expect(results.length, 3);
});
test("2-3-1", () async {
var channel = Channel<int>(2);
// producer
() async {
await channel.push(1);
}();
() async {
await channel.push(2);
}();
() async {
await channel.push(3);
}();
// consumer
var results = <int>[];
for (var i = 0; i < 3; i++) {
var item = await channel.pop();
if (item != null) {
results.add(item);
}
}
expect(results.length, 3);
});
test("1-1-3", () async {
var channel = Channel<int>(1);
// producer
() async {
print("push 1");
await channel.push(1);
print("push 2");
await channel.push(2);
print("push 3");
await channel.push(3);
print("push done");
channel.close();
}();
// consumer
var consumers = <Future>[];
var results = <int>[];
for (var i = 0; i < 3; i++) {
consumers.add(() async {
while (true) {
var item = await channel.pop();
if (item == null) {
break;
}
print("pop $item");
results.add(item);
}
}());
}
await Future.wait(consumers);
expect(results.length, 3);
});
test("close", () async {
var channel = Channel<int>(2);
// producer
() async {
await channel.push(1);
await channel.push(2);
await channel.push(3);
channel.close();
}();
// consumer
await channel.pop();
await channel.pop();
await channel.pop();
var item4 = await channel.pop();
expect(item4, null);
});
}