Merge pull request #183 from venera-app/v1.2.4-dev

V1.2.4
This commit is contained in:
nyne
2025-02-09 19:59:26 +08:00
committed by GitHub
29 changed files with 648 additions and 336 deletions

View File

@@ -39,12 +39,18 @@ jobs:
ln -s /Applications dist/dmg_contents/Applications
hdiutil create -volname "venera" -srcfolder dist/dmg_contents -ov -format UDZO "dist/venera.dmg"
- name: Add version to filename
run: |
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
mkdir -p result
mv dist/venera.dmg result/venera-$APP_VERSION.dmg
# Step 4: Attach and upload artifacts (optional)
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: venera.dmg
path: dist/venera.dmg
name: macos_build
path: result/
Build_IOS:
runs-on: macos-15
steps:
@@ -62,10 +68,15 @@ jobs:
mv /Users/runner/work/venera/venera/build/ios/iphoneos/Runner.app /Users/runner/work/venera/venera/build/ios/iphoneos/Payload
cd /Users/runner/work/venera/venera/build/ios/iphoneos/
zip -r venera-ios.ipa Payload
- name: Add version to filename
run: |
APP_VERSION=$(grep "version:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
mkdir -p result
mv build/ios/iphoneos/venera-ios.ipa result/venera-ios-$APP_VERSION.ipa
- uses: actions/upload-artifact@v4
with:
name: app-ios.ipa
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa
name: ios_build
path: result/
Build_Android:
runs-on: ubuntu-22.04
steps:
@@ -130,7 +141,7 @@ jobs:
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py
- run: python3 debian/build.py x64
- run: dart run flutter_to_arch
- run: |
sudo rm -rf build/linux/arch/app.tar.gz
@@ -145,19 +156,43 @@ jobs:
with:
name: arch_build
path: build/linux/arch/
Build_Linux_ARM64:
runs-on: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v4
- name: Setup Flutter
run: |
FLUTTER_VERSION=$(grep " flutter:" pubspec.yaml | cut -d':' -f2 | tr -d ' ')
sudo apt-get update -y && sudo apt-get upgrade -y;
sudo apt-get install -y curl git unzip xz-utils zip libglu1-mesa clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev
git clone --depth 1 --branch $FLUTTER_VERSION https://github.com/flutter/flutter.git $RUNNER_TEMP/flutter
echo "$RUNNER_TEMP/flutter/bin" >> $GITHUB_PATH
- name: Install Flutter
run: flutter doctor
- name: Install dependencies
run: |
flutter pub get
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- run: python3 debian/build.py arm64
- uses: actions/upload-artifact@v4
with:
name: deb_arm64_build
path: build/linux/x64/release/debian # This is a bug related to flutter_to_debian, but it's not a big deal.
Release:
runs-on: ubuntu-22.04
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux]
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux, Build_Linux_ARM64]
if: github.event_name == 'release' # 仅在 push 事件时执行
steps:
- uses: actions/download-artifact@v4
with:
name: venera.dmg
name: macos_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: app-ios.ipa
name: ios_build
path: outputs
- uses: actions/download-artifact@v4
with:
@@ -175,6 +210,10 @@ jobs:
with:
name: arch_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: deb_arm64_build
path: outputs
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}

View File

@@ -321,7 +321,10 @@
"Manage": "管理",
"Verify": "验证",
"Cloudflare verification required": "需要Cloudflare验证",
"Success": "成功"
"Success": "成功",
"Compressing": "压缩中",
"Exporting": "导出中",
"Search Sources": "搜索源"
},
"zh_TW": {
"Home": "首頁",
@@ -645,6 +648,9 @@
"Manage": "管理",
"Verify": "驗證",
"Cloudflare verification required": "需要Cloudflare驗證",
"Success": "成功"
"Success": "成功",
"Compressing": "壓縮中",
"Exporting": "匯出中",
"Search Sources": "搜索源"
}
}

11
debian/build.py vendored
View File

@@ -1,5 +1,7 @@
import subprocess
import sys
arch = sys.argv[1]
debianContent = ''
desktopContent = ''
version = ''
@@ -12,7 +14,14 @@ with open('pubspec.yaml', 'r') as f:
version = str.split(str.split(f.read(), 'version: ')[1], '+')[0]
with open('debian/debian.yaml', 'w') as f:
f.write(debianContent.replace('{{Version}}', version))
content = debianContent.replace('{{Version}}', version)
if arch == 'x64':
content = content.replace('{{Arch}}', 'x64')
content = content.replace('{{Architecture}}', 'amd64')
elif arch == 'arm64':
content = content.replace('{{Arch}}', 'arm64')
content = content.replace('{{Architecture}}', 'arm64')
f.write(content)
with open('debian/gui/venera.desktop', 'w') as f:
f.write(desktopContent.replace('{{Version}}', version))

6
debian/debian.yaml vendored
View File

@@ -1,13 +1,13 @@
flutter_app:
command: venera
arch: x64
arch: {{Arch}}
parent: /usr/local/lib
nonInteractive: false
nonInteractive: true
control:
Package: venera
Version: {{Version}}
Architecture: amd64
Architecture: {{Architecture}}
Priority: optional
Depends: libwebkit2gtk-4.1-0, libgtk-3-0
Maintainer: nyne

View File

@@ -550,7 +550,7 @@ class _ComicDescription extends StatelessWidget {
int cnt = (constraints.maxHeight - 22).toInt() ~/ 25;
return Container(
clipBehavior: Clip.antiAlias,
height: 22 + cnt * 25,
height: 21 + cnt * 24,
width: double.infinity,
decoration: const BoxDecoration(),
child: Wrap(
@@ -562,31 +562,30 @@ class _ComicDescription extends StatelessWidget {
children: [
for (var s in tags!)
Container(
height: 22,
padding: const EdgeInsets.fromLTRB(3, 2, 3, 2),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45,
height: 21,
padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45,
),
decoration: BoxDecoration(
color: s == "Unavailable"
? context.colorScheme.errorContainer
: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
decoration: BoxDecoration(
color: s == "Unavailable"
? Theme.of(context).colorScheme.errorContainer
: Theme.of(context)
.colorScheme
.secondaryContainer,
borderRadius:
const BorderRadius.all(Radius.circular(8)),
),
child: Center(
widthFactor: 1,
child: Text(
enableTranslate
? TagsTranslation.translateTag(s)
: s.split(':').last,
style: const TextStyle(fontSize: 12),
softWrap: true,
overflow: TextOverflow.ellipsis,
maxLines: 1,
))),
),
),
],
),
).toAlign(Alignment.topCenter);
@@ -1520,14 +1519,15 @@ class SimpleComicTile extends StatelessWidget {
return AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ?? () {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
onTap: onTap ??
() {
context.to(
() => ComicPage(
id: comic.id,
sourceKey: comic.sourceKey,
),
);
},
child: Container(
width: 92,
height: 114,

View File

@@ -148,3 +148,18 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
return false;
}
}
class SliverLazyToBoxAdapter extends StatelessWidget {
/// Creates a sliver that contains a single box widget which can be lazy loaded.
const SliverLazyToBoxAdapter({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SliverList.list(children: [
SizedBox(),
child,
]);
}
}

View File

@@ -168,7 +168,15 @@ Future<void> showConfirmDialog({
}
class LoadingDialogController {
void Function()? closeDialog;
double? _progress;
String? _message;
void Function()? _closeDialog;
void Function(double? value)? _serProgress;
void Function(String message)? _setMessage;
bool closed = false;
@@ -177,63 +185,86 @@ class LoadingDialogController {
return;
}
closed = true;
if (closeDialog == null) {
Future.microtask(closeDialog!);
if (_closeDialog == null) {
Future.microtask(_closeDialog!);
} else {
closeDialog!();
_closeDialog!();
}
}
void setProgress(double? value) {
if (closed) {
return;
}
_serProgress?.call(value);
}
void setMessage(String message) {
if (closed) {
return;
}
_setMessage?.call(message);
}
}
LoadingDialogController showLoadingDialog(BuildContext context,
{void Function()? onCancel,
bool barrierDismissible = true,
bool allowCancel = true,
String? message,
String cancelButtonText = "Cancel"}) {
LoadingDialogController showLoadingDialog(
BuildContext context, {
void Function()? onCancel,
bool barrierDismissible = true,
bool allowCancel = true,
String? message,
String cancelButtonText = "Cancel",
bool withProgress = false,
}) {
var controller = LoadingDialogController();
controller._message = message;
if (withProgress) {
controller._progress = 0;
}
var loadingDialogRoute = DialogRoute(
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return Dialog(
child: Container(
width: 100,
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const SizedBox(
width: 30,
height: 30,
child: CircularProgressIndicator(),
),
const SizedBox(
width: 16,
),
Text(
message ?? 'Loading',
style: const TextStyle(fontSize: 16),
),
const Spacer(),
if (allowCancel)
TextButton(
onPressed: () {
controller.close();
onCancel?.call();
},
child: Text(cancelButtonText.tl))
],
),
),
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return StatefulBuilder(builder: (context, setState) {
controller._serProgress = (value) {
setState(() {
controller._progress = value;
});
};
controller._setMessage = (message) {
setState(() {
controller._message = message;
});
};
return ContentDialog(
title: controller._message ?? 'Loading',
content: LinearProgressIndicator(
value: controller._progress,
backgroundColor: context.colorScheme.surfaceContainer,
).paddingHorizontal(16).paddingVertical(16),
actions: [
FilledButton(
onPressed: allowCancel
? () {
controller.close();
onCancel?.call();
}
: null,
child: Text(cancelButtonText.tl),
)
],
);
});
},
);
var navigator = Navigator.of(context, rootNavigator: true);
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
controller.closeDialog = () {
controller._closeDialog = () {
navigator.removeRoute(loadingDialogRoute);
};
@@ -444,9 +475,7 @@ Future<int?> showSelectDialog({
child: Text('Cancel'.tl),
),
FilledButton(
onPressed: current == null
? null
: context.pop,
onPressed: current == null ? null : context.pop,
child: Text('Confirm'.tl),
),
],

View File

@@ -10,7 +10,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.2.3";
final version = "1.2.4";
bool get isAndroid => Platform.isAndroid;
@@ -52,7 +52,7 @@ class _App {
BuildContext get rootContext => rootNavigatorKey.currentContext!;
void rootPop() {
rootNavigatorKey.currentState?.pop();
rootNavigatorKey.currentState?.maybePop();
}
void pop() {

View File

@@ -126,6 +126,7 @@ class _Settings with ChangeNotifier {
'explore_pages': [],
'categories': [],
'favorites': [],
'searchSources': null,
'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false,
'blockedWords': [],

View File

@@ -42,11 +42,16 @@ Future<void> init() async {
await ComicSource.init().wait();
await LocalManager().init().wait();
CacheManager().setLimitSize(appdata.settings['cacheSize']);
if (appdata.settings['searchSources'] == null) {
appdata.settings['searchSources'] = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
}
if (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
}
}

View File

@@ -132,6 +132,38 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
};
}
ThemeData getTheme(
Color primary,
Color? secondary,
Color? tertiary,
Brightness brightness,
) {
String? font;
List<String>? fallback;
if (App.isWindows) {
font = 'Segoe UI';
fallback = [
'Segoe UI',
'Microsoft YaHei',
'PingFang SC',
'Noto Sans CJK',
'Arial',
'sans-serif'
];
}
return ThemeData(
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
brightness: brightness,
tones: FlexTones.vividBackground(brightness),
),
fontFamily: font,
fontFamilyFallback: fallback,
);
}
@override
Widget build(BuildContext context) {
Widget home;
@@ -158,24 +190,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
return MaterialApp(
home: home,
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
tones: FlexTones.vividBackground(Brightness.light),
),
),
theme: getTheme(primary, secondary, tertiary, Brightness.light),
navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData(
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
brightness: Brightness.dark,
tones: FlexTones.vividBackground(Brightness.dark),
),
),
darkTheme: getTheme(primary, secondary, tertiary, Brightness.dark),
themeMode: switch (appdata.settings['theme_mode']) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,

View File

@@ -152,6 +152,7 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
);
webview.open();
} else {
bool success = false;
void check(InAppWebViewController controller) async {
var head = await controller.evaluateJavascript(
source: "document.head.innerHTML") as String;
@@ -176,7 +177,10 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
return;
}
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
App.rootPop();
if (!success) {
App.rootPop();
success = true;
}
}
}

View File

@@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import 'package:shimmer_animation/shimmer_animation.dart';
import "package:venera/components/components.dart";
import "package:venera/foundation/app.dart";
import "package:venera/foundation/appdata.dart";
import "package:venera/foundation/comic_source/comic_source.dart";
import "package:venera/pages/search_result_page.dart";
import "package:venera/utils/translations.dart";
@@ -24,7 +25,18 @@ class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
@override
void initState() {
sources = ComicSource.all().where((e) => e.searchPageData != null).toList();
var all = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
var settings = appdata.settings['searchSources'] as List;
var sources = <String>[];
for (var source in settings) {
if (all.contains(source)) {
sources.add(source);
}
}
this.sources = sources.map((e) => ComicSource.find(e)!).toList();
_keyword = widget.keyword;
controller = SearchBarController(
currentText: widget.keyword,

View File

@@ -206,62 +206,64 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
yield const SliverPadding(padding: EdgeInsets.only(top: 8));
yield Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
yield SliverLazyToBoxAdapter(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
),
width: double.infinity,
height: double.infinity,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14)
.paddingVertical(4),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
),
width: double.infinity,
height: double.infinity,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14)
.paddingVertical(4),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
),
],
),
),
],
).toSliver();
],
),
);
}
Widget buildActions() {
bool isMobile = context.width < changePoint;
bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1);
return SliverToBoxAdapter(
return SliverLazyToBoxAdapter(
child: Column(
children: [
ListView(
@@ -354,7 +356,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.description == null || comic.description!.trim().isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverToBoxAdapter(
return SliverLazyToBoxAdapter(
child: Column(
children: [
ListTile(
@@ -482,7 +484,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
bool enableTranslation =
App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate;
return SliverToBoxAdapter(
return SliverLazyToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1388,42 +1390,67 @@ class _FavoritePanel extends StatefulWidget {
State<_FavoritePanel> createState() => _FavoritePanelState();
}
class _FavoritePanelState extends State<_FavoritePanel> {
class _FavoritePanelState extends State<_FavoritePanel>
with SingleTickerProviderStateMixin {
late ComicSource comicSource;
late TabController tabController;
late bool hasNetwork;
@override
void initState() {
comicSource = widget.type.comicSource!;
localFolders = LocalFavoritesManager().folderNames;
added = LocalFavoritesManager().find(widget.cid, widget.type);
hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
var initIndex = 0;
if (appdata.implicitData['favoritePanelIndex'] is int) {
initIndex = appdata.implicitData['favoritePanelIndex'];
}
initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0);
tabController = TabController(
initialIndex: initIndex,
length: hasNetwork ? 2 : 1,
vsync: this,
);
super.initState();
}
@override
void dispose() {
var currentIndex = tabController.index;
appdata.implicitData['favoritePanelIndex'] = currentIndex;
appdata.writeImplicitData();
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
var hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
return Scaffold(
appBar: Appbar(
title: Text("Favorite".tl),
),
body: DefaultTabController(
length: hasNetwork ? 2 : 1,
child: Column(
children: [
TabBar(tabs: [
body: Column(
children: [
TabBar(
controller: tabController,
tabs: [
Tab(text: "Local".tl),
if (hasNetwork) Tab(text: "Network".tl),
]),
Expanded(
child: TabBarView(
children: [
buildLocal(),
if (hasNetwork) buildNetwork(),
],
),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
buildLocal(),
if (hasNetwork) buildNetwork(),
],
),
],
),
),
],
),
);
}
@@ -1850,7 +1877,7 @@ class _CommentsPartState extends State<_CommentsPart> {
Widget build(BuildContext context) {
return MultiSliver(
children: [
SliverToBoxAdapter(
SliverLazyToBoxAdapter(
child: ListTile(
title: Text("Comments".tl),
trailing: Row(

View File

@@ -42,7 +42,7 @@ class _CommentsPageState extends State<CommentsPage> {
_error = res.errorMessage;
_loading = false;
});
} else {
} else if (mounted) {
setState(() {
_comments = res.data;
_loading = false;

View File

@@ -16,6 +16,7 @@ import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';

View File

@@ -20,22 +20,35 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
var networkFolders = <String>[];
void findNetworkFolders() {
networkFolders.clear();
var all = ComicSource.all()
.where((e) => e.favoriteData != null)
.map((e) => e.favoriteData!.key)
.toList();
var settings = appdata.settings['favorites'] as List;
for (var p in settings) {
if (all.contains(p) && !networkFolders.contains(p)) {
networkFolders.add(p);
}
}
}
@override
void initState() {
favPage = widget.favPage ??
context.findAncestorStateOfType<_FavoritesPageState>()!;
favPage.folderList = this;
folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all()
.where((e) => e.favoriteData != null && e.isLogged)
.map((e) => e.favoriteData!.key)
.toList();
findNetworkFolders();
appdata.settings.addListener(updateFolders);
super.initState();
}
@override
void dispose() {
super.dispose();
appdata.settings.removeListener(updateFolders);
}
@override
@@ -102,7 +115,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
onClick: () {
newFolder().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
folders =
LocalFavoritesManager().folderNames;
});
});
},
@@ -113,7 +127,8 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
onClick: () {
sortFolders().then((value) {
setState(() {
folders = LocalFavoritesManager().folderNames;
folders =
LocalFavoritesManager().folderNames;
});
});
},
@@ -143,15 +158,24 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
),
child: Row(
children: [
const SizedBox(width: 16),
Icon(
Icons.cloud,
color: context.colorScheme.secondary,
),
const SizedBox(width: 12),
Text("Network".tl),
const Spacer(),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
showPopUpWidget(
App.rootContext,
setFavoritesPagesWidget(),
);
},
),
],
),
).paddingHorizontal(16),
);
}
index--;
@@ -241,10 +265,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
if (!mounted) return;
setState(() {
folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all()
.where((e) => e.favoriteData != null)
.map((e) => e.favoriteData!.key)
.toList();
findNetworkFolders();
});
}
}

View File

@@ -12,6 +12,7 @@ import 'package:venera/utils/epub.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart';
import 'package:zip_flutter/zip_flutter.dart';
class LocalComicsPage extends StatefulWidget {
const LocalComicsPage({super.key});
@@ -147,13 +148,13 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
text: "View Detail".tl,
onClick: () {
context.to(() => ComicPage(
id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey,
));
id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey,
));
},
),
if (selectedComics.length == 1)
...exportActions(selectedComics.keys.first),
if (selectedComics.isNotEmpty)
...exportActions(selectedComics.keys.toList()),
]);
}
@@ -322,7 +323,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
});
},
),
...exportActions(c as LocalComic),
...exportActions([c as LocalComic]),
];
},
),
@@ -390,79 +391,102 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return isDeleted;
}
List<MenuEntry> exportActions(LocalComic c) {
List<MenuEntry> exportActions(List<LocalComic> comics) {
return [
MenuEntry(
icon: Icons.outbox_outlined,
text: "Export as cbz".tl,
onClick: () async {
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
var file = await CBZ.export(c);
await saveFile(filename: file.name, file: file);
await file.delete();
} catch (e, s) {
context.showMessage(message: e.toString());
Log.error("CBZ Export", e, s);
}
controller.close();
}),
icon: Icons.outbox_outlined,
text: "Export as cbz".tl,
onClick: () {
exportComics(comics, CBZ.export, ".cbz");
},
),
MenuEntry(
icon: Icons.picture_as_pdf_outlined,
text: "Export as pdf".tl,
onClick: () async {
var cache = FilePath.join(App.cachePath, 'temp.pdf');
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
await createPdfFromComicIsolate(
comic: c,
savePath: cache,
);
await saveFile(
file: File(cache),
filename: "${c.title}.pdf",
);
} catch (e, s) {
Log.error("PDF Export", e, s);
context.showMessage(message: e.toString());
} finally {
controller.close();
File(cache).deleteIgnoreError();
}
exportComics(comics, createPdfFromComicIsolate, ".pdf");
},
),
MenuEntry(
icon: Icons.import_contacts_outlined,
text: "Export as epub".tl,
onClick: () async {
var controller = showLoadingDialog(
context,
allowCancel: false,
);
File? file;
try {
file = await createEpubWithLocalComic(
c,
);
await saveFile(
file: file,
filename: "${c.title}.epub",
);
} catch (e, s) {
Log.error("EPUB Export", e, s);
context.showMessage(message: e.toString());
} finally {
controller.close();
file?.deleteIgnoreError();
}
exportComics(comics, createEpubWithLocalComic, ".epub");
},
)
];
}
/// Export given comics to a file
void exportComics(
List<LocalComic> comics, ExportComicFunc export, String ext) async {
var current = 0;
var cacheDir = FilePath.join(App.cachePath, 'comics_export');
var outFile = FilePath.join(App.cachePath, 'comics_export.zip');
bool canceled = false;
if (Directory(cacheDir).existsSync()) {
Directory(cacheDir).deleteSync(recursive: true);
}
Directory(cacheDir).createSync();
var loadingController = showLoadingDialog(
context,
allowCancel: true,
message: "${"Exporting".tl} $current/${comics.length}",
withProgress: comics.length > 1,
onCancel: () {
canceled = true;
},
);
try {
var fileName = "";
// For each comic, export it to a file
for (var comic in comics) {
fileName = FilePath.join(cacheDir, sanitizeFileName(comic.title) + ext);
await export(comic, fileName);
current++;
if (comics.length > 1) {
loadingController
.setMessage("${"Exporting".tl} $current/${comics.length}");
loadingController.setProgress(current / comics.length);
}
if (canceled) {
return;
}
}
// For single comic, just save the file
if (comics.length == 1) {
await saveFile(
file: File(fileName),
filename: File(fileName).name,
);
Directory(cacheDir).deleteSync(recursive: true);
loadingController.close();
return;
}
// For multiple comics, compress the folder
loadingController.setProgress(null);
loadingController.setMessage("Compressing".tl);
await ZipFile.compressFolderAsync(cacheDir, outFile);
if (canceled) {
File(outFile).deleteIgnoreError();
return;
}
} catch (e, s) {
Log.error("Export Comics", e, s);
context.showMessage(message: e.toString());
loadingController.close();
return;
} finally {
Directory(cacheDir).deleteIgnoreError(recursive: true);
}
await saveFile(
file: File(outFile),
filename: "comics_export.zip",
);
loadingController.close();
File(outFile).deleteIgnoreError();
}
}
typedef ExportComicFunc = Future<File> Function(
LocalComic comic, String outFilePath);

View File

@@ -10,12 +10,14 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/aggregated_search_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'comic_page.dart';
import 'comic_source_page.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@@ -27,8 +29,13 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> {
late final SearchBarController controller;
late List<String> searchSources;
String searchTarget = "";
SearchPageData get currentSearchPageData =>
ComicSource.find(searchTarget)!.searchPageData!;
bool aggregatedSearch = false;
var focusNode = FocusNode();
@@ -139,29 +146,85 @@ class _SearchPageState extends State<SearchPage> {
@override
void initState() {
findSearchSources();
var defaultSearchTarget = appdata.settings['defaultSearchTarget'];
if (defaultSearchTarget == "_aggregated_") {
aggregatedSearch = true;
} else if (defaultSearchTarget != null &&
ComicSource.find(defaultSearchTarget) != null) {
searchSources.contains(defaultSearchTarget)) {
searchTarget = defaultSearchTarget;
} else {
searchTarget = ComicSource.all().first.key;
}
controller = SearchBarController(
onSearch: search,
);
appdata.settings.addListener(updateSearchSourcesIfNeeded);
super.initState();
}
@override
void dispose() {
focusNode.dispose();
appdata.settings.removeListener(updateSearchSourcesIfNeeded);
super.dispose();
}
void findSearchSources() {
var all = ComicSource.all()
.where((e) => e.searchPageData != null)
.map((e) => e.key)
.toList();
var settings = appdata.settings['searchSources'] as List;
var sources = <String>[];
for (var source in settings) {
if (all.contains(source)) {
sources.add(source);
}
}
searchSources = sources;
if (!searchSources.contains(searchTarget)) {
searchTarget = searchSources.firstOrNull ?? "";
}
}
void updateSearchSourcesIfNeeded() {
var old = searchSources;
findSearchSources();
if (old.isEqualsTo(searchSources)) {
return;
}
setState(() {});
}
void manageSearchSources() {
showPopUpWidget(App.rootContext, setSearchSourcesWidget());
}
Widget buildEmpty() {
var msg = "No Search Sources".tl;
msg += '\n';
VoidCallback onTap;
if (ComicSource.isEmpty) {
msg += "Please add some sources".tl;
onTap = () {
context.to(() => ComicSourcePage());
};
} else {
msg += "Please check your settings".tl;
onTap = manageSearchSources;
}
return NetworkError(
message: msg,
retry: onTap,
withAppbar: true,
buttonText: "Manage".tl,
);
}
@override
Widget build(BuildContext context) {
if (searchSources.isEmpty) {
return buildEmpty();
}
return Scaffold(
body: SmoothCustomScrollView(
slivers: buildSlivers().toList(),
@@ -190,8 +253,7 @@ class _SearchPageState extends State<SearchPage> {
}
Widget buildSearchTarget() {
var sources =
ComicSource.all().where((e) => e.searchPageData != null).toList();
var sources = searchSources.map((e) => ComicSource.find(e)!).toList();
return SliverToBoxAdapter(
child: Container(
width: double.infinity,
@@ -203,6 +265,10 @@ class _SearchPageState extends State<SearchPage> {
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.search),
title: Text("Search in".tl),
trailing: IconButton(
icon: const Icon(Icons.settings),
onPressed: manageSearchSources,
),
),
Wrap(
spacing: 8,
@@ -229,11 +295,6 @@ class _SearchPageState extends State<SearchPage> {
onChanged: (value) {
setState(() {
aggregatedSearch = value ?? false;
if (!aggregatedSearch &&
appdata.settings['defaultSearchTarget'] ==
"_aggregated_") {
searchTarget = sources.first.key;
}
});
},
),
@@ -245,9 +306,7 @@ class _SearchPageState extends State<SearchPage> {
}
void useDefaultOptions() {
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
final searchOptions = currentSearchPageData.searchOptions ?? [];
options = searchOptions.map((e) => e.defaultValue).toList();
}
@@ -258,9 +317,7 @@ class _SearchPageState extends State<SearchPage> {
var children = <Widget>[];
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
final searchOptions = currentSearchPageData.searchOptions ?? [];
if (searchOptions.length != options.length) {
useDefaultOptions();
}
@@ -394,7 +451,9 @@ class _SearchPageState extends State<SearchPage> {
Text(
subTitle,
style: TextStyle(
fontSize: 14, color: Theme.of(context).colorScheme.outline),
fontSize: 14,
color: Theme.of(context).colorScheme.outline,
),
)
],
),

View File

@@ -116,13 +116,13 @@ class _SearchResultPageState extends State<SearchResultPage> {
@override
void initState() {
sourceKey = widget.sourceKey;
text = checkAutoLanguage(widget.text);
controller = SearchBarController(
currentText: checkAutoLanguage(widget.text),
currentText: text,
onSearch: search,
);
options = widget.options ?? const [];
validateOptions();
text = widget.text;
appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller);
super.initState();

View File

@@ -38,19 +38,11 @@ class _ExploreSettingsState extends State<ExploreSettings> {
).toSliver(),
_PopupWindowSetting(
title: "Network Favorite Pages".tl,
builder: () {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.favoriteData != null) {
pages[c.favoriteData!.key] = c.favoriteData!.title;
}
}
return _MultiPagesFilter(
title: "Network Favorite Pages".tl,
settingsIndex: "favorites",
pages: pages,
);
},
builder: setFavoritesPagesWidget,
).toSliver(),
_PopupWindowSetting(
title: "Search Sources".tl,
builder: setSearchSourcesWidget,
).toSliver(),
_SwitchSetting(
title: "Show favorite status on comic tile".tl,
@@ -208,4 +200,32 @@ Widget setCategoryPagesWidget() {
settingsIndex: "categories",
pages: pages,
);
}
Widget setFavoritesPagesWidget() {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.favoriteData != null) {
pages[c.favoriteData!.key] = c.favoriteData!.title;
}
}
return _MultiPagesFilter(
title: "Network Favorite Pages".tl,
settingsIndex: "favorites",
pages: pages,
);
}
Widget setSearchSourcesWidget() {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.searchPageData != null) {
pages[c.key] = c.name;
}
}
return _MultiPagesFilter(
title: "Search Sources".tl,
settingsIndex: "searchSources",
pages: pages,
);
}

View File

@@ -85,6 +85,10 @@ abstract class CBZ {
if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync();
await extractArchive(file, cache);
var f = cache.listSync();
if (f.length == 1 && f.first is Directory) {
cache = f.first as Directory;
}
var metaDataFile = File(FilePath.join(cache.path, 'metadata.json'));
ComicMetaData? metaData;
if (metaDataFile.existsSync()) {
@@ -171,7 +175,7 @@ abstract class CBZ {
return comic;
}
static Future<File> export(LocalComic comic) async {
static Future<File> export(LocalComic comic, String outFilePath) async {
var cache = Directory(FilePath.join(App.cachePath, 'cbz_export'));
if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync();
@@ -230,7 +234,7 @@ abstract class CBZ {
).toJson(),
),
);
var cbz = File(FilePath.join(App.cachePath, sanitizeFileName('${comic.title}.cbz')));
var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync();
await _compress(cache.path, cbz.path);
cache.deleteSync(recursive: true);

View File

@@ -118,6 +118,7 @@ class DataSync with ChangeNotifier {
await client.remove(files.first.name!);
}
await client.write(filename, await data.readAsBytes());
data.deleteIgnoreError();
Log.info("Upload Data", "Data uploaded successfully");
return const Res(true);
} catch (e, s) {

View File

@@ -24,7 +24,8 @@ class EpubData {
});
}
Future<File> createEpubComic(EpubData data, String cacheDir) async {
Future<File> createEpubComic(
EpubData data, String cacheDir, String outFilePath) async {
final workingDir = Directory(FilePath.join(cacheDir, 'epub'));
if (workingDir.existsSync()) {
workingDir.deleteSync(recursive: true);
@@ -109,8 +110,7 @@ ${images.map((e) => ' <img src="$e" alt="$e"/>').join('\n')}
}
// content.opf
final contentOpf =
File(FilePath.join(workingDir.path, 'content.opf'));
final contentOpf = File(FilePath.join(workingDir.path, 'content.opf'));
final uuid = const Uuid().v4();
var spineStrBuilder = StringBuffer();
for (var i = 0; i < chapterIndex; i++) {
@@ -171,16 +171,15 @@ ${navMapStrBuilder.toString()}
</ncx>
''');
// zip
final zipPath = FilePath.join(cacheDir, '${data.title}.epub');
ZipFile.compressFolder(workingDir.path, zipPath);
ZipFile.compressFolder(workingDir.path, outFilePath);
workingDir.deleteSync(recursive: true);
return File(zipPath);
return File(outFilePath);
}
Future<File> createEpubWithLocalComic(LocalComic comic) async {
Future<File> createEpubWithLocalComic(
LocalComic comic, String outFilePath) async {
var chapters = <String, List<File>>{};
if (comic.chapters == null) {
chapters[comic.title] =
@@ -188,11 +187,11 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
.map((e) => File(e))
.toList();
} else {
for (var chapter in comic.chapters!.keys) {
chapters[comic.chapters![chapter]!] = (await LocalManager()
.getImages(comic.id, comic.comicType, chapter))
.map((e) => File(e))
.toList();
for (var chapter in comic.downloadedChapters) {
chapters[comic.chapters![chapter]!] =
(await LocalManager().getImages(comic.id, comic.comicType, chapter))
.map((e) => File(e))
.toList();
}
}
var data = EpubData(
@@ -205,6 +204,6 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
final cacheDir = App.cachePath;
return Isolate.run(() => overrideIO(() async {
return createEpubComic(data, cacheDir);
return createEpubComic(data, cacheDir, outFilePath);
}));
}

View File

@@ -35,19 +35,9 @@ class FilePath {
}
extension FileSystemEntityExt on FileSystemEntity {
/// Get the base name of the file or directory.
String get name {
var path = this.path;
if (path.endsWith('/') || path.endsWith('\\')) {
path = path.substring(0, path.length - 1);
}
int i = path.length - 1;
while (i >= 0 && path[i] != '\\' && path[i] != '/') {
i--;
}
return path.substring(i + 1);
return p.basename(path);
}
Future<void> deleteIgnoreError({bool recursive = false}) async {
@@ -83,6 +73,10 @@ extension FileExtension on File {
// Stream is not usable since [AndroidFile] does not support [openRead].
await newFile.writeAsBytes(await readAsBytes());
}
String get basenameWithoutExt {
return p.basenameWithoutExtension(path);
}
}
extension DirectoryExtension on Directory {

View File

@@ -30,14 +30,14 @@ Future<void> _createPdfFromComic({
files.removeWhere(
(element) => element is! File || element.path.startsWith('cover'));
files.sort((a, b) {
var aName = (a as File).name;
var bName = (b as File).name;
var aName = (a as File).basenameWithoutExt;
var bName = (b as File).basenameWithoutExt;
var aNumber = int.tryParse(aName);
var bNumber = int.tryParse(bName);
if (aNumber != null && bNumber != null) {
return aNumber.compareTo(bNumber);
}
return aName.compareTo(bName);
return a.name.compareTo(b.name);
});
}
@@ -49,7 +49,7 @@ Future<void> _createPdfFromComic({
images.add(file.path);
}
} else {
for (var chapter in comic.chapters!.keys) {
for (var chapter in comic.downloadedChapters) {
var files = Directory(FilePath.join(baseDir, chapter)).listSync();
reorderFiles(files);
for (var file in files) {
@@ -112,10 +112,7 @@ Future<Isolate> _runIsolate(
);
}
Future<void> createPdfFromComicIsolate({
required LocalComic comic,
required String savePath,
}) async {
Future<File> createPdfFromComicIsolate(LocalComic comic, String savePath) async {
var receivePort = ReceivePort();
SendPort? sendPort;
Isolate? isolate;
@@ -134,7 +131,8 @@ Future<void> createPdfFromComicIsolate({
}
});
isolate = await _runIsolate(comic, savePath, receivePort.sendPort);
return completer.future;
await completer.future;
return File(savePath);
}
class PdfGenerator {

View File

@@ -1100,4 +1100,4 @@ packages:
version: "0.0.10"
sdks:
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.3"
flutter: ">=3.27.4"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.2.3+123
version: 1.2.4+124
environment:
sdk: '>=3.6.0 <4.0.0'
flutter: 3.27.3
flutter: 3.27.4
dependencies:
flutter:
@@ -82,7 +82,7 @@ dev_dependencies:
sdk: flutter
flutter_lints: ^5.0.0
flutter_to_arch: ^1.0.1
flutter_to_debian:
flutter_to_debian: ^2.0.2
flutter:
uses-material-design: true

View File

@@ -3,11 +3,36 @@
#define MyAppName "Venera"
#define MyAppVersion "{{version}}"
#define MyAppPublisher "wgh136"
#define MyAppPublisher "nyne"
#define MyAppURL "https://github.com/venera-app/venera"
#define MyAppExeName "venera.exe"
#define RootPath "{{root_path}}"
[Code]
procedure CurStepChanged(CurStep: TSetupStep);
var
OldVersionPath, ShortcutPath: string;
begin
if CurStep = ssInstall then
begin
OldVersionPath := 'C:\Program Files (x86)\Venera';
if DirExists(OldVersionPath) then
begin
DelTree(OldVersionPath, True, True, True);
ShortcutPath := GetEnv('USERPROFILE') + '\Desktop\Venera.lnk';
if FileExists(ShortcutPath) then
begin
DeleteFile(ShortcutPath);
end;
ShortcutPath := 'C:\Users\Public\Desktop\Venera.lnk';
if FileExists(ShortcutPath) then
begin
DeleteFile(ShortcutPath);
end;
end;
end;
end;
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
@@ -30,6 +55,8 @@ SetupIconFile={#RootPath}\windows\runner\resources\app_icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
ArchitecturesAllowed=x64compatible
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"