38 Commits

Author SHA1 Message Date
nyne
8868a02a7e Merge pull request #183 from venera-app/v1.2.4-dev
V1.2.4
2025-02-09 19:59:26 +08:00
nyne
e1b95c9e23 Merge branch 'master' into v1.2.4-dev 2025-02-09 19:57:42 +08:00
0b65b4ab53 Update version code 2025-02-09 19:32:10 +08:00
df4263f969 Add ability to manage search sources. Close #174 2025-02-09 19:29:51 +08:00
17ef17ca5b Add a button for managing network folders. 2025-02-09 18:22:38 +08:00
nyne
e55c45a589 Support Linux arm64. Close #176 2025-02-09 15:11:46 +08:00
591f2836d4 Improve windows build script. 2025-02-09 13:45:30 +08:00
8ab4f7a34b Fix the issue where cache files are not deleted. 2025-02-09 11:38:19 +08:00
614c01872b Fix auto language filter. Close #171 2025-02-08 21:10:43 +08:00
6be258092a Remove confirmation prompt from deb. Close #177 2025-02-08 20:40:45 +08:00
ce50812857 Fix invalid image order when exporting comic as pdf. 2025-02-08 19:37:04 +08:00
f0b1135eb7 Allow batch export. Close #179 2025-02-08 18:23:49 +08:00
shenmo
cc0f070df5 Use Ubuntu 22.04 to run the workflow. (#178) 2025-02-07 19:19:39 +08:00
35429c132c Improve comic page performance 2025-02-07 18:15:36 +08:00
998d4c31d3 Improve importing comic: If the archive has only one directory, set working dir as it. 2025-02-07 17:32:51 +08:00
0122bb8f28 fix windows font 2025-02-07 17:28:03 +08:00
33a9fa062b flutter 3.27.4 2025-02-07 17:19:26 +08:00
13081332f2 Improve tags display 2025-02-07 17:19:04 +08:00
Pacalini
cdc6c95579 pre-search: enable suggestions for EN (#175) 2025-02-07 17:16:41 +08:00
buste
3aca3baafc Fix ensure searchTarget is properly initialized for aggregatedSearch mode (#173)
Set searchTarget = defaultSearchTarget when aggregatedSearch is enabled, ensuring correct initialization and preventing missing suggestions on first input.

Without this fix, when opening the search page for the first time with aggregatedSearch enabled by default, entering an ID that matches a comic source does not trigger the "Open comic" suggestion. However, after toggling aggregatedSearch off and then back on, the same ID input correctly displays the suggestion.
2025-02-07 17:03:52 +08:00
58d6ccdde1 Fix an issue where an application turns to a white screen after finishing cloudflare verification. Close #169 2025-02-05 21:21:20 +08:00
23404b86f6 Record the last state of the favorite pane. 2025-02-05 20:40:14 +08:00
UjuiUjuMandan
965187e9de replace raw.githubusercontent.com 2025-02-05 20:21:15 +08:00
nyne
24155746f2 Merge pull request #166 from venera-app/dev
v1.2.3
2025-02-01 16:35:34 +08:00
340496da30 Fix cloudflare bypass 2025-02-01 16:24:43 +08:00
28a56b4612 Update version code 2025-02-01 15:56:57 +08:00
4e6f71ef36 Merge account page and comic source page. 2025-02-01 15:54:52 +08:00
739685f60f Fix crash when using cbz export on iOS and macOS.
Close #164
2025-02-01 10:11:34 +08:00
8c5dae1e59 Fix empty page.
Close #160
2025-01-31 13:27:22 +08:00
e2c69d882f Fix image order.
Close #159
2025-01-31 13:11:04 +08:00
0b9f0b7d35 Improve downloading message.
Close #165
2025-01-31 13:08:24 +08:00
9ea749a84a login with webview on windows and linux.
fix #162, fix #141
2025-01-31 11:53:06 +08:00
d675af3fb4 fix cloudflare verification 2025-01-31 10:46:24 +08:00
d99a30b7d8 Update desktop file 2025-01-30 17:49:01 +08:00
nyne
3c3c07b6fb fix #163 2025-01-28 17:04:13 +08:00
nyne
e688ab759a Merge pull request #161 from UjuiUjuMandan/debug
move out applicationVariants.all
2025-01-27 16:34:18 +08:00
UjuiUjuMandan
64a3ef352f move out applicationVariants.all 2025-01-27 07:04:15 +00:00
ef8dc9e8d4 fix #158 2025-01-26 18:36:35 +08:00
45 changed files with 1417 additions and 1086 deletions

View File

@@ -39,12 +39,18 @@ jobs:
ln -s /Applications dist/dmg_contents/Applications ln -s /Applications dist/dmg_contents/Applications
hdiutil create -volname "venera" -srcfolder dist/dmg_contents -ov -format UDZO "dist/venera.dmg" 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) # Step 4: Attach and upload artifacts (optional)
- name: Upload DMG - name: Upload DMG
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: venera.dmg name: macos_build
path: dist/venera.dmg path: result/
Build_IOS: Build_IOS:
runs-on: macos-15 runs-on: macos-15
steps: steps:
@@ -62,12 +68,17 @@ jobs:
mv /Users/runner/work/venera/venera/build/ios/iphoneos/Runner.app /Users/runner/work/venera/venera/build/ios/iphoneos/Payload 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/ cd /Users/runner/work/venera/venera/build/ios/iphoneos/
zip -r venera-ios.ipa Payload 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 - uses: actions/upload-artifact@v4
with: with:
name: app-ios.ipa name: ios_build
path: /Users/runner/work/venera/venera/build/ios/iphoneos/venera-ios.ipa path: result/
Build_Android: Build_Android:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
@@ -118,7 +129,7 @@ jobs:
name: windows_build name: windows_build
path: build/windows/Venera-* path: build/windows/Venera-*
Build_Linux: Build_Linux:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: subosito/flutter-action@v2 - uses: subosito/flutter-action@v2
@@ -130,7 +141,7 @@ 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
- run: python3 debian/build.py - run: python3 debian/build.py x64
- run: dart run flutter_to_arch - run: dart run flutter_to_arch
- run: | - run: |
sudo rm -rf build/linux/arch/app.tar.gz sudo rm -rf build/linux/arch/app.tar.gz
@@ -145,19 +156,43 @@ jobs:
with: with:
name: arch_build name: arch_build
path: build/linux/arch/ 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: Release:
runs-on: ubuntu-latest 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 事件时执行 if: github.event_name == 'release' # 仅在 push 事件时执行
steps: steps:
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: venera.dmg name: macos_build
path: outputs path: outputs
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
name: app-ios.ipa name: ios_build
path: outputs path: outputs
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
with: with:
@@ -175,6 +210,10 @@ jobs:
with: with:
name: arch_build name: arch_build
path: outputs path: outputs
- uses: actions/download-artifact@v4
with:
name: deb_arm64_build
path: outputs
- uses: softprops/action-gh-release@v2 - uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}

View File

@@ -1,5 +1,4 @@
# venera # venera
[![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/) [![flutter](https://img.shields.io/badge/flutter-3.27.1-blue)](https://flutter.dev/)
[![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE) [![License](https://img.shields.io/github/license/venera-app/venera)](https://github.com/venera-app/venera/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases) [![Download](https://img.shields.io/github/v/release/venera-app/venera)](https://github.com/venera-app/venera/releases)
@@ -13,7 +12,6 @@ A comic reader that support reading local and network comics.
height="75">](https://f-droid.org/packages/com.github.wgh136.venera/) height="75">](https://f-droid.org/packages/com.github.wgh136.venera/)
## Features ## Features
- Read local comics - Read local comics
- Use javascript to create comic sources - Use javascript to create comic sources
- Read comics from network sources - Read comics from network sources
@@ -23,14 +21,12 @@ A comic reader that support reading local and network comics.
- Login to comment, rate, and other operations if the source supports - Login to comment, rate, and other operations if the source supports
## Build from source ## Build from source
1. Clone the repository 1. Clone the repository
2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install) 2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install)
3. Install rust, see [rustup.rs](https://rustup.rs/) 3. Install rust, see [rustup.rs](https://rustup.rs/)
4. Build for your platform: e.g. `flutter build apk` 4. Build for your platform: e.g. `flutter build apk`
## Create a new comic source ## Create a new comic source
See [Comic Source](doc/comic_source.md) See [Comic Source](doc/comic_source.md)
## Thanks ## Thanks

View File

@@ -83,31 +83,31 @@ android {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64" abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
} }
signingConfig signingConfigs.release signingConfig signingConfigs.release
applicationVariants.all { variant ->
variant.outputs.all { output ->
def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (abi != null) {
outputFileName = "venera-${variant.versionName}-${abi}.apk"
def abiVersionCode = project.ext.abiCodes.get(abi)
if (abiVersionCode != null) {
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
} else {
outputFileName = "venera-${variant.versionName}.apk"
versionCodeOverride = variant.versionCode * 10
}
}
}
} }
debug { debug {
ndk { ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64" abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
} }
signingConfig signingConfigs.debug signingConfig signingConfigs.debug
applicationVariants.all { variant -> }
variant.outputs.all { output -> }
versionCodeOverride = variant.versionCode * 10 + 4
applicationVariants.all { variant ->
variant.outputs.all { output ->
def abi = output.getFilter(com.android.build.OutputFile.ABI)
if (variant.buildType.name == "release") {
if (abi != null) {
outputFileName = "venera-${variant.versionName}-${abi}.apk"
def abiVersionCode = project.ext.abiCodes.get(abi)
if (abiVersionCode != null) {
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
}
} else {
outputFileName = "venera-${variant.versionName}.apk"
versionCodeOverride = variant.versionCode * 10
} }
} else if (variant.buildType.name == "debug") {
versionCodeOverride = variant.versionCode * 10 + 4
} }
} }
} }

View File

@@ -318,7 +318,13 @@
"Deselect All": "取消全选", "Deselect All": "取消全选",
"Add keyword": "添加关键词", "Add keyword": "添加关键词",
"Keyword": "关键词", "Keyword": "关键词",
"Manage": "管理" "Manage": "管理",
"Verify": "验证",
"Cloudflare verification required": "需要Cloudflare验证",
"Success": "成功",
"Compressing": "压缩中",
"Exporting": "导出中",
"Search Sources": "搜索源"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -639,6 +645,12 @@
"Deselect All": "取消全選", "Deselect All": "取消全選",
"Add keyword": "添加關鍵詞", "Add keyword": "添加關鍵詞",
"Keyword": "關鍵詞", "Keyword": "關鍵詞",
"Manage": "管理" "Manage": "管理",
"Verify": "驗證",
"Cloudflare verification required": "需要Cloudflare驗證",
"Success": "成功",
"Compressing": "壓縮中",
"Exporting": "匯出中",
"Search Sources": "搜索源"
} }
} }

11
debian/build.py vendored
View File

@@ -1,5 +1,7 @@
import subprocess import subprocess
import sys
arch = sys.argv[1]
debianContent = '' debianContent = ''
desktopContent = '' desktopContent = ''
version = '' version = ''
@@ -12,7 +14,14 @@ with open('pubspec.yaml', 'r') as f:
version = str.split(str.split(f.read(), 'version: ')[1], '+')[0] version = str.split(str.split(f.read(), 'version: ')[1], '+')[0]
with open('debian/debian.yaml', 'w') as f: 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: with open('debian/gui/venera.desktop', 'w') as f:
f.write(desktopContent.replace('{{Version}}', version)) f.write(desktopContent.replace('{{Version}}', version))

6
debian/debian.yaml vendored
View File

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

View File

@@ -6,3 +6,4 @@ Terminal=false
Type=Application Type=Application
Categories=Utility Categories=Utility
Keywords=Flutter;comic;images; Keywords=Flutter;comic;images;
Icon=venera

View File

@@ -550,7 +550,7 @@ class _ComicDescription extends StatelessWidget {
int cnt = (constraints.maxHeight - 22).toInt() ~/ 25; int cnt = (constraints.maxHeight - 22).toInt() ~/ 25;
return Container( return Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
height: 22 + cnt * 25, height: 21 + cnt * 24,
width: double.infinity, width: double.infinity,
decoration: const BoxDecoration(), decoration: const BoxDecoration(),
child: Wrap( child: Wrap(
@@ -562,31 +562,30 @@ class _ComicDescription extends StatelessWidget {
children: [ children: [
for (var s in tags!) for (var s in tags!)
Container( Container(
height: 22, height: 21,
padding: const EdgeInsets.fromLTRB(3, 2, 3, 2), padding: const EdgeInsets.symmetric(horizontal: 4),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.45, 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); ).toAlign(Alignment.topCenter);
@@ -1520,14 +1519,15 @@ class SimpleComicTile extends StatelessWidget {
return AnimatedTapRegion( return AnimatedTapRegion(
borderRadius: 8, borderRadius: 8,
onTap: onTap ?? () { onTap: onTap ??
context.to( () {
() => ComicPage( context.to(
id: comic.id, () => ComicPage(
sourceKey: comic.sourceKey, id: comic.id,
), sourceKey: comic.sourceKey,
); ),
}, );
},
child: Container( child: Container(
width: 92, width: 92,
height: 114, height: 114,

View File

@@ -148,3 +148,18 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
return false; 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

@@ -57,7 +57,9 @@ class NetworkError extends StatelessWidget {
if (cfe != null) if (cfe != null)
FilledButton( FilledButton(
onPressed: () => passCloudflare( onPressed: () => passCloudflare(
CloudflareException.fromString(message)!, retry!), CloudflareException.fromString(message)!,
retry!,
),
child: Text('Verify'.tl), child: Text('Verify'.tl),
) )
else else
@@ -130,7 +132,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
if (res.success) { if (res.success) {
return res; return res;
} else { } else {
if(!mounted) return res; if (!mounted) return res;
if (retry >= 3) { if (retry >= 3) {
return res; return res;
} }
@@ -188,7 +190,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
isLoading = true; isLoading = true;
Future.microtask(() { Future.microtask(() {
loadDataWithRetry().then((value) async { loadDataWithRetry().then((value) async {
if(!mounted) return; if (!mounted) return;
if (value.success) { if (value.success) {
data = value.data; data = value.data;
await onDataLoaded(); await onDataLoaded();
@@ -321,21 +323,11 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
} }
Widget buildError(BuildContext context, String error) { Widget buildError(BuildContext context, String error) {
return Center( return NetworkError(
child: Column( withAppbar: false,
mainAxisSize: MainAxisSize.min, message: error,
children: [ retry: reset,
Text(error, maxLines: 3), );
const SizedBox(height: 12),
Button.outlined(
onPressed: () {
reset();
},
child: const Text("Retry"),
)
],
),
).paddingHorizontal(16);
} }
@override @override

View File

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

View File

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

View File

@@ -126,6 +126,7 @@ class _Settings with ChangeNotifier {
'explore_pages': [], 'explore_pages': [],
'categories': [], 'categories': [],
'favorites': [], 'favorites': [],
'searchSources': null,
'showFavoriteStatusOnTile': true, 'showFavoriteStatusOnTile': true,
'showHistoryStatusOnTile': false, 'showHistoryStatusOnTile': false,
'blockedWords': [], 'blockedWords': [],
@@ -155,7 +156,7 @@ class _Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing, 'customImageProcessing': defaultCustomImageProcessing,
'sni': true, 'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json", 'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
}; };
operator [](String key) { operator [](String key) {

View File

@@ -42,11 +42,16 @@ Future<void> init() async {
await ComicSource.init().wait(); await ComicSource.init().wait();
await LocalManager().init().wait(); await LocalManager().init().wait();
CacheManager().setLimitSize(appdata.settings['cacheSize']); 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) { if (App.isAndroid) {
handleLinks(); handleLinks();
} }
FlutterError.onError = (details) { FlutterError.onError = (details) {
Log.error( Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
"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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget home; Widget home;
@@ -158,24 +190,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
return MaterialApp( return MaterialApp(
home: home, home: home,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: getTheme(primary, secondary, tertiary, Brightness.light),
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
tones: FlexTones.vividBackground(Brightness.light),
),
),
navigatorKey: App.rootNavigatorKey, navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData( darkTheme: getTheme(primary, secondary, tertiary, Brightness.dark),
colorScheme: SeedColorScheme.fromSeeds(
primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
brightness: Brightness.dark,
tones: FlexTones.vividBackground(Brightness.dark),
),
),
themeMode: switch (appdata.settings['theme_mode']) { themeMode: switch (appdata.settings['theme_mode']) {
'light' => ThemeMode.light, 'light' => ThemeMode.light,
'dark' => ThemeMode.dark, 'dark' => ThemeMode.dark,

View File

@@ -1,9 +1,11 @@
import 'dart:io' as io; import 'dart:io' as io;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/webview.dart'; import 'package:venera/pages/webview.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
@@ -58,7 +60,7 @@ class CloudflareException implements DioException {
class CloudflareInterceptor extends Interceptor { class CloudflareInterceptor extends Interceptor {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if(options.headers['cookie'].toString().contains('cf_clearance')) { if (options.headers['cookie'].toString().contains('cf_clearance')) {
options.headers['user-agent'] = appdata.implicitData['ua'] ?? webUA; options.headers['user-agent'] = appdata.implicitData['ua'] ?? webUA;
} }
handler.next(options); handler.next(options);
@@ -120,16 +122,25 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
var webview = DesktopWebview( var webview = DesktopWebview(
initialUrl: url, initialUrl: url,
onTitleChange: (title, controller) async { onTitleChange: (title, controller) async {
var res = await controller.evaluateJavascript( var head =
"document.head.innerHTML.includes('#challenge-success-text')"); await controller.evaluateJavascript("document.head.innerHTML") ??
if (res == 'false') { "";
Log.info("Cloudflare", "Checking head: $head");
var isChallenging = head.contains('#challenge-success-text') ||
head.contains("#challenge-error-text") ||
head.contains("#challenge-form");
if (!isChallenging) {
Log.info(
"Cloudflare",
"Cloudflare is passed due to there is no challenge css",
);
var ua = controller.userAgent; var ua = controller.userAgent;
if (ua != null) { if (ua != null) {
appdata.implicitData['ua'] = ua; appdata.implicitData['ua'] = ua;
appdata.writeImplicitData(); appdata.writeImplicitData();
} }
var cookiesMap = await controller.getCookies(url); var cookiesMap = await controller.getCookies(url);
if(cookiesMap['cf_clearance'] == null) { if (cookiesMap['cf_clearance'] == null) {
return; return;
} }
saveCookies(cookiesMap); saveCookies(cookiesMap);
@@ -137,30 +148,51 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
onFinished(); onFinished();
} }
}, },
onClose: onFinished,
); );
webview.open(); webview.open();
} else { } else {
bool success = false;
void check(InAppWebViewController controller) async {
var head = await controller.evaluateJavascript(
source: "document.head.innerHTML") as String;
Log.info("Cloudflare", "Checking head: $head");
var isChallenging = head.contains('#challenge-success-text') ||
head.contains("#challenge-error-text") ||
head.contains("#challenge-form");
if (!isChallenging) {
Log.info(
"Cloudflare",
"Cloudflare is passed due to there is no challenge css",
);
var ua = await controller.getUA();
if (ua != null) {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookies = await controller.getCookies(url) ?? [];
if (cookies.firstWhereOrNull(
(element) => element.name == 'cf_clearance') ==
null) {
return;
}
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
if (!success) {
App.rootPop();
success = true;
}
}
}
await App.rootContext.to( await App.rootContext.to(
() => AppWebview( () => AppWebview(
initialUrl: url, initialUrl: url,
singlePage: true, singlePage: true,
onTitleChange: (title, controller) async {
check(controller);
},
onLoadStop: (controller) async { onLoadStop: (controller) async {
var res = await controller.platform.evaluateJavascript( check(controller);
source:
"document.head.innerHTML.includes('#challenge-success-text')");
if (res == false) {
var ua = await controller.getUA();
if (ua != null) {
appdata.implicitData['ua'] = ua;
appdata.writeImplicitData();
}
var cookies = await controller.getCookies(url) ?? [];
if(cookies.firstWhereOrNull((element) => element.name == 'cf_clearance') == null) {
return;
}
SingleInstanceCookieJar.instance?.saveFromResponse(uri, cookies);
App.rootPop();
}
}, },
onStarted: (controller) async { onStarted: (controller) async {
var ua = await controller.getUA(); var ua = await controller.getUA();

View File

@@ -59,6 +59,16 @@ abstract class DownloadTask with ChangeNotifier {
return null; return null;
} }
} }
@override
bool operator ==(Object other) {
return other is DownloadTask &&
other.id == id &&
other.comicType == comicType;
}
@override
int get hashCode => Object.hash(id, comicType);
} }
class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
@@ -220,7 +230,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
runRecorder(); runRecorder();
if (comic == null) { if (comic == null) {
var res = await runWithRetry(() async { _message = "Fetching comic info...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicInfo!(comicId); var r = await source.loadComicInfo!(comicId);
if (r.error) { if (r.error) {
throw r.errorMessage!; throw r.errorMessage!;
@@ -260,7 +272,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
await LocalManager().saveCurrentDownloadingTasks(); await LocalManager().saveCurrentDownloadingTasks();
if (_cover == null) { if (_cover == null) {
var res = await runWithRetry(() async { _message = "Downloading cover...";
notifyListeners();
var res = await _runWithRetry(() async {
Uint8List? data; Uint8List? data;
await for (var progress await for (var progress
in ImageDownloader.loadThumbnail(comic!.cover, source.key)) { in ImageDownloader.loadThumbnail(comic!.cover, source.key)) {
@@ -272,8 +286,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
throw "Failed to download cover"; throw "Failed to download cover";
} }
var fileType = detectFileType(data); var fileType = detectFileType(data);
var file = var file = File(FilePath.join(path!, "cover${fileType.ext}"));
File(FilePath.join(path!, "cover${fileType.ext}"));
file.writeAsBytesSync(data); file.writeAsBytesSync(data);
return "file://${file.path}"; return "file://${file.path}";
}); });
@@ -290,7 +303,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (_images == null) { if (_images == null) {
if (comic!.chapters == null) { if (comic!.chapters == null) {
var res = await runWithRetry(() async { _message = "Fetching image list...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicPages!(comicId, null); var r = await source.loadComicPages!(comicId, null);
if (r.error) { if (r.error) {
throw r.errorMessage!; throw r.errorMessage!;
@@ -312,6 +327,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} else { } else {
_images = {}; _images = {};
_totalCount = 0; _totalCount = 0;
int cpCount = 0;
int totalCpCount = chapters?.length ?? comic!.chapters!.length;
for (var i in comic!.chapters!.keys) { for (var i in comic!.chapters!.keys) {
if (chapters != null && !chapters!.contains(i)) { if (chapters != null && !chapters!.contains(i)) {
continue; continue;
@@ -320,7 +337,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_totalCount += _images![i]!.length; _totalCount += _images![i]!.length;
continue; continue;
} }
var res = await runWithRetry(() async { _message = "Fetching image list ($cpCount/$totalCpCount)...";
notifyListeners();
var res = await _runWithRetry(() async {
var r = await source.loadComicPages!(comicId, i); var r = await source.loadComicPages!(comicId, i);
if (r.error) { if (r.error) {
throw r.errorMessage!; throw r.errorMessage!;
@@ -458,8 +477,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}).toList(), }).toList(),
directory: Directory(path!).name, directory: Directory(path!).name,
chapters: comic!.chapters, chapters: comic!.chapters,
cover: cover: File(_cover!.split("file://").last).name,
File(_cover!.split("file://").last).name,
comicType: ComicType(source.key.hashCode), comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [], downloadedChapters: chapters ?? [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
@@ -478,7 +496,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
int get hashCode => Object.hash(comicId, source.key); int get hashCode => Object.hash(comicId, source.key);
} }
Future<Res<T>> runWithRetry<T>(Future<T> Function() task, Future<Res<T>> _runWithRetry<T>(Future<T> Function() task,
{int retry = 3}) async { {int retry = 3}) async {
for (var i = 0; i < retry; i++) { for (var i = 0; i < retry; i++) {
try { try {

View File

@@ -1,349 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/webview.dart';
import 'package:venera/utils/translations.dart';
class AccountsPageLogic extends StateController {
final _reLogin = <String, bool>{};
}
class AccountsPage extends StatelessWidget {
const AccountsPage({super.key});
AccountsPageLogic get logic => StateController.find<AccountsPageLogic>();
@override
Widget build(BuildContext context) {
var body = StateBuilder<AccountsPageLogic>(
init: AccountsPageLogic(),
builder: (logic) {
return CustomScrollView(
slivers: [
SliverAppbar(title: Text("Accounts".tl)),
SliverList(
delegate: SliverChildListDelegate(
buildContent(context).toList(),
),
),
SliverPadding(
padding: EdgeInsets.only(bottom: context.padding.bottom),
)
],
);
},
);
return Scaffold(
body: body,
);
}
Iterable<Widget> buildContent(BuildContext context) sync* {
var sources = ComicSource.all().where((element) => element.account != null);
if (sources.isEmpty) return;
for (var element in sources) {
final bool logged = element.isLogged;
yield Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
element.name,
style: const TextStyle(fontSize: 16),
),
);
if (!logged) {
yield ListTile(
title: Text("Log in".tl),
trailing: const Icon(Icons.arrow_right),
onTap: () async {
await context.to(
() => _LoginPage(
config: element.account!,
source: element,
),
);
element.saveData();
ComicSource.notifyListeners();
logic.update();
},
);
}
if (logged) {
for (var item in element.account!.infoItems) {
if (item.builder != null) {
yield item.builder!(context);
} else {
yield ListTile(
title: Text(item.title.tl),
subtitle: item.data == null ? null : Text(item.data!()),
onTap: item.onTap,
);
}
}
if (element.data["account"] is List) {
bool loading = logic._reLogin[element.key] == true;
yield ListTile(
title: Text("Re-login".tl),
subtitle: Text("Click if login expired".tl),
onTap: () async {
if (element.data["account"] == null) {
context.showMessage(message: "No data".tl);
return;
}
logic._reLogin[element.key] = true;
logic.update();
final List account = element.data["account"];
var res = await element.account!.login!(account[0], account[1]);
if (res.error) {
context.showMessage(message: res.errorMessage!);
} else {
context.showMessage(message: "Success".tl);
}
logic._reLogin[element.key] = false;
logic.update();
},
trailing: loading
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh),
);
}
yield ListTile(
title: Text("Log out".tl),
onTap: () {
element.data["account"] = null;
element.account?.logout();
element.saveData();
ComicSource.notifyListeners();
logic.update();
},
trailing: const Icon(Icons.logout),
);
}
yield const Divider(thickness: 0.6);
}
}
void setClipboard(String text) {
Clipboard.setData(ClipboardData(text: text));
showToast(
message: "Copied".tl,
icon: const Icon(Icons.check),
context: App.rootContext,
);
}
}
class _LoginPage extends StatefulWidget {
const _LoginPage({required this.config, required this.source});
final AccountConfig config;
final ComicSource source;
@override
State<_LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<_LoginPage> {
String username = "";
String password = "";
bool loading = false;
final Map<String, String> _cookies = {};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const Appbar(
title: Text(''),
),
body: Center(
child: Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Login".tl, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
if (widget.config.cookieFields == null)
TextField(
decoration: InputDecoration(
labelText: "Username".tl,
border: const OutlineInputBorder(),
),
enabled: widget.config.login != null,
onChanged: (s) {
username = s;
},
autofillHints: const [AutofillHints.username],
).paddingBottom(16),
if (widget.config.cookieFields == null)
TextField(
decoration: InputDecoration(
labelText: "Password".tl,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.login != null,
onChanged: (s) {
password = s;
},
onSubmitted: (s) => login(),
autofillHints: const [AutofillHints.password],
).paddingBottom(16),
for (var field in widget.config.cookieFields ?? <String>[])
TextField(
decoration: InputDecoration(
labelText: field,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.validateCookies != null,
onChanged: (s) {
_cookies[field] = s;
},
).paddingBottom(16),
if (widget.config.login == null &&
widget.config.cookieFields == null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Text("Login with password is disabled".tl),
],
)
else
Button.filled(
isLoading: loading,
onPressed: login,
child: Text("Continue".tl),
),
const SizedBox(height: 24),
if (widget.config.loginWebsite != null)
TextButton(
onPressed: loginWithWebview,
child: Text("Login with webview".tl),
),
const SizedBox(height: 8),
if (widget.config.registerWebsite != null)
TextButton(
onPressed: () =>
launchUrlString(widget.config.registerWebsite!),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.link),
const SizedBox(width: 8),
Text("Create Account".tl),
],
),
),
],
),
),
),
),
);
}
void login() {
if (widget.config.login != null) {
if (username.isEmpty || password.isEmpty) {
showToast(
message: "Cannot be empty".tl,
icon: const Icon(Icons.error_outline),
context: context,
);
return;
}
setState(() {
loading = true;
});
widget.config.login!(username, password).then((value) {
if (value.error) {
context.showMessage(message: value.errorMessage!);
setState(() {
loading = false;
});
} else {
if (mounted) {
context.pop();
}
}
});
} else if (widget.config.validateCookies != null) {
setState(() {
loading = true;
});
var cookies =
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
widget.config.validateCookies!(cookies).then((value) {
if (value) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
} else {
context.showMessage(message: "Invalid cookies".tl);
setState(() {
loading = false;
});
}
});
}
}
void loginWithWebview() async {
var url = widget.config.loginWebsite!;
var title = '';
bool success = false;
void validate(InAppWebViewController c) async {
if (widget.config.checkLoginStatus != null
&& widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
widget.config.onLoginWithWebviewSuccess?.call();
App.mainNavigatorKey?.currentContext?.pop();
}
}
await context.to(
() => AppWebview(
initialUrl: widget.config.loginWebsite!,
onNavigation: (u, c) {
url = u;
validate(c);
return false;
},
onTitleChange: (t, c) {
title = t;
validate(c);
},
),
);
if (success) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
}
}
}

View File

@@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import 'package:shimmer_animation/shimmer_animation.dart'; import 'package:shimmer_animation/shimmer_animation.dart';
import "package:venera/components/components.dart"; import "package:venera/components/components.dart";
import "package:venera/foundation/app.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/foundation/comic_source/comic_source.dart";
import "package:venera/pages/search_result_page.dart"; import "package:venera/pages/search_result_page.dart";
import "package:venera/utils/translations.dart"; import "package:venera/utils/translations.dart";
@@ -24,7 +25,18 @@ class _AggregatedSearchPageState extends State<AggregatedSearchPage> {
@override @override
void initState() { 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; _keyword = widget.keyword;
controller = SearchBarController( controller = SearchBarController(
currentText: widget.keyword, currentText: widget.keyword,

View File

@@ -206,62 +206,64 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
yield const SliverPadding(padding: EdgeInsets.only(top: 8)); yield const SliverPadding(padding: EdgeInsets.only(top: 8));
yield Row( yield SliverLazyToBoxAdapter(
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 16), children: [
Hero( const SizedBox(width: 16),
tag: "cover${comic.id}${comic.sourceKey}", Hero(
child: Container( tag: "cover${comic.id}${comic.sourceKey}",
decoration: BoxDecoration( child: Container(
color: context.colorScheme.primaryContainer, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color: context.colorScheme.primaryContainer,
boxShadow: [ borderRadius: BorderRadius.circular(8),
BoxShadow( boxShadow: [
color: context.colorScheme.outlineVariant, BoxShadow(
blurRadius: 1, color: context.colorScheme.outlineVariant,
offset: const Offset(0, 1), 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() { Widget buildActions() {
bool isMobile = context.width < changePoint; bool isMobile = context.width < changePoint;
bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1); bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1);
return SliverToBoxAdapter( return SliverLazyToBoxAdapter(
child: Column( child: Column(
children: [ children: [
ListView( ListView(
@@ -354,7 +356,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
if (comic.description == null || comic.description!.trim().isEmpty) { if (comic.description == null || comic.description!.trim().isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero); return const SliverPadding(padding: EdgeInsets.zero);
} }
return SliverToBoxAdapter( return SliverLazyToBoxAdapter(
child: Column( child: Column(
children: [ children: [
ListTile( ListTile(
@@ -482,7 +484,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
bool enableTranslation = bool enableTranslation =
App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate; App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate;
return SliverToBoxAdapter( return SliverLazyToBoxAdapter(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1388,42 +1390,67 @@ class _FavoritePanel extends StatefulWidget {
State<_FavoritePanel> createState() => _FavoritePanelState(); State<_FavoritePanel> createState() => _FavoritePanelState();
} }
class _FavoritePanelState extends State<_FavoritePanel> { class _FavoritePanelState extends State<_FavoritePanel>
with SingleTickerProviderStateMixin {
late ComicSource comicSource; late ComicSource comicSource;
late TabController tabController;
late bool hasNetwork;
@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;
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) {
var hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
return Scaffold( return Scaffold(
appBar: Appbar( appBar: Appbar(
title: Text("Favorite".tl), title: Text("Favorite".tl),
), ),
body: DefaultTabController( body: Column(
length: hasNetwork ? 2 : 1, children: [
child: Column( TabBar(
children: [ controller: tabController,
TabBar(tabs: [ tabs: [
Tab(text: "Local".tl), Tab(text: "Local".tl),
if (hasNetwork) Tab(text: "Network".tl), if (hasNetwork) Tab(text: "Network".tl),
]), ],
Expanded( ),
child: TabBarView( Expanded(
children: [ child: TabBarView(
buildLocal(), controller: tabController,
if (hasNetwork) buildNetwork(), children: [
], buildLocal(),
), if (hasNetwork) buildNetwork(),
],
), ),
], ),
), ],
), ),
); );
} }
@@ -1850,7 +1877,7 @@ class _CommentsPartState extends State<_CommentsPart> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiSliver( return MultiSliver(
children: [ children: [
SliverToBoxAdapter( SliverLazyToBoxAdapter(
child: ListTile( child: ListTile(
title: Text("Comments".tl), title: Text("Comments".tl),
trailing: Row( trailing: Row(

View File

@@ -1,5 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io' as io;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -7,11 +9,13 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/webview.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
class ComicSourcePage extends StatefulWidget { class ComicSourcePage extends StatelessWidget {
const ComicSourcePage({super.key}); const ComicSourcePage({super.key});
static Future<int> checkComicSourceUpdate() async { static Future<int> checkComicSourceUpdate() async {
@@ -44,11 +48,6 @@ class ComicSourcePage extends StatefulWidget {
return shouldUpdate.length; return shouldUpdate.length;
} }
@override
State<ComicSourcePage> createState() => _ComicSourcePageState();
}
class _ComicSourcePageState extends State<ComicSourcePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -92,167 +91,19 @@ class _BodyState extends State<_Body> {
style: AppbarStyle.shadow, style: AppbarStyle.shadow,
), ),
buildCard(context), buildCard(context),
for (var source in ComicSource.all()) buildSource(context, source), for (var source in ComicSource.all())
_SliverComicSource(
key: ValueKey(source.key),
source: source,
edit: edit,
update: update,
delete: delete,
),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
], ],
); );
} }
Widget buildSource(BuildContext context, ComicSource source) {
var newVersion = ComicSource.availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
return SliverToBoxAdapter(
child: Column(
children: [
const Divider(),
ListTile(
title: Row(
children: [
Text(source.name),
const SizedBox(width: 6),
if (hasUpdate)
Tooltip(
message: newVersion,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
"New Version".tl,
style: const TextStyle(fontSize: 13),
),
),
)
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: "Edit".tl,
child: IconButton(
onPressed: () => edit(source),
icon: const Icon(Icons.edit_note)),
),
Tooltip(
message: "Update".tl,
child: IconButton(
onPressed: () => update(source),
icon: const Icon(Icons.update)),
),
Tooltip(
message: "Delete".tl,
child: IconButton(
onPressed: () => delete(source),
icon: const Icon(Icons.delete)),
),
],
),
),
ListTile(
title: const Text("Version"),
subtitle: Text(source.version),
),
...buildSourceSettings(source),
],
),
);
}
Iterable<Widget> buildSourceSettings(ComicSource source) sync* {
if (source.settings == null) {
return;
} else if (source.data['settings'] == null) {
source.data['settings'] = {};
}
for (var item in source.settings!.entries) {
var key = item.key;
String type = item.value['type'];
try {
if (type == "select") {
var current = source.data['settings'][key];
if (current == null) {
var d = item.value['default'];
for (var option in item.value['options']) {
if (option['value'] == d) {
current = option['text'] ?? option['value'];
break;
}
}
} else {
current = item.value['options']
.firstWhere((e) => e['value'] == current)['text'] ??
current;
}
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Select(
current: (current as String).ts(source.key),
values: (item.value['options'] as List)
.map<String>((e) =>
((e['text'] ?? e['value']) as String).ts(source.key))
.toList(),
onTap: (i) {
source.data['settings'][key] =
item.value['options'][i]['value'];
source.saveData();
setState(() {});
},
),
);
} else if (type == "switch") {
var current = source.data['settings'][key] ?? item.value['default'];
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Switch(
value: current,
onChanged: (v) {
source.data['settings'][key] = v;
source.saveData();
setState(() {});
},
),
);
} else if (type == "input") {
var current =
source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
subtitle:
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
showInputDialog(
context: context,
title: (item.value['title'] as String).ts(source.key),
initialValue: current,
inputValidator: item.value['validator'] == null
? null
: RegExp(item.value['validator']),
onConfirm: (value) {
source.data['settings'][key] = value;
source.saveData();
setState(() {});
return null;
},
);
},
),
);
} else if (type == "callback") {
yield _CallbackSetting(setting: item, sourceKey: source.key);
}
} catch (e, s) {
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
}
}
}
void delete(ComicSource source) { void delete(ComicSource source) {
showConfirmDialog( showConfirmDialog(
context: App.rootContext, context: App.rootContext,
@@ -297,10 +148,12 @@ class _BodyState extends State<_Body> {
// //
} }
} }
context.to(() => _EditFilePage(source.filePath, () async { context.to(
await ComicSource.reload(); () => _EditFilePage(source.filePath, () async {
setState(() {}); await ComicSource.reload();
})); setState(() {});
}),
);
} }
static Future<void> update(ComicSource source) async { static Future<void> update(ComicSource source) async {
@@ -764,3 +617,566 @@ class _CallbackSettingState extends State<_CallbackSetting> {
); );
} }
} }
class _SliverComicSource extends StatefulWidget {
const _SliverComicSource({
super.key,
required this.source,
required this.edit,
required this.update,
required this.delete,
});
final ComicSource source;
final void Function(ComicSource source) edit;
final void Function(ComicSource source) update;
final void Function(ComicSource source) delete;
@override
State<_SliverComicSource> createState() => _SliverComicSourceState();
}
class _SliverComicSourceState extends State<_SliverComicSource> {
ComicSource get source => widget.source;
@override
Widget build(BuildContext context) {
var newVersion = ComicSource.availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
return SliverMainAxisGroup(
slivers: [
SliverPadding(padding: const EdgeInsets.only(top: 16)),
SliverToBoxAdapter(
child: ListTile(
title: Row(
children: [
Text(
source.name,
style: ts.s18,
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
source.version,
style: const TextStyle(fontSize: 13),
),
),
if (hasUpdate)
Tooltip(
message: newVersion,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
"New Version".tl,
style: const TextStyle(fontSize: 13),
),
),
).paddingLeft(4)
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: "Edit".tl,
child: IconButton(
onPressed: () => widget.edit(source),
icon: const Icon(Icons.edit_note),
),
),
Tooltip(
message: "Update".tl,
child: IconButton(
onPressed: () => widget.update(source),
icon: const Icon(Icons.update),
),
),
Tooltip(
message: "Delete".tl,
child: IconButton(
onPressed: () => widget.delete(source),
icon: const Icon(Icons.delete),
),
),
],
),
),
),
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
),
),
SliverToBoxAdapter(
child: Column(
children: buildSourceSettings().toList(),
),
),
SliverToBoxAdapter(
child: Column(
children: _buildAccount().toList(),
),
),
],
);
}
Iterable<Widget> buildSourceSettings() sync* {
if (source.settings == null) {
return;
} else if (source.data['settings'] == null) {
source.data['settings'] = {};
}
for (var item in source.settings!.entries) {
var key = item.key;
String type = item.value['type'];
try {
if (type == "select") {
var current = source.data['settings'][key];
if (current == null) {
var d = item.value['default'];
for (var option in item.value['options']) {
if (option['value'] == d) {
current = option['text'] ?? option['value'];
break;
}
}
} else {
current = item.value['options']
.firstWhere((e) => e['value'] == current)['text'] ??
current;
}
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Select(
current: (current as String).ts(source.key),
values: (item.value['options'] as List)
.map<String>((e) =>
((e['text'] ?? e['value']) as String).ts(source.key))
.toList(),
onTap: (i) {
source.data['settings'][key] =
item.value['options'][i]['value'];
source.saveData();
setState(() {});
},
),
);
} else if (type == "switch") {
var current = source.data['settings'][key] ?? item.value['default'];
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Switch(
value: current,
onChanged: (v) {
source.data['settings'][key] = v;
source.saveData();
setState(() {});
},
),
);
} else if (type == "input") {
var current =
source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
subtitle:
Text(current, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
showInputDialog(
context: context,
title: (item.value['title'] as String).ts(source.key),
initialValue: current,
inputValidator: item.value['validator'] == null
? null
: RegExp(item.value['validator']),
onConfirm: (value) {
source.data['settings'][key] = value;
source.saveData();
setState(() {});
return null;
},
);
},
),
);
} else if (type == "callback") {
yield _CallbackSetting(setting: item, sourceKey: source.key);
}
} catch (e, s) {
Log.error("ComicSourcePage", "Failed to build a setting\n$e\n$s");
}
}
}
final _reLogin = <String, bool>{};
Iterable<Widget> _buildAccount() sync* {
if (source.account == null) return;
final bool logged = source.isLogged;
if (!logged) {
yield ListTile(
title: Text("Log in".tl),
trailing: const Icon(Icons.arrow_right),
onTap: () async {
await context.to(
() => _LoginPage(
config: source.account!,
source: source,
),
);
source.saveData();
setState(() {});
},
);
}
if (logged) {
for (var item in source.account!.infoItems) {
if (item.builder != null) {
yield item.builder!(context);
} else {
yield ListTile(
title: Text(item.title.tl),
subtitle: item.data == null ? null : Text(item.data!()),
onTap: item.onTap,
);
}
}
if (source.data["account"] is List) {
bool loading = _reLogin[source.key] == true;
yield ListTile(
title: Text("Re-login".tl),
subtitle: Text("Click if login expired".tl),
onTap: () async {
if (source.data["account"] == null) {
context.showMessage(message: "No data".tl);
return;
}
setState(() {
_reLogin[source.key] = true;
});
final List account = source.data["account"];
var res = await source.account!.login!(account[0], account[1]);
if (res.error) {
context.showMessage(message: res.errorMessage!);
} else {
context.showMessage(message: "Success".tl);
}
setState(() {
_reLogin[source.key] = false;
});
},
trailing: loading
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh),
);
}
yield ListTile(
title: Text("Log out".tl),
onTap: () {
source.data["account"] = null;
source.account?.logout();
source.saveData();
ComicSource.notifyListeners();
setState(() {});
},
trailing: const Icon(Icons.logout),
);
}
}
}
class _LoginPage extends StatefulWidget {
const _LoginPage({required this.config, required this.source});
final AccountConfig config;
final ComicSource source;
@override
State<_LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<_LoginPage> {
String username = "";
String password = "";
bool loading = false;
final Map<String, String> _cookies = {};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const Appbar(
title: Text(''),
),
body: Center(
child: Container(
padding: const EdgeInsets.all(16),
constraints: const BoxConstraints(maxWidth: 400),
child: AutofillGroup(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("Login".tl, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32),
if (widget.config.cookieFields == null)
TextField(
decoration: InputDecoration(
labelText: "Username".tl,
border: const OutlineInputBorder(),
),
enabled: widget.config.login != null,
onChanged: (s) {
username = s;
},
autofillHints: const [AutofillHints.username],
).paddingBottom(16),
if (widget.config.cookieFields == null)
TextField(
decoration: InputDecoration(
labelText: "Password".tl,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.login != null,
onChanged: (s) {
password = s;
},
onSubmitted: (s) => login(),
autofillHints: const [AutofillHints.password],
).paddingBottom(16),
for (var field in widget.config.cookieFields ?? <String>[])
TextField(
decoration: InputDecoration(
labelText: field,
border: const OutlineInputBorder(),
),
obscureText: true,
enabled: widget.config.validateCookies != null,
onChanged: (s) {
_cookies[field] = s;
},
).paddingBottom(16),
if (widget.config.login == null &&
widget.config.cookieFields == null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Text("Login with password is disabled".tl),
],
)
else
Button.filled(
isLoading: loading,
onPressed: login,
child: Text("Continue".tl),
),
const SizedBox(height: 24),
if (widget.config.loginWebsite != null)
TextButton(
onPressed: () {
if (App.isWindows || App.isLinux) {
loginWithWebview2();
} else {
loginWithWebview();
}
},
child: Text("Login with webview".tl),
),
const SizedBox(height: 8),
if (widget.config.registerWebsite != null)
TextButton(
onPressed: () =>
launchUrlString(widget.config.registerWebsite!),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.link),
const SizedBox(width: 8),
Text("Create Account".tl),
],
),
),
],
),
),
),
),
);
}
void login() {
if (widget.config.login != null) {
if (username.isEmpty || password.isEmpty) {
showToast(
message: "Cannot be empty".tl,
icon: const Icon(Icons.error_outline),
context: context,
);
return;
}
setState(() {
loading = true;
});
widget.config.login!(username, password).then((value) {
if (value.error) {
context.showMessage(message: value.errorMessage!);
setState(() {
loading = false;
});
} else {
if (mounted) {
context.pop();
}
}
});
} else if (widget.config.validateCookies != null) {
setState(() {
loading = true;
});
var cookies =
widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList();
widget.config.validateCookies!(cookies).then((value) {
if (value) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
} else {
context.showMessage(message: "Invalid cookies".tl);
setState(() {
loading = false;
});
}
});
}
}
void loginWithWebview() async {
var url = widget.config.loginWebsite!;
var title = '';
bool success = false;
void validate(InAppWebViewController c) async {
if (widget.config.checkLoginStatus != null &&
widget.config.checkLoginStatus!(url, title)) {
var cookies = (await c.getCookies(url)) ?? [];
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
widget.config.onLoginWithWebviewSuccess?.call();
App.mainNavigatorKey?.currentContext?.pop();
}
}
await context.to(
() => AppWebview(
initialUrl: widget.config.loginWebsite!,
onNavigation: (u, c) {
url = u;
validate(c);
return false;
},
onTitleChange: (t, c) {
title = t;
validate(c);
},
),
);
if (success) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
}
}
// for windows and linux
void loginWithWebview2() async {
if (!await DesktopWebview.isAvailable()) {
context.showMessage(message: "Webview is not available".tl);
}
var url = widget.config.loginWebsite!;
var title = '';
bool success = false;
void onClose() {
if (success) {
widget.source.data['account'] = 'ok';
widget.source.saveData();
context.pop();
}
}
void validate(DesktopWebview webview) async {
if (widget.config.checkLoginStatus != null &&
widget.config.checkLoginStatus!(url, title)) {
var cookiesMap = await webview.getCookies(url);
var cookies = <io.Cookie>[];
cookiesMap.forEach((key, value) {
cookies.add(io.Cookie(key, value));
});
SingleInstanceCookieJar.instance?.saveFromResponse(
Uri.parse(url),
cookies,
);
success = true;
widget.config.onLoginWithWebviewSuccess?.call();
webview.close();
onClose();
}
}
var webview = DesktopWebview(
initialUrl: widget.config.loginWebsite!,
onTitleChange: (t, webview) {
title = t;
validate(webview);
},
onNavigation: (u, webview) {
url = u;
validate(webview);
},
onClose: onClose,
);
webview.open();
}
}

View File

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

View File

@@ -46,6 +46,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
i--; i--;
return _DownloadTaskTile( return _DownloadTaskTile(
key: ValueKey(LocalManager().downloadingTasks[i]),
task: LocalManager().downloadingTasks[i], task: LocalManager().downloadingTasks[i],
); );
}, },
@@ -120,7 +121,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
} }
class _DownloadTaskTile extends StatefulWidget { class _DownloadTaskTile extends StatefulWidget {
const _DownloadTaskTile({required this.task}); const _DownloadTaskTile({required this.task, super.key});
final DownloadTask task; final DownloadTask task;
@@ -129,20 +130,33 @@ class _DownloadTaskTile extends StatefulWidget {
} }
class _DownloadTaskTileState extends State<_DownloadTaskTile> { class _DownloadTaskTileState extends State<_DownloadTaskTile> {
late DownloadTask task;
@override @override
void initState() { void initState() {
widget.task.addListener(update); task = widget.task;
task.addListener(update);
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
widget.task.removeListener(update); task.removeListener(update);
super.dispose(); super.dispose();
} }
@override
void didUpdateWidget(covariant _DownloadTaskTile oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.task != widget.task) {
task.removeListener(update);
task = widget.task;
task.addListener(update);
}
}
void update() { void update() {
context.findAncestorStateOfType<_DownloadingPageState>()?.update(); setState(() {});
} }
@override @override

View File

@@ -16,6 +16,7 @@ import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/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/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';

View File

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

View File

@@ -9,7 +9,6 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/downloading_page.dart';
@@ -36,7 +35,6 @@ class HomePage extends StatelessWidget {
const _History(), const _History(),
const _Local(), const _Local(),
const _ComicSourceWidget(), const _ComicSourceWidget(),
const _AccountsWidget(),
const ImageFavorites(), const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
], ],
@@ -698,115 +696,6 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
} }
} }
class _AccountsWidget extends StatefulWidget {
const _AccountsWidget();
@override
State<_AccountsWidget> createState() => _AccountsWidgetState();
}
class _AccountsWidgetState extends State<_AccountsWidget> {
late List<String> accounts;
void onComicSourceChange() {
setState(() {
accounts.clear();
for (var c in ComicSource.all()) {
if (c.isLogged) {
accounts.add(c.name);
}
}
});
}
@override
void initState() {
accounts = [];
for (var c in ComicSource.all()) {
if (c.isLogged) {
accounts.add(c.name);
}
}
ComicSource.addListener(onComicSourceChange);
super.initState();
}
@override
void dispose() {
ComicSource.removeListener(onComicSourceChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
context.to(() => const AccountsPage());
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 56,
child: Row(
children: [
Center(
child: Text('Accounts'.tl, style: ts.s18),
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(accounts.length.toString(), style: ts.s12),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
),
).paddingHorizontal(16),
SizedBox(
width: double.infinity,
child: Wrap(
runSpacing: 8,
spacing: 8,
children: accounts.map((e) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(e),
);
}).toList(),
).paddingHorizontal(16).paddingBottom(16),
),
],
),
),
),
);
}
}
class _AnimatedDownloadingIcon extends StatefulWidget { class _AnimatedDownloadingIcon extends StatefulWidget {
const _AnimatedDownloadingIcon(); const _AnimatedDownloadingIcon();

View File

@@ -12,6 +12,7 @@ import 'package:venera/utils/epub.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart'; import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:zip_flutter/zip_flutter.dart';
class LocalComicsPage extends StatefulWidget { class LocalComicsPage extends StatefulWidget {
const LocalComicsPage({super.key}); const LocalComicsPage({super.key});
@@ -147,13 +148,13 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
text: "View Detail".tl, text: "View Detail".tl,
onClick: () { onClick: () {
context.to(() => ComicPage( context.to(() => ComicPage(
id: selectedComics.keys.first.id, id: selectedComics.keys.first.id,
sourceKey: selectedComics.keys.first.sourceKey, sourceKey: selectedComics.keys.first.sourceKey,
)); ));
}, },
), ),
if (selectedComics.length == 1) if (selectedComics.isNotEmpty)
...exportActions(selectedComics.keys.first), ...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; return isDeleted;
} }
List<MenuEntry> exportActions(LocalComic c) { List<MenuEntry> exportActions(List<LocalComic> comics) {
return [ return [
MenuEntry( MenuEntry(
icon: Icons.outbox_outlined, icon: Icons.outbox_outlined,
text: "Export as cbz".tl, text: "Export as cbz".tl,
onClick: () async { onClick: () {
var controller = showLoadingDialog( exportComics(comics, CBZ.export, ".cbz");
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();
}),
MenuEntry( MenuEntry(
icon: Icons.picture_as_pdf_outlined, icon: Icons.picture_as_pdf_outlined,
text: "Export as pdf".tl, text: "Export as pdf".tl,
onClick: () async { onClick: () async {
var cache = FilePath.join(App.cachePath, 'temp.pdf'); exportComics(comics, createPdfFromComicIsolate, ".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();
}
}, },
), ),
MenuEntry( MenuEntry(
icon: Icons.import_contacts_outlined, icon: Icons.import_contacts_outlined,
text: "Export as epub".tl, text: "Export as epub".tl,
onClick: () async { onClick: () async {
var controller = showLoadingDialog( exportComics(comics, createEpubWithLocalComic, ".epub");
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();
}
}, },
) )
]; ];
} }
/// 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

@@ -111,9 +111,7 @@ class _GalleryModeState extends State<_GalleryMode>
late _ReaderState reader; late _ReaderState reader;
int get totalPages => ((reader.images!.length + reader.imagesPerPage - 1) / int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
reader.imagesPerPage)
.ceil();
@override @override
void initState() { void initState() {
@@ -228,6 +226,8 @@ class _GalleryModeState extends State<_GalleryMode>
? Axis.vertical ? Axis.vertical
: Axis.horizontal; : Axis.horizontal;
bool reverse = reader.mode == ReaderMode.galleryRightToLeft;
List<Widget> imageWidgets = images.map((imageKey) { List<Widget> imageWidgets = images.map((imageKey) {
ImageProvider imageProvider = ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context); _createImageProviderFromKey(imageKey, context);
@@ -239,6 +239,10 @@ class _GalleryModeState extends State<_GalleryMode>
); );
}).toList(); }).toList();
if (reverse) {
imageWidgets = imageWidgets.reversed.toList();
}
return axis == Axis.vertical return axis == Axis.vertical
? Column(children: imageWidgets) ? Column(children: imageWidgets)
: Row(children: imageWidgets); : Row(children: imageWidgets);

View File

@@ -98,8 +98,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} }
@override @override
int get maxPage => int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
((images?.length ?? 1) + imagesPerPage - 1) ~/ imagesPerPage;
ComicType get type => widget.type; ComicType get type => widget.type;

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/foundation/state_controller.dart';
import 'package:venera/pages/aggregated_search_page.dart'; import 'package:venera/pages/aggregated_search_page.dart';
import 'package:venera/pages/search_result_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/app_links.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.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';
import 'comic_page.dart'; import 'comic_page.dart';
import 'comic_source_page.dart';
class SearchPage extends StatefulWidget { class SearchPage extends StatefulWidget {
const SearchPage({super.key}); const SearchPage({super.key});
@@ -27,8 +29,13 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> { class _SearchPageState extends State<SearchPage> {
late final SearchBarController controller; late final SearchBarController controller;
late List<String> searchSources;
String searchTarget = ""; String searchTarget = "";
SearchPageData get currentSearchPageData =>
ComicSource.find(searchTarget)!.searchPageData!;
bool aggregatedSearch = false; bool aggregatedSearch = false;
var focusNode = FocusNode(); var focusNode = FocusNode();
@@ -139,29 +146,85 @@ class _SearchPageState extends State<SearchPage> {
@override @override
void initState() { void initState() {
findSearchSources();
var defaultSearchTarget = appdata.settings['defaultSearchTarget']; var defaultSearchTarget = appdata.settings['defaultSearchTarget'];
if (defaultSearchTarget == "_aggregated_") { if (defaultSearchTarget == "_aggregated_") {
aggregatedSearch = true; aggregatedSearch = true;
} else if (defaultSearchTarget != null && } else if (defaultSearchTarget != null &&
ComicSource.find(defaultSearchTarget) != null) { searchSources.contains(defaultSearchTarget)) {
searchTarget = defaultSearchTarget; searchTarget = defaultSearchTarget;
} else {
searchTarget = ComicSource.all().first.key;
} }
controller = SearchBarController( controller = SearchBarController(
onSearch: search, onSearch: search,
); );
appdata.settings.addListener(updateSearchSourcesIfNeeded);
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
focusNode.dispose(); focusNode.dispose();
appdata.settings.removeListener(updateSearchSourcesIfNeeded);
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (searchSources.isEmpty) {
return buildEmpty();
}
return Scaffold( return Scaffold(
body: SmoothCustomScrollView( body: SmoothCustomScrollView(
slivers: buildSlivers().toList(), slivers: buildSlivers().toList(),
@@ -190,8 +253,7 @@ class _SearchPageState extends State<SearchPage> {
} }
Widget buildSearchTarget() { Widget buildSearchTarget() {
var sources = var sources = searchSources.map((e) => ComicSource.find(e)!).toList();
ComicSource.all().where((e) => e.searchPageData != null).toList();
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Container( child: Container(
width: double.infinity, width: double.infinity,
@@ -203,6 +265,10 @@ class _SearchPageState extends State<SearchPage> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.search), leading: const Icon(Icons.search),
title: Text("Search in".tl), title: Text("Search in".tl),
trailing: IconButton(
icon: const Icon(Icons.settings),
onPressed: manageSearchSources,
),
), ),
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -229,11 +295,6 @@ class _SearchPageState extends State<SearchPage> {
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
aggregatedSearch = value ?? false; aggregatedSearch = value ?? false;
if (!aggregatedSearch &&
appdata.settings['defaultSearchTarget'] ==
"_aggregated_") {
searchTarget = sources.first.key;
}
}); });
}, },
), ),
@@ -245,9 +306,7 @@ class _SearchPageState extends State<SearchPage> {
} }
void useDefaultOptions() { void useDefaultOptions() {
final searchOptions = final searchOptions = currentSearchPageData.searchOptions ?? [];
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
options = searchOptions.map((e) => e.defaultValue).toList(); options = searchOptions.map((e) => e.defaultValue).toList();
} }
@@ -258,9 +317,7 @@ class _SearchPageState extends State<SearchPage> {
var children = <Widget>[]; var children = <Widget>[];
final searchOptions = final searchOptions = currentSearchPageData.searchOptions ?? [];
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
if (searchOptions.length != options.length) { if (searchOptions.length != options.length) {
useDefaultOptions(); useDefaultOptions();
} }
@@ -394,7 +451,9 @@ class _SearchPageState extends State<SearchPage> {
Text( Text(
subTitle, subTitle,
style: TextStyle( 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 @override
void initState() { void initState() {
sourceKey = widget.sourceKey; sourceKey = widget.sourceKey;
text = checkAutoLanguage(widget.text);
controller = SearchBarController( controller = SearchBarController(
currentText: checkAutoLanguage(widget.text), currentText: text,
onSearch: search, onSearch: search,
); );
options = widget.options ?? const []; options = widget.options ?? const [];
validateOptions(); validateOptions();
text = widget.text;
appdata.addSearchHistory(text); appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller); suggestionsController = _SuggestionsController(controller);
super.initState(); super.initState();

View File

@@ -86,7 +86,7 @@ class _AboutSettingsState extends State<AboutSettings> {
Future<bool> checkUpdate() async { Future<bool> checkUpdate() async {
var res = await AppDio().get( var res = await AppDio().get(
"https://raw.githubusercontent.com/venera-app/venera/refs/heads/master/pubspec.yaml"); "https://cdn.jsdelivr.net/gh/venera-app/venera@latest/pubspec.yaml");
if (res.statusCode == 200) { if (res.statusCode == 200) {
var data = loadYaml(res.data); var data = loadYaml(res.data);
if (data["version"] != null) { if (data["version"] != null) {

View File

@@ -38,19 +38,11 @@ class _ExploreSettingsState extends State<ExploreSettings> {
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Network Favorite Pages".tl, title: "Network Favorite Pages".tl,
builder: () { builder: setFavoritesPagesWidget,
var pages = <String, String>{}; ).toSliver(),
for (var c in ComicSource.all()) { _PopupWindowSetting(
if (c.favoriteData != null) { title: "Search Sources".tl,
pages[c.favoriteData!.key] = c.favoriteData!.title; builder: setSearchSourcesWidget,
}
}
return _MultiPagesFilter(
title: "Network Favorite Pages".tl,
settingsIndex: "favorites",
pages: pages,
);
},
).toSliver(), ).toSliver(),
_SwitchSetting( _SwitchSetting(
title: "Show favorite status on comic tile".tl, title: "Show favorite status on comic tile".tl,
@@ -209,3 +201,31 @@ Widget setCategoryPagesWidget() {
pages: pages, 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

@@ -206,37 +206,41 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
], ],
); );
} else { } else {
return Stack( return LayoutBuilder(
children: [ builder: (context, constrains) {
Positioned.fill(child: buildLeft()), return Stack(
Positioned( children: [
left: offset, Positioned.fill(child: buildLeft()),
width: MediaQuery.of(context).size.width, Positioned(
top: 0, left: offset,
bottom: 0, width: constrains.maxWidth,
child: Listener( top: 0,
onPointerDown: handlePointerDown, bottom: 0,
child: AnimatedSwitcher( child: Listener(
duration: const Duration(milliseconds: 200), onPointerDown: handlePointerDown,
switchInCurve: Curves.fastOutSlowIn, child: AnimatedSwitcher(
switchOutCurve: Curves.fastOutSlowIn, duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) { switchInCurve: Curves.fastOutSlowIn,
var tween = Tween<Offset>( switchOutCurve: Curves.fastOutSlowIn,
begin: const Offset(1, 0), end: const Offset(0, 0)); transitionBuilder: (child, animation) {
var tween = Tween<Offset>(
begin: const Offset(1, 0), end: const Offset(0, 0));
return SlideTransition( return SlideTransition(
position: tween.animate(animation), position: tween.animate(animation),
child: child, child: child,
); );
}, },
child: Material( child: Material(
key: ValueKey(currentPage), key: ValueKey(currentPage),
child: buildRight(), child: buildRight(),
),
),
), ),
), )
), ],
) );
], },
); );
} }
} }

View File

@@ -303,7 +303,10 @@ class DesktopWebview {
proxy: AppDio.proxy, proxy: AppDio.proxy,
)); ));
_webview!.addOnWebMessageReceivedCallback(onMessage); _webview!.addOnWebMessageReceivedCallback(onMessage);
_webview!.setOnNavigation((s) => onNavigation?.call(s, this)); _webview!.setOnNavigation((s) {
s = s.substring(1, s.length - 1);
return onNavigation?.call(s, this);
});
_webview!.launch(initialUrl, triggerOnUrlRequestEvent: false); _webview!.launch(initialUrl, triggerOnUrlRequestEvent: false);
_runTimer(); _runTimer();
_webview!.onClose.then((value) { _webview!.onClose.then((value) {

View File

@@ -85,6 +85,10 @@ abstract class CBZ {
if (cache.existsSync()) cache.deleteSync(recursive: true); if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync(); cache.createSync();
await extractArchive(file, cache); 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')); var metaDataFile = File(FilePath.join(cache.path, 'metadata.json'));
ComicMetaData? metaData; ComicMetaData? metaData;
if (metaDataFile.existsSync()) { if (metaDataFile.existsSync()) {
@@ -171,7 +175,7 @@ abstract class CBZ {
return comic; 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')); var cache = Directory(FilePath.join(App.cachePath, 'cbz_export'));
if (cache.existsSync()) cache.deleteSync(recursive: true); if (cache.existsSync()) cache.deleteSync(recursive: true);
cache.createSync(); cache.createSync();
@@ -230,7 +234,7 @@ abstract class CBZ {
).toJson(), ).toJson(),
), ),
); );
var cbz = File(FilePath.join(App.cachePath, sanitizeFileName('${comic.title}.cbz'))); var cbz = File(outFilePath);
if (cbz.existsSync()) cbz.deleteSync(); if (cbz.existsSync()) cbz.deleteSync();
await _compress(cache.path, cbz.path); await _compress(cache.path, cbz.path);
cache.deleteSync(recursive: true); cache.deleteSync(recursive: true);

View File

@@ -118,6 +118,7 @@ class DataSync with ChangeNotifier {
await client.remove(files.first.name!); await client.remove(files.first.name!);
} }
await client.write(filename, await data.readAsBytes()); await client.write(filename, await data.readAsBytes());
data.deleteIgnoreError();
Log.info("Upload Data", "Data uploaded successfully"); Log.info("Upload Data", "Data uploaded successfully");
return const Res(true); return const Res(true);
} catch (e, s) { } 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')); final workingDir = Directory(FilePath.join(cacheDir, 'epub'));
if (workingDir.existsSync()) { if (workingDir.existsSync()) {
workingDir.deleteSync(recursive: true); workingDir.deleteSync(recursive: true);
@@ -109,8 +110,7 @@ ${images.map((e) => ' <img src="$e" alt="$e"/>').join('\n')}
} }
// content.opf // content.opf
final contentOpf = final contentOpf = File(FilePath.join(workingDir.path, 'content.opf'));
File(FilePath.join(workingDir.path, 'content.opf'));
final uuid = const Uuid().v4(); final uuid = const Uuid().v4();
var spineStrBuilder = StringBuffer(); var spineStrBuilder = StringBuffer();
for (var i = 0; i < chapterIndex; i++) { for (var i = 0; i < chapterIndex; i++) {
@@ -171,16 +171,15 @@ ${navMapStrBuilder.toString()}
</ncx> </ncx>
'''); ''');
// zip ZipFile.compressFolder(workingDir.path, outFilePath);
final zipPath = FilePath.join(cacheDir, '${data.title}.epub');
ZipFile.compressFolder(workingDir.path, zipPath);
workingDir.deleteSync(recursive: true); 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>>{}; var chapters = <String, List<File>>{};
if (comic.chapters == null) { if (comic.chapters == null) {
chapters[comic.title] = chapters[comic.title] =
@@ -188,11 +187,11 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
.map((e) => File(e)) .map((e) => File(e))
.toList(); .toList();
} else { } else {
for (var chapter in comic.chapters!.keys) { for (var chapter in comic.downloadedChapters) {
chapters[comic.chapters![chapter]!] = (await LocalManager() chapters[comic.chapters![chapter]!] =
.getImages(comic.id, comic.comicType, chapter)) (await LocalManager().getImages(comic.id, comic.comicType, chapter))
.map((e) => File(e)) .map((e) => File(e))
.toList(); .toList();
} }
} }
var data = EpubData( var data = EpubData(
@@ -205,6 +204,6 @@ Future<File> createEpubWithLocalComic(LocalComic comic) async {
final cacheDir = App.cachePath; final cacheDir = App.cachePath;
return Isolate.run(() => overrideIO(() async { 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 { extension FileSystemEntityExt on FileSystemEntity {
/// Get the base name of the file or directory.
String get name { String get name {
var path = this.path; return p.basename(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);
} }
Future<void> deleteIgnoreError({bool recursive = false}) async { 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]. // Stream is not usable since [AndroidFile] does not support [openRead].
await newFile.writeAsBytes(await readAsBytes()); await newFile.writeAsBytes(await readAsBytes());
} }
String get basenameWithoutExt {
return p.basenameWithoutExtension(path);
}
} }
extension DirectoryExtension on Directory { extension DirectoryExtension on Directory {

View File

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

View File

@@ -15,9 +15,6 @@ extension TagsTranslation on String{
static final Map<String, Map<String, String>> _data = {}; static final Map<String, Map<String, String>> _data = {};
static Future<void> readData() async{ static Future<void> readData() async{
if(App.locale.languageCode != "zh"){
return;
}
var fileName = App.locale.countryCode == 'TW' var fileName = App.locale.countryCode == 'TW'
? "assets/tags_tw.json" ? "assets/tags_tw.json"
: "assets/tags.json"; : "assets/tags.json";

View File

@@ -1083,21 +1083,21 @@ packages:
source: hosted source: hosted
version: "6.5.0" version: "6.5.0"
yaml: yaml:
dependency: transitive dependency: "direct main"
description: description:
name: yaml name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.3"
zip_flutter: zip_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: zip_flutter name: zip_flutter
sha256: fe63ef9098bb2426b001adba2e28029820d71ce80cce957a36676bd6b3227245 sha256: bbf3160062610a43901b7ebbc6f6dd46519540f03a84027dc7b1fff399dda1ac
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.9" version: "0.0.10"
sdks: sdks:
dart: ">=3.6.0 <4.0.0" 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." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.2.2+122 version: 1.2.4+124
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.6.0 <4.0.0'
flutter: 3.27.3 flutter: 3.27.4
dependencies: dependencies:
flutter: flutter:
@@ -48,7 +48,7 @@ dependencies:
sliver_tools: ^0.2.12 sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2 flutter_file_dialog: ^3.0.2
file_selector: ^1.0.3 file_selector: ^1.0.3
zip_flutter: ^0.0.9 zip_flutter: ^0.0.10
lodepng_flutter: lodepng_flutter:
git: git:
url: https://github.com/venera-app/lodepng_flutter url: https://github.com/venera-app/lodepng_flutter
@@ -75,13 +75,14 @@ dependencies:
flex_seed_scheme: ^3.5.0 flex_seed_scheme: ^3.5.0
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
yaml: ^3.1.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
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: flutter_to_debian: ^2.0.2
flutter: flutter:
uses-material-design: true uses-material-design: true

View File

@@ -3,11 +3,36 @@
#define MyAppName "Venera" #define MyAppName "Venera"
#define MyAppVersion "{{version}}" #define MyAppVersion "{{version}}"
#define MyAppPublisher "wgh136" #define MyAppPublisher "nyne"
#define MyAppURL "https://github.com/venera-app/venera" #define MyAppURL "https://github.com/venera-app/venera"
#define MyAppExeName "venera.exe" #define MyAppExeName "venera.exe"
#define RootPath "{{root_path}}" #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] [Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. ; 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.) ; (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 Compression=lzma
SolidCompression=yes SolidCompression=yes
WizardStyle=modern WizardStyle=modern
ArchitecturesInstallIn64BitMode=x64compatible
ArchitecturesAllowed=x64compatible
[Languages] [Languages]
Name: "english"; MessagesFile: "compiler:Default.isl" Name: "english"; MessagesFile: "compiler:Default.isl"

View File

@@ -29,7 +29,7 @@ file.close()
if not os.path.exists("windows/ChineseSimplified.isl"): if not os.path.exists("windows/ChineseSimplified.isl"):
# download ChineseSimplified.isl # download ChineseSimplified.isl
url = "https://raw.githubusercontent.com/kira-96/Inno-Setup-Chinese-Simplified-Translation/refs/heads/main/ChineseSimplified.isl" url = "https://cdn.jsdelivr.net/gh/kira-96/Inno-Setup-Chinese-Simplified-Translation@latest/ChineseSimplified.isl"
response = httpx.get(url) response = httpx.get(url)
with open('windows/ChineseSimplified.isl', 'wb') as file: with open('windows/ChineseSimplified.isl', 'wb') as file:
file.write(response.content) file.write(response.content)