41 Commits

Author SHA1 Message Date
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
nyne
19af2d79dd Merge pull request #157 from venera-app/dev
v1.2.2
2025-01-26 14:29:13 +08:00
5a11168f98 fix #151 2025-01-26 14:04:24 +08:00
1564156e28 Improve download retries.
Close https://github.com/venera-app/venera-configs/issues/39
2025-01-26 13:29:40 +08:00
2534c55ffb Improve UI of empty Explore and Category pages. 2025-01-26 12:35:49 +08:00
ba4eff66db Update version code 2025-01-25 16:57:55 +08:00
b43d907763 fix #156 2025-01-25 16:55:06 +08:00
f5a814cfe4 Improve UI 2025-01-25 16:50:04 +08:00
24b9bcd86e fix #155 2025-01-25 16:26:24 +08:00
812b36d1e9 Add buttons for adding pages 2025-01-25 12:23:30 +08:00
bab2578b65 Fix mouse scroll 2025-01-25 11:19:36 +08:00
5cf2f9f33a Update theme 2025-01-25 11:10:00 +08:00
040a5d7ad2 Update flutter_qjs 2025-01-24 19:37:24 +08:00
69da66904a Add debug config 2025-01-24 19:21:56 +08:00
11e4d7a9f2 Fix pdf 2025-01-24 19:20:57 +08:00
7bd0c2b82a Reduce app size 2025-01-24 18:06:23 +08:00
6b0a5184b9 Remove text_scroll & Improve layout 2025-01-24 11:06:54 +08:00
864980079b Remove text_scroll & Improve layout 2025-01-24 11:06:26 +08:00
de51b66d39 Fix layout 2025-01-23 23:23:18 +08:00
23205c518d Improve thumbnail 2025-01-23 19:42:49 +08:00
3ae5c7c7f2 Improve thumbnail 2025-01-23 19:08:38 +08:00
312e991935 Importing data does not require restarting 2025-01-23 18:27:46 +08:00
5184130ff8 Improve ui 2025-01-23 18:21:42 +08:00
e555779419 support strong label in comments 2025-01-23 16:42:31 +08:00
5ef973cbfb improve downloading data 2025-01-22 22:03:46 +08:00
8e2520f8e8 improve code editor 2025-01-22 22:02:16 +08:00
87f0f5bb55 improve cache 2025-01-22 21:58:14 +08:00
41 changed files with 1719 additions and 1180 deletions

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,20 +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 -> debug {
def abi = output.getFilter(com.android.build.OutputFile.ABI) ndk {
if (abi != null) { abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
outputFileName = "venera-${variant.versionName}-${abi}.apk" }
def abiVersionCode = project.ext.abiCodes.get(abi) signingConfig signingConfigs.debug
if (abiVersionCode != null) { }
versionCodeOverride = variant.versionCode * 10 + abiVersionCode }
}
} else { applicationVariants.all { variant ->
outputFileName = "venera-${variant.versionName}.apk" variant.outputs.all { output ->
versionCodeOverride = variant.versionCode * 10 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

@@ -160,7 +160,7 @@
"Date Desc": "日期降序", "Date Desc": "日期降序",
"Start": "开始", "Start": "开始",
"Export App Data": "导出应用数据", "Export App Data": "导出应用数据",
"Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)", "Import App Data": "导入应用数据",
"Export": "导出", "Export": "导出",
"Download Threads": "下载线程数", "Download Threads": "下载线程数",
"Update Time": "更新时间", "Update Time": "更新时间",
@@ -229,7 +229,7 @@
"Clear History": "清除历史", "Clear History": "清除历史",
"Are you sure you want to clear your history?": "确定要清除您的历史记录吗?", "Are you sure you want to clear your history?": "确定要清除您的历史记录吗?",
"No Explore Pages": "没有探索页面", "No Explore Pages": "没有探索页面",
"Add a comic source in home page": "在主页添加一个漫画源", "Please add some sources": "请添加一些源",
"Please check your settings": "请检查您的设置", "Please check your settings": "请检查您的设置",
"No Category Pages": "没有分类页面", "No Category Pages": "没有分类页面",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
@@ -314,7 +314,14 @@
"New Version": "新版本", "New Version": "新版本",
"@c updates": "@c 项更新", "@c updates": "@c 项更新",
"No updates": "无更新", "No updates": "无更新",
"Set comic source list url": "设置漫画源列表URL" "Set comic source list url": "设置漫画源列表URL",
"Deselect All": "取消全选",
"Add keyword": "添加关键词",
"Keyword": "关键词",
"Manage": "管理",
"Verify": "验证",
"Cloudflare verification required": "需要Cloudflare验证",
"Success": "成功"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -477,7 +484,7 @@
"Start": "開始", "Start": "開始",
"Reversed successfully": "反轉成功", "Reversed successfully": "反轉成功",
"Export App Data": "匯出應用數據", "Export App Data": "匯出應用數據",
"Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)", "Import App Data": "匯入應用數據",
"Export": "匯出", "Export": "匯出",
"Download Threads": "下載線程數", "Download Threads": "下載線程數",
"Update Time": "更新時間", "Update Time": "更新時間",
@@ -546,7 +553,7 @@
"Clear History": "清除歷史", "Clear History": "清除歷史",
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?", "Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
"No Explore Pages": "沒有探索頁面", "No Explore Pages": "沒有探索頁面",
"Add a comic source in home page": "在主頁添加一個漫畫源", "Please add some sources": "請添加一些源",
"Please check your settings": "請檢查您的設定", "Please check your settings": "請檢查您的設定",
"No Category Pages": "沒有分類頁面", "No Category Pages": "沒有分類頁面",
"Chapter @ep": "第 @ep 章", "Chapter @ep": "第 @ep 章",
@@ -631,6 +638,13 @@
"New Version": "新版本", "New Version": "新版本",
"@c updates": "@c 項更新", "@c updates": "@c 項更新",
"No updates": "無更新", "No updates": "無更新",
"Set comic source list url": "設置漫畫源列表URL" "Set comic source list url": "設置漫畫源列表URL",
"Deselect All": "取消全選",
"Add keyword": "添加關鍵詞",
"Keyword": "關鍵詞",
"Manage": "管理",
"Verify": "驗證",
"Cloudflare verification required": "需要Cloudflare驗證",
"Success": "成功"
} }
} }

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

@@ -1,12 +1,14 @@
part of 'components.dart'; part of 'components.dart';
class Appbar extends StatefulWidget implements PreferredSizeWidget { class Appbar extends StatefulWidget implements PreferredSizeWidget {
const Appbar( const Appbar({
{required this.title, required this.title,
this.leading, this.leading,
this.actions, this.actions,
this.backgroundColor, this.backgroundColor,
super.key}); this.style = AppbarStyle.blur,
super.key,
});
final Widget title; final Widget title;
@@ -16,6 +18,8 @@ class Appbar extends StatefulWidget implements PreferredSizeWidget {
final Color? backgroundColor; final Color? backgroundColor;
final AppbarStyle style;
@override @override
State<Appbar> createState() => _AppbarState(); State<Appbar> createState() => _AppbarState();
@@ -108,10 +112,18 @@ class _AppbarState extends State<Appbar> {
], ],
).paddingTop(context.padding.top), ).paddingTop(context.padding.top),
); );
return BlurEffect( if (widget.style == AppbarStyle.shadow) {
blur: _scrolledUnder ? 15 : 0, return Material(
child: content, color: context.colorScheme.surface,
); elevation: _scrolledUnder ? 2 : 0,
child: content,
);
} else {
return BlurEffect(
blur: _scrolledUnder ? 15 : 0,
child: content,
);
}
} }
} }
@@ -256,18 +268,25 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
} }
} }
class FilledTabBar extends StatefulWidget { class AppTabBar extends StatefulWidget {
const FilledTabBar({super.key, this.controller, required this.tabs}); const AppTabBar({
super.key,
this.controller,
required this.tabs,
this.actionButton,
});
final TabController? controller; final TabController? controller;
final List<Tab> tabs; final List<Tab> tabs;
final Widget? actionButton;
@override @override
State<FilledTabBar> createState() => _FilledTabBarState(); State<AppTabBar> createState() => _AppTabBarState();
} }
class _FilledTabBarState extends State<FilledTabBar> { class _AppTabBarState extends State<AppTabBar> {
late TabController _controller; late TabController _controller;
late List<GlobalKey> keys; late List<GlobalKey> keys;
@@ -315,7 +334,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
} }
@override @override
void didUpdateWidget(covariant FilledTabBar oldWidget) { void didUpdateWidget(covariant AppTabBar oldWidget) {
if (widget.controller != oldWidget.controller) { if (widget.controller != oldWidget.controller) {
_controller = widget.controller ?? DefaultTabController.of(context); _controller = widget.controller ?? DefaultTabController.of(context);
_controller.animation!.addListener(onTabChanged); _controller.animation!.addListener(onTabChanged);
@@ -366,25 +385,27 @@ class _FilledTabBarState extends State<FilledTabBar> {
painter: painter, painter: painter,
child: _TabRow( child: _TabRow(
callback: _tabLayoutCallback, callback: _tabLayoutCallback,
children: List.generate(widget.tabs.length, buildTab), children: List.generate(widget.tabs.length, buildTab)
..addIfNotNull(widget.actionButton?.padding(tabPadding)),
), ),
).paddingHorizontal(4), ).paddingHorizontal(4),
); );
}, },
); );
return Container( return Container(
key: tabBarKey, key: tabBarKey,
height: _kTabHeight, height: _kTabHeight,
width: double.infinity, width: double.infinity,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: context.colorScheme.outlineVariant, color: context.colorScheme.outlineVariant,
width: 0.6, width: 0.6,
),
), ),
), ),
child: widget.tabs.isEmpty ? const SizedBox() : child); ),
child: widget.tabs.isEmpty ? const SizedBox() : child,
);
} }
int? previousIndex; int? previousIndex;
@@ -544,7 +565,7 @@ class _IndicatorPainter extends CustomPainter {
var rect = Rect.fromLTWH( var rect = Rect.fromLTWH(
tabLeft + padding.left + horizontalPadding, tabLeft + padding.left + horizontalPadding,
_FilledTabBarState._kTabHeight - 3.6, _AppTabBarState._kTabHeight - 3.6,
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2, tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
3, 3,
); );
@@ -621,7 +642,6 @@ class _TabViewBodyState extends State<TabViewBody> {
} }
} }
class SearchBarController { class SearchBarController {
_SearchBarMixin? _state; _SearchBarMixin? _state;
@@ -894,3 +914,42 @@ class _SearchBarState extends State<AppSearchBar> with _SearchBarMixin {
); );
} }
} }
class TabActionButton extends StatelessWidget {
const TabActionButton({
super.key,
required this.icon,
required this.text,
required this.onPressed,
});
final Icon icon;
final String text;
final void Function() onPressed;
static const _kTabHeight = 46.0;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Container(
height: _kTabHeight,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: IconTheme(
data: IconThemeData(size: 20, color: context.colorScheme.primary),
child: Row(
children: [
icon,
const SizedBox(width: 8),
Text(text, style: ts.withColor(context.colorScheme.primary)),
],
),
),
),
);
}
}

View File

@@ -55,7 +55,7 @@ class _CodeEditorState extends State<CodeEditor> {
Widget buildLineNumbers() { Widget buildLineNumbers() {
return SizedBox( return SizedBox(
width: 32, width: 36,
child: Column( child: Column(
children: [ children: [
for (var i = 1; i <= lineCount; i++) for (var i = 1; i <= lineCount; i++)

View File

@@ -356,14 +356,13 @@ class ComicTile extends StatelessWidget {
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0), padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
child: TextScroll( child: Text(
comic.title.replaceAll('\n', ''), comic.title.replaceAll('\n', ''),
mode: TextScrollMode.endless, maxLines: 1,
overflow: TextOverflow.clip,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
delayBefore: Duration(milliseconds: 500),
velocity: const Velocity(pixelsPerSecond: Offset(40, 0)),
), ),
), ),
], ],

View File

@@ -9,7 +9,6 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:syntax_highlight/syntax_highlight.dart'; import 'package:syntax_highlight/syntax_highlight.dart';
import 'package:text_scroll/text_scroll.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/app_page_route.dart'; import 'package:venera/foundation/app_page_route.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';

View File

@@ -58,26 +58,12 @@ class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
}, },
child: GestureDetector( child: GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
child: AnimatedContainer( child: AnimatedPhysicalModel(
duration: _fastAnimationDuration, duration: _fastAnimationDuration,
decoration: BoxDecoration( elevation: isHovered ? 3 : 1,
borderRadius: BorderRadius.circular(widget.borderRadius), color: context.colorScheme.surface,
boxShadow: isHovered shadowColor: context.colorScheme.shadow,
? [ borderRadius: BorderRadius.circular(widget.borderRadius),
BoxShadow(
color: context.colorScheme.outline,
blurRadius: 2,
offset: const Offset(0, 2),
),
]
: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
child: widget.child, child: widget.child,
), ),
), ),

View File

@@ -277,35 +277,38 @@ class _AnimatedImageState extends State<AnimatedImage>
if (_imageInfo != null) { if (_imageInfo != null) {
if (widget.part != null) { if (widget.part != null) {
return CustomPaint( result = CustomPaint(
isComplex: true,
painter: ImagePainter( painter: ImagePainter(
image: _imageInfo!.image, image: _imageInfo!.image,
part: widget.part!, part: widget.part!,
fit: widget.fit ?? BoxFit.cover,
), ),
child: SizedBox( child: SizedBox(
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
), ),
); );
} else {
result = RawImage(
image: _imageInfo?.image,
width: widget.width,
height: widget.height,
debugImageLabel: _imageInfo?.debugLabel,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: BoxFit.cover,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
} }
result = RawImage(
image: _imageInfo?.image,
width: widget.width,
height: widget.height,
debugImageLabel: _imageInfo?.debugLabel,
scale: _imageInfo?.scale ?? 1.0,
color: widget.color,
opacity: widget.opacity,
colorBlendMode: widget.colorBlendMode,
fit: BoxFit.cover,
alignment: widget.alignment,
repeat: widget.repeat,
centerSlice: widget.centerSlice,
matchTextDirection: widget.matchTextDirection,
invertColors: _invertColors,
isAntiAlias: widget.isAntiAlias,
filterQuality: widget.filterQuality,
);
} else if (_lastException != null) { } else if (_lastException != null) {
result = const Center( result = const Center(
child: Icon(Icons.error), child: Icon(Icons.error),
@@ -362,10 +365,13 @@ class ImagePainter extends CustomPainter {
final ImagePart part; final ImagePart part;
final BoxFit fit;
/// Render a part of the image. /// Render a part of the image.
const ImagePainter({ const ImagePainter({
required this.image, required this.image,
this.part = const ImagePart(), this.part = const ImagePart(),
this.fit = BoxFit.cover,
}); });
@override @override
@@ -377,7 +383,8 @@ class ImagePainter extends CustomPainter {
part.y2 ?? image.height.toDouble(), part.y2 ?? image.height.toDouble(),
), ),
); );
final Rect dst = Offset.zero & size; var fitted = applyBoxFit(fit, Size(src.width, src.height), size).destination;
var dst = Alignment.center.inscribe(fitted, Offset.zero & size);
canvas.drawImageRect(image, src, dst, Paint()); canvas.drawImageRect(image, src, dst, Paint());
} }

View File

@@ -2,7 +2,10 @@ part of 'components.dart';
class SliverGridViewWithFixedItemHeight extends StatelessWidget { class SliverGridViewWithFixedItemHeight extends StatelessWidget {
const SliverGridViewWithFixedItemHeight( const SliverGridViewWithFixedItemHeight(
{required this.delegate, required this.maxCrossAxisExtent, required this.itemHeight, super.key}); {required this.delegate,
required this.maxCrossAxisExtent,
required this.itemHeight,
super.key});
final SliverChildDelegate delegate; final SliverChildDelegate delegate;
@@ -62,7 +65,8 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
@override @override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) { bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true; if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || oldDelegate.itemHeight != itemHeight) { if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
oldDelegate.itemHeight != itemHeight) {
return true; return true;
} }
return false; return false;
@@ -70,28 +74,29 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
} }
class SliverGridDelegateWithComics extends SliverGridDelegate { class SliverGridDelegateWithComics extends SliverGridDelegate {
SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]); SliverGridDelegateWithComics();
final bool useBriefMode; final bool useBriefMode = appdata.settings['comicDisplayMode'] == 'brief';
final double? scale; final double scale = (appdata.settings['comicTileScale'] as num).toDouble();
@override @override
SliverGridLayout getLayout(SliverConstraints constraints) { SliverGridLayout getLayout(SliverConstraints constraints) {
if (appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode) { if (useBriefMode) {
return getBriefModeLayout( return getBriefModeLayout(
constraints, constraints,
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(), scale,
); );
} else { } else {
return getDetailedModeLayout( return getDetailedModeLayout(
constraints, constraints,
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(), scale,
); );
} }
} }
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale) { SliverGridLayout getDetailedModeLayout(
SliverConstraints constraints, double scale) {
const minCrossAxisExtent = 360; const minCrossAxisExtent = 360;
final itemHeight = 152 * scale; final itemHeight = 152 * scale;
final width = constraints.crossAxisExtent; final width = constraints.crossAxisExtent;
@@ -106,11 +111,14 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
reverseCrossAxis: false); reverseCrossAxis: false);
} }
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale) { SliverGridLayout getBriefModeLayout(
SliverConstraints constraints, double scale) {
final maxCrossAxisExtent = 192.0 * scale; final maxCrossAxisExtent = 192.0 * scale;
const childAspectRatio = 0.68; const childAspectRatio = 0.64;
const crossAxisSpacing = 0.0; const crossAxisSpacing = 0.0;
int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil(); int crossAxisCount =
(constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing))
.ceil();
// Ensure a minimum count of 1, can be zero and result in an infinite extent // Ensure a minimum count of 1, can be zero and result in an infinite extent
// below when the window size is 0. // below when the window size is 0.
crossAxisCount = math.max(1, crossAxisCount); crossAxisCount = math.max(1, crossAxisCount);
@@ -132,6 +140,11 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
@override @override
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) { bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
return true; if (oldDelegate is! SliverGridDelegateWithComics) return true;
if (oldDelegate.scale != scale ||
oldDelegate.useBriefMode != useBriefMode) {
return true;
}
return false;
} }
} }

View File

@@ -6,6 +6,7 @@ class NetworkError extends StatelessWidget {
required this.message, required this.message,
this.retry, this.retry,
this.withAppbar = true, this.withAppbar = true,
this.buttonText,
}); });
final String message; final String message;
@@ -14,6 +15,8 @@ class NetworkError extends StatelessWidget {
final bool withAppbar; final bool withAppbar;
final String? buttonText;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var cfe = CloudflareException.fromString(message); var cfe = CloudflareException.fromString(message);
@@ -54,13 +57,15 @@ 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
FilledButton( FilledButton(
onPressed: retry, onPressed: retry,
child: Text('Retry'.tl), child: Text(buttonText ?? 'Retry'.tl),
), ),
], ],
), ),
@@ -127,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;
} }
@@ -185,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();
@@ -318,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

@@ -98,8 +98,17 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
_controller.position.maxScrollExtent, _controller.position.maxScrollExtent,
); );
if (_futurePosition == old) return; if (_futurePosition == old) return;
_controller.animateTo(_futurePosition!, var target = _futurePosition!;
duration: _fastAnimationDuration, curve: Curves.linear); _controller.animateTo(
_futurePosition!,
duration: _fastAnimationDuration,
curve: Curves.linear,
).then((_) {
var current = _controller.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
}
});
} }
}, },
child: ScrollControllerProvider._( child: ScrollControllerProvider._(

View File

@@ -10,7 +10,7 @@ export "widget_utils.dart";
export "context.dart"; export "context.dart";
class _App { class _App {
final version = "1.2.1"; final version = "1.2.3";
bool get isAndroid => Platform.isAndroid; bool get isAndroid => Platform.isAndroid;

View File

@@ -1,4 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_saf/flutter_saf.dart'; import 'package:flutter_saf/flutter_saf.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -8,12 +10,12 @@ import 'package:venera/foundation/js_engine.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/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/app_links.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 'foundation/appdata.dart'; import 'foundation/appdata.dart';
extension FutureInit<T> on Future<T> { extension _FutureInit<T> on Future<T> {
/// Prevent unhandled exception /// Prevent unhandled exception
/// ///
/// A unhandled exception occurred in init() will cause the app to crash. /// A unhandled exception occurred in init() will cause the app to crash.
@@ -27,6 +29,7 @@ extension FutureInit<T> on Future<T> {
} }
Future<void> init() async { Future<void> init() async {
await Rhttp.init();
await SAFTaskWorker().init().wait(); await SAFTaskWorker().init().wait();
await AppTranslation.init().wait(); await AppTranslation.init().wait();
await appdata.init().wait(); await appdata.init().wait();
@@ -39,4 +42,11 @@ 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 (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");
};
} }

View File

@@ -1,14 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flex_seed_scheme/flex_seed_scheme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:rhttp/rhttp.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/pages/auth_page.dart'; import 'package:venera/pages/auth_page.dart';
import 'package:venera/pages/main_page.dart'; import 'package:venera/pages/main_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'components/components.dart'; import 'components/components.dart';
@@ -18,21 +17,11 @@ import 'foundation/appdata.dart';
import 'init.dart'; import 'init.dart';
void main(List<String> args) { void main(List<String> args) {
if (runWebViewTitleBarWidget(args)) { if (runWebViewTitleBarWidget(args)) return;
return;
}
overrideIO(() { overrideIO(() {
runZonedGuarded(() async { runZonedGuarded(() async {
await Rhttp.init();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await init(); await init();
if (App.isAndroid) {
handleLinks();
}
FlutterError.onError = (details) {
Log.error(
"Unhandled Exception", "${details.exception}\n${details.stack}");
};
runApp(const MyApp()); runApp(const MyApp());
if (App.isDesktop) { if (App.isDesktop) {
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
@@ -55,7 +44,7 @@ void main(List<String> args) {
}); });
} }
}, (error, stack) { }, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack"); Log.error("Unhandled Exception", error, stack);
}); });
}); });
} }
@@ -156,50 +145,44 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
home = const MainPage(); home = const MainPage();
} }
return DynamicColorBuilder(builder: (light, dark) { return DynamicColorBuilder(builder: (light, dark) {
Color? primary, secondary, tertiary;
if (appdata.settings['color'] != 'system' || if (appdata.settings['color'] != 'system' ||
light == null || light == null ||
dark == null) { dark == null) {
var color = translateColorSetting(); primary = translateColorSetting();
light = ColorScheme.fromSeed(
seedColor: color,
surface: Colors.white,
);
dark = ColorScheme.fromSeed(
seedColor: color,
brightness: Brightness.dark,
surface: Colors.black,
);
} else { } else {
light = ColorScheme.fromSeed( primary = light.primary;
seedColor: light.primary, secondary = light.secondary;
surface: Colors.white, tertiary = light.tertiary;
);
dark = ColorScheme.fromSeed(
seedColor: dark.primary,
brightness: Brightness.dark,
surface: Colors.black,
);
} }
return MaterialApp( return MaterialApp(
home: home, home: home,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
colorScheme: light, colorScheme: SeedColorScheme.fromSeeds(
fontFamily: App.isWindows ? "Microsoft YaHei" : null, primaryKey: primary,
secondaryKey: secondary,
tertiaryKey: tertiary,
tones: FlexTones.vividBackground(Brightness.light),
),
), ),
navigatorKey: App.rootNavigatorKey, navigatorKey: App.rootNavigatorKey,
darkTheme: ThemeData( darkTheme: ThemeData(
colorScheme: dark, colorScheme: SeedColorScheme.fromSeeds(
fontFamily: App.isWindows ? "Microsoft YaHei" : null, 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,
_ => ThemeMode.system _ => ThemeMode.system
}, },
localizationsDelegates: const [ localizationsDelegates: [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
locale: () { locale: () {
@@ -215,9 +198,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
}; };
}(), }(),
supportedLocales: const [ supportedLocales: const [
Locale('en'),
Locale('zh', 'CN'), Locale('zh', 'CN'),
Locale('zh', 'TW'), Locale('zh', 'TW'),
Locale('en'),
], ],
builder: (context, widget) { builder: (context, widget) {
ErrorWidget.builder = (details) { ErrorWidget.builder = (details) {

View File

@@ -1,5 +1,5 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:venera/network/app_dio.dart';
class NetworkCache { class NetworkCache {
final Uri uri; final Uri uri;
@@ -117,7 +117,7 @@ class NetworkCacheManager implements Interceptor {
var o = options.copyWith( var o = options.copyWith(
method: "HEAD", method: "HEAD",
); );
var dio = Dio(); var dio = AppDio();
var response = await dio.fetch(o); var response = await dio.fetch(o);
if (response.statusCode == 200 && if (response.statusCode == 200 &&
compareHeaders(cache.responseHeaders, response.headers.map)) { compareHeaders(cache.responseHeaders, response.headers.map)) {

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,47 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
onFinished(); onFinished();
} }
}, },
onClose: onFinished,
); );
webview.open(); webview.open();
} else { } else {
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);
App.rootPop();
}
}
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 {
@@ -487,6 +505,7 @@ Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
if (i == retry - 1) { if (i == retry - 1) {
return Res.error(e.toString()); return Res.error(e.toString());
} }
await Future.delayed(Duration(seconds: i + 1));
} }
} }
throw UnimplementedError(); throw UnimplementedError();

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

@@ -3,80 +3,128 @@ 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/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/ranking_page.dart'; import 'package:venera/pages/ranking_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/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'category_comics_page.dart'; import 'category_comics_page.dart';
import 'comic_source_page.dart';
class CategoriesPage extends StatelessWidget { class CategoriesPage extends StatefulWidget {
const CategoriesPage({super.key}); const CategoriesPage({super.key});
@override
State<CategoriesPage> createState() => _CategoriesPageState();
}
class _CategoriesPageState extends State<CategoriesPage> {
var categories = <String>[];
void onSettingsChanged() {
var categories =
List.from(appdata.settings["categories"]).whereType<String>().toList();
var allCategories = ComicSource.all()
.map((e) => e.categoryData?.key)
.where((element) => element != null)
.map((e) => e!)
.toList();
categories =
categories.where((element) => allCategories.contains(element)).toList();
if (!categories.isEqualsTo(this.categories)) {
setState(() {
this.categories = categories;
});
}
}
@override
void initState() {
super.initState();
var categories =
List.from(appdata.settings["categories"]).whereType<String>().toList();
var allCategories = ComicSource.all()
.map((e) => e.categoryData?.key)
.where((element) => element != null)
.map((e) => e!)
.toList();
this.categories =
categories.where((element) => allCategories.contains(element)).toList();
appdata.settings.addListener(onSettingsChanged);
}
void addPage() {
showPopUpWidget(App.rootContext, setCategoryPagesWidget());
}
@override
void dispose() {
super.dispose();
appdata.settings.removeListener(onSettingsChanged);
}
Widget buildEmpty() {
var msg = "No Category Pages".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 = addPage;
}
return NetworkError(
message: msg,
retry: onTap,
withAppbar: false,
buttonText: "Manage".tl,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StateBuilder<SimpleController>( if (categories.isEmpty) {
tag: "category", return buildEmpty();
init: SimpleController(), }
builder: (controller) {
var categories = List.from(appdata.settings["categories"]);
var allCategories = ComicSource.all()
.map((e) => e.categoryData?.key)
.where((element) => element != null)
.map((e) => e!)
.toList();
categories = categories
.where((element) => allCategories.contains(element))
.toList();
if(categories.isEmpty) { return Material(
var msg = "No Category Pages".tl; child: DefaultTabController(
msg += '\n'; length: categories.length,
if(ComicSource.isEmpty) { key: Key(categories.toString()),
msg += "Add a comic source in home page".tl; child: Column(
} else { children: [
msg += "Please check your settings".tl; AppTabBar(
} key: PageStorageKey(categories.toString()),
return NetworkError( tabs: categories.map((e) {
message: msg, String title = e;
retry: () { try {
controller.update(); title = getCategoryDataWithKey(e).title;
}, } catch (e) {
withAppbar: false, //
); }
} return Tab(
text: title,
return Material( key: Key(e),
child: DefaultTabController( );
length: categories.length, }).toList(),
key: Key(categories.toString()), actionButton: TabActionButton(
child: Column( icon: const Icon(Icons.add),
children: [ text: "Add".tl,
FilledTabBar( onPressed: addPage,
key: PageStorageKey(categories.toString()), ),
tabs: categories.map((e) { ).paddingTop(context.padding.top),
String title = e; Expanded(
try { child: TabBarView(
title = getCategoryDataWithKey(e).title; children: categories.map((e) => _CategoryPage(e)).toList(),
} catch (e) { ),
// )
} ],
return Tab( ),
text: title, ),
key: Key(e),
);
}).toList(),
).paddingTop(context.padding.top),
Expanded(
child: TabBarView(
children:
categories.map((e) => _CategoryPage(e)).toList()),
)
],
),
),
);
},
); );
} }
} }

View File

@@ -1283,7 +1283,9 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
y2 = double.parse(r.split('-')[1]); y2 = double.parse(r.split('-')[1]);
} }
} }
} finally {} } catch (_) {
// ignore
}
part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2); part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2);
} }
return Padding( return Padding(
@@ -1297,30 +1299,29 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
child: InkWell( child: InkWell(
onTap: () => state.read(null, index + 1), onTap: () => state.read(null, index + 1),
borderRadius: borderRadius:
const BorderRadius.all(Radius.circular(16)), const BorderRadius.all(Radius.circular(8)),
child: Container( child: Container(
decoration: BoxDecoration( foregroundDecoration: BoxDecoration(
borderRadius: borderRadius: BorderRadius.circular(8),
const BorderRadius.all(Radius.circular(16)),
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.outline, color: Theme.of(context).colorScheme.outline,
), ),
), ),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
child: ClipRRect( clipBehavior: Clip.antiAlias,
borderRadius: child: AnimatedImage(
const BorderRadius.all(Radius.circular(16)), image: CachedImageProvider(
child: AnimatedImage( url,
image: CachedImageProvider( sourceKey: state.widget.sourceKey,
url,
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
part: part,
), ),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
part: part,
), ),
), ),
), ),
@@ -1336,7 +1337,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
), ),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 200,
childAspectRatio: 0.65, childAspectRatio: 0.68,
), ),
), ),
if (error != null) if (error != null)
@@ -2000,6 +2001,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
} }
return Shimmer( return Shimmer(
color: context.isDarkMode ? Colors.grey.shade700 : Colors.white,
child: Column( child: Column(
children: [ children: [
Appbar(title: Text(""), backgroundColor: context.colorScheme.surface), Appbar(title: Text(""), backgroundColor: context.colorScheme.surface),

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 {
@@ -19,8 +23,7 @@ class ComicSourcePage extends StatefulWidget {
return 0; return 0;
} }
var dio = AppDio(); var dio = AppDio();
var res = await dio.get<String>( var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
if (res.statusCode != 200) { if (res.statusCode != 200) {
return -1; return -1;
} }
@@ -45,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(
@@ -93,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,
@@ -298,10 +148,12 @@ class _BodyState extends State<_Body> {
// //
} }
} }
context.to(() => _EditFilePage(source.filePath)).then((value) 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 {
@@ -419,7 +271,8 @@ class _BodyState extends State<_Body> {
} }
void help() { void help() {
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/comic_source.md"); launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
} }
Future<void> handleAddSource(String url) async { Future<void> handleAddSource(String url) async {
@@ -521,18 +374,29 @@ class _ComicSourceListState extends State<_ComicSourceList> {
var key = json![index]["key"]; var key = json![index]["key"];
var action = currentKey.contains(key) var action = currentKey.contains(key)
? const Icon(Icons.check, size: 20).paddingRight(8) ? const Icon(Icons.check, size: 20).paddingRight(8)
: Tooltip( : Button.filled(
message: "Add", child: Text("Add".tl),
child: Button.icon( onPressed: () async {
color: context.colorScheme.primary, var fileName = json![index]["fileName"];
icon: const Icon(Icons.add), var url = json![index]["url"];
onPressed: () async { if (url == null || !(url.toString()).isURL) {
await widget.onAdd( var listUrl =
"https://raw.githubusercontent.com/venera-app/venera-configs/master/${json![index]["fileName"]}"); appdata.settings['comicSourceListUrl'] as String;
setState(() {}); if (listUrl
}, .replaceFirst("https://", "")
), .replaceFirst("http://", "")
); .contains("/")) {
url =
listUrl.substring(0, listUrl.lastIndexOf("/") + 1) +
fileName;
} else {
url = '$listUrl/$fileName';
}
}
await widget.onAdd(url);
setState(() {});
},
).fixHeight(32);
return ListTile( return ListTile(
title: Text(json![index]["name"]), title: Text(json![index]["name"]),
@@ -617,10 +481,12 @@ void _addAllPagesWithComicSource(ComicSource source) {
} }
class _EditFilePage extends StatefulWidget { class _EditFilePage extends StatefulWidget {
const _EditFilePage(this.path); const _EditFilePage(this.path, this.onExit);
final String path; final String path;
final void Function() onExit;
@override @override
State<_EditFilePage> createState() => __EditFilePageState(); State<_EditFilePage> createState() => __EditFilePageState();
} }
@@ -637,6 +503,7 @@ class __EditFilePageState extends State<_EditFilePage> {
@override @override
void dispose() { void dispose() {
File(widget.path).writeAsStringSync(current); File(widget.path).writeAsStringSync(current);
widget.onExit();
super.dispose(); super.dispose();
} }
@@ -750,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

@@ -73,6 +73,7 @@ class _CommentsPageState extends State<CommentsPage> {
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: Appbar( appBar: Appbar(
title: Text("Comments".tl), title: Text("Comments".tl),
style: AppbarStyle.shadow,
), ),
body: buildBody(context), body: buildBody(context),
); );
@@ -529,6 +530,7 @@ class _Tag {
'u' => style.underline, 'u' => style.underline,
's' => style.lineThrough, 's' => style.lineThrough,
'a' => style.withColor(context.colorScheme.primary), 'a' => style.withColor(context.colorScheme.primary),
'strong' => style.bold,
'span' => () { 'span' => () {
if (attributes.containsKey('style')) { if (attributes.containsKey('style')) {
var s = attributes['style']!; var s = attributes['style']!;
@@ -622,10 +624,14 @@ class RichCommentContent extends StatefulWidget {
class _RichCommentContentState extends State<RichCommentContent> { class _RichCommentContentState extends State<RichCommentContent> {
var textSpan = <InlineSpan>[]; var textSpan = <InlineSpan>[];
var images = <_CommentImage>[]; var images = <_CommentImage>[];
bool isRendered = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
render(); if (!isRendered) {
render();
isRendered = true;
}
super.didChangeDependencies(); super.didChangeDependencies();
} }
@@ -670,7 +676,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', ''); attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', '');
} }
} }
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span']; const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span', 'strong'];
if (acceptedTags.contains(tagName)) { if (acceptedTags.contains(tagName)) {
writeBuffer(); writeBuffer();
if (tagName == 'img') { if (tagName == 'img') {

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

@@ -5,7 +5,9 @@ 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/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/foundation/state_controller.dart'; import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/comic_source_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/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -56,6 +58,10 @@ class _ExplorePageState extends State<ExplorePage>
} }
} }
void addPage() {
showPopUpWidget(App.rootContext, setExplorePagesWidget());
}
NaviPaneState? naviPane; NaviPaneState? naviPane;
@override @override
@@ -117,15 +123,21 @@ class _ExplorePageState extends State<ExplorePage>
Widget buildEmpty() { Widget buildEmpty() {
var msg = "No Explore Pages".tl; var msg = "No Explore Pages".tl;
msg += '\n'; msg += '\n';
VoidCallback onTap;
if (ComicSource.isEmpty) { if (ComicSource.isEmpty) {
msg += "Add a comic source in home page".tl; msg += "Please add some sources".tl;
onTap = () {
context.to(() => ComicSourcePage());
};
} else { } else {
msg += "Please check your settings".tl; msg += "Please check your settings".tl;
onTap = addPage;
} }
return NetworkError( return NetworkError(
message: msg, message: msg,
retry: onSettingsChanged, retry: onTap,
withAppbar: false, withAppbar: false,
buttonText: "Manage".tl,
); );
} }
@@ -137,10 +149,15 @@ class _ExplorePageState extends State<ExplorePage>
} }
Widget tabBar = Material( Widget tabBar = Material(
child: FilledTabBar( child: AppTabBar(
key: PageStorageKey(pages.toString()), key: PageStorageKey(pages.toString()),
tabs: pages.map((e) => buildTab(e)).toList(), tabs: pages.map((e) => buildTab(e)).toList(),
controller: controller, controller: controller,
actionButton: TabActionButton(
icon: const Icon(Icons.add),
text: "Add".tl,
onPressed: addPage,
),
), ),
).paddingTop(context.padding.top); ).paddingTop(context.padding.top);

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

@@ -391,7 +391,7 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget tabBar = Material( Widget tabBar = Material(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: FilledTabBar( child: AppTabBar(
key: PageStorageKey(optionTypes), key: PageStorageKey(optionTypes),
tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(), tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(),
), ),

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

@@ -48,7 +48,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
var readerMode = context.reader.mode; var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) { if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1; showFloatingButtonValue = 1;
_floatingButtonDragListener = _DragListener( _floatingButtonDragListener = _DragListener(
onMove: (offset) { onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) { if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy; fABValue.value -= offset.dy;
@@ -845,6 +845,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
late int _batteryLevel = 100; late int _batteryLevel = 100;
Timer? _timer; Timer? _timer;
bool _hasBattery = false; bool _hasBattery = false;
BatteryState state = BatteryState.unknown;
@override @override
void initState() { void initState() {
@@ -856,29 +857,23 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
void _checkBatteryAvailability() async { void _checkBatteryAvailability() async {
try { try {
_batteryLevel = await _battery.batteryLevel; _batteryLevel = await _battery.batteryLevel;
if (_batteryLevel != -1) { state = await _battery.batteryState;
if (_batteryLevel > 0 && state != BatteryState.unknown) {
setState(() { setState(() {
_hasBattery = true; _hasBattery = true;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) { });
_battery.batteryLevel.then((level) => { _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_batteryLevel != level) _battery.batteryLevel.then((level) {
{ if (_batteryLevel != level) {
setState(() { setState(() {
_batteryLevel = level; _batteryLevel = level;
}) });
} }
});
}); });
}); });
} else {
setState(() {
_hasBattery = false;
});
} }
} catch (e) { } catch (_) {
setState(() { // ignore
_hasBattery = false;
});
} }
} }
@@ -900,7 +895,9 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
IconData batteryIcon; IconData batteryIcon;
Color batteryColor = context.colorScheme.onSurface; Color batteryColor = context.colorScheme.onSurface;
if (batteryLevel >= 96) { if (state == BatteryState.charging) {
batteryIcon = Icons.battery_charging_full;
} else if (batteryLevel >= 96) {
batteryIcon = Icons.battery_full_sharp; batteryIcon = Icons.battery_full_sharp;
} else if (batteryLevel >= 84) { } else if (batteryLevel >= 84) {
batteryIcon = Icons.battery_6_bar_sharp; batteryIcon = Icons.battery_6_bar_sharp;

View File

@@ -107,7 +107,7 @@ class _AppSettingsState extends State<AppSettings> {
actionTitle: 'Export'.tl, actionTitle: 'Export'.tl,
).toSliver(), ).toSliver(),
_CallbackSetting( _CallbackSetting(
title: "Import App Data (Please restart after success)".tl, title: "Import App Data".tl,
callback: () async { callback: () async {
var controller = showLoadingDialog(context); var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera', 'picadata']); var file = await selectFile(ext: ['venera', 'picadata']);
@@ -126,6 +126,7 @@ class _AppSettingsState extends State<AppSettings> {
context.showMessage(message: "Failed to import data".tl); context.showMessage(message: "Failed to import data".tl);
} finally { } finally {
cacheFile.deleteIgnoreError(); cacheFile.deleteIgnoreError();
App.forceRebuild();
} }
} }
controller.close(); controller.close();

View File

@@ -30,35 +30,11 @@ class _ExploreSettingsState extends State<ExploreSettings> {
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Explore Pages".tl, title: "Explore Pages".tl,
builder: () { builder: setExplorePagesWidget,
var pages = <String, String>{};
for (var c in ComicSource.all()) {
for (var page in c.explorePages) {
pages[page.title] = page.title;
}
}
return _MultiPagesFilter(
title: "Explore Pages".tl,
settingsIndex: "explore_pages",
pages: pages,
);
},
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Category Pages".tl, title: "Category Pages".tl,
builder: () { builder: setCategoryPagesWidget,
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.categoryData != null) {
pages[c.categoryData!.key] = c.categoryData!.title;
}
}
return _MultiPagesFilter(
title: "Category Pages".tl,
settingsIndex: "categories",
pages: pages,
);
},
).toSliver(), ).toSliver(),
_PopupWindowSetting( _PopupWindowSetting(
title: "Network Favorite Pages".tl, title: "Network Favorite Pages".tl,
@@ -132,8 +108,9 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
return PopUpWidgetScaffold( return PopUpWidgetScaffold(
title: "Keyword blocking".tl, title: "Keyword blocking".tl,
tailing: [ tailing: [
IconButton( TextButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: Text("Add".tl),
onPressed: add, onPressed: add,
), ),
], ],
@@ -159,7 +136,6 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
void add() { void add() {
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
barrierColor: Colors.black.toOpacity(0.1),
builder: (context) { builder: (context) {
var controller = TextEditingController(); var controller = TextEditingController();
String? error; String? error;
@@ -205,3 +181,31 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
); );
} }
} }
Widget setExplorePagesWidget() {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
for (var page in c.explorePages) {
pages[page.title] = page.title.ts(c.key);
}
}
return _MultiPagesFilter(
title: "Explore Pages".tl,
settingsIndex: "explore_pages",
pages: pages,
);
}
Widget setCategoryPagesWidget() {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.categoryData != null) {
pages[c.categoryData!.key] = c.categoryData!.title;
}
}
return _MultiPagesFilter(
title: "Category Pages".tl,
settingsIndex: "categories",
pages: pages,
);
}

View File

@@ -376,6 +376,14 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
super.initState(); super.initState();
} }
@override
void dispose() {
super.dispose();
Future.microtask(() {
updateSetting();
});
}
var reorderWidgetKey = UniqueKey(); var reorderWidgetKey = UniqueKey();
var scrollController = ScrollController(); var scrollController = ScrollController();
final _key = GlobalKey(); final _key = GlobalKey();
@@ -404,7 +412,6 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
setState(() { setState(() {
keys = List.from(reorderFunc(keys)); keys = List.from(reorderFunc(keys));
}); });
updateSetting();
}, },
children: tiles, children: tiles,
builder: (children) { builder: (children) {
@@ -424,7 +431,11 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
title: widget.title, title: widget.title,
tailing: [ tailing: [
if (keys.length < widget.pages.length) if (keys.length < widget.pages.length)
IconButton(onPressed: showAddDialog, icon: const Icon(Icons.add)) TextButton.icon(
label: Text("Add".tl),
icon: const Icon(Icons.add),
onPressed: showAddDialog,
)
], ],
body: view, body: view,
); );
@@ -438,9 +449,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
setState(() { setState(() {
keys.remove(key); keys.remove(key);
}); });
updateSetting();
}, },
icon: const Icon(Icons.delete)), icon: const Icon(Icons.delete_outline)),
); );
return ListTile( return ListTile(
@@ -463,30 +473,68 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
canAdd[key] = value; canAdd[key] = value;
} }
}); });
var selected = <String>[];
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return ContentDialog( return StatefulBuilder(builder: (context, setState) {
title: "Add".tl, return ContentDialog(
content: Column( title: "Add".tl,
mainAxisSize: MainAxisSize.min, content: Column(
children: canAdd.entries mainAxisSize: MainAxisSize.min,
.map( children: canAdd.entries
(e) => ListTile( .map(
title: Text(e.value), (e) => CheckboxListTile(
key: Key(e.key), value: selected.contains(e.key),
onTap: () { title: Text(e.value),
context.pop(); key: Key(e.key),
setState(() { onChanged: (value) {
keys.add(e.key); setState(() {
}); if (value!) {
updateSetting(); selected.add(e.key);
}, } else {
), selected.remove(e.key);
}
});
},
),
)
.toList(),
),
actions: [
if (selected.length < canAdd.length)
TextButton(
child: Text("Select All".tl),
onPressed: () {
setState(() {
selected = canAdd.keys.toList();
});
},
) )
.toList(), else
), TextButton(
); child: Text("Deselect All".tl),
onPressed: () {
setState(() {
selected.clear();
});
},
),
const SizedBox(width: 8),
FilledButton(
onPressed: selected.isNotEmpty
? () {
this.setState(() {
keys.addAll(selected);
});
Navigator.pop(context);
}
: null,
child: Text("Add".tl),
),
],
);
});
}, },
); );
} }

View File

@@ -170,44 +170,78 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
), ),
), ),
), ),
Expanded(child: buildRight()) Expanded(
], child: AnimatedSwitcher(
); duration: const Duration(milliseconds: 200),
} else { transitionBuilder: (child, animation) {
return Stack( return LayoutBuilder(
children: [ builder: (context, constrains) {
Positioned.fill(child: buildLeft()), return AnimatedBuilder(
Positioned( animation: animation,
left: offset, builder: (context, _) {
right: 0, var width = constrains.maxWidth;
top: 0, var value = animation.isForwardOrCompleted
bottom: 0, ? 1 - animation.value
child: Listener( : 1;
onPointerDown: handlePointerDown, var left = width * value;
child: AnimatedSwitcher( return Stack(
duration: const Duration(milliseconds: 300), children: [
reverseDuration: const Duration(milliseconds: 300), Positioned(
switchInCurve: Curves.fastOutSlowIn, top: 0,
switchOutCurve: Curves.fastOutSlowIn, bottom: 0,
transitionBuilder: (child, animation) { left: left,
var tween = Tween<Offset>( width: width,
begin: const Offset(1, 0), end: const Offset(0, 0)); child: child,
),
return SlideTransition( ],
position: tween.animate(animation), );
child: child, },
); );
}, },
child: currentPage == -1 );
? const SizedBox( },
key: Key("1"), child: buildRight(),
)
: buildRight(),
),
), ),
) )
], ],
); );
} else {
return LayoutBuilder(
builder: (context, constrains) {
return Stack(
children: [
Positioned.fill(child: buildLeft()),
Positioned(
left: offset,
width: constrains.maxWidth,
top: 0,
bottom: 0,
child: Listener(
onPointerDown: handlePointerDown,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
transitionBuilder: (child, animation) {
var tween = Tween<Offset>(
begin: const Offset(1, 0), end: const Offset(0, 0));
return SlideTransition(
position: tween.animate(animation),
child: child,
);
},
child: Material(
key: ValueKey(currentPage),
child: buildRight(),
),
),
),
)
],
);
},
);
} }
} }
@@ -307,7 +341,7 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
} }
Widget buildRight() { Widget buildRight() {
final Widget body = switch (currentPage) { return switch (currentPage) {
-1 => const SizedBox(), -1 => const SizedBox(),
0 => const ExploreSettings(), 0 => const ExploreSettings(),
1 => const ReaderSettings(), 1 => const ReaderSettings(),
@@ -318,10 +352,6 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
6 => const AboutSettings(), 6 => const AboutSettings(),
_ => throw UnimplementedError() _ => throw UnimplementedError()
}; };
return Material(
child: body,
);
} }
var canPop = ValueNotifier(true); var canPop = ValueNotifier(true);

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

@@ -55,7 +55,7 @@ class DataSync with ChangeNotifier {
} }
Future<Res<bool>> uploadData() async { Future<Res<bool>> uploadData() async {
if(isDownloading) return const Res(true); if (isDownloading) return const Res(true);
if (haveWaitingTask) return const Res(true); if (haveWaitingTask) return const Res(true);
while (isUploading) { while (isUploading) {
haveWaitingTask = true; haveWaitingTask = true;
@@ -109,7 +109,7 @@ class DataSync with ChangeNotifier {
filename += '.venera'; filename += '.venera';
var files = await client.readDir('/'); var files = await client.readDir('/');
files = files.where((e) => e.name!.endsWith('.venera')).toList(); files = files.where((e) => e.name!.endsWith('.venera')).toList();
var old = files.firstWhereOrNull( (e) => e.name!.startsWith("$time-")); var old = files.firstWhereOrNull((e) => e.name!.startsWith("$time-"));
if (old != null) { if (old != null) {
await client.remove(old.name!); await client.remove(old.name!);
} }
@@ -176,8 +176,11 @@ class DataSync with ChangeNotifier {
var files = await client.readDir('/'); var files = await client.readDir('/');
files.sort((a, b) => b.name!.compareTo(a.name!)); files.sort((a, b) => b.name!.compareTo(a.name!));
var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera')); var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera'));
if (file == null) {
throw 'No data file found';
}
var version = var version =
file!.name!.split('-').elementAtOrNull(1)?.split('.').first; file.name!.split('-').elementAtOrNull(1)?.split('.').first;
if (version != null && int.tryParse(version) != null) { if (version != null && int.tryParse(version) != null) {
var currentVersion = appdata.settings['dataVersion']; var currentVersion = appdata.settings['dataVersion'];
if (currentVersion != null && int.parse(version) <= currentVersion) { if (currentVersion != null && int.parse(version) <= currentVersion) {

View File

@@ -26,7 +26,7 @@ class Image {
var codec = await ui.instantiateImageCodec(data); var codec = await ui.instantiateImageCodec(data);
var frame = await codec.getNextFrame(); var frame = await codec.getNextFrame();
codec.dispose(); codec.dispose();
var info = await frame.image.toByteData(); var info = await frame.image.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
if (info == null) { if (info == null) {
throw Exception('Failed to decode image'); throw Exception('Failed to decode image');
} }
@@ -39,6 +39,14 @@ class Image {
return image; return image;
} }
Color getPixelAtIndex(int index) {
if (index < 0 || index >= _data.length) {
throw ArgumentError(
'Invalid argument: index must be in the range of [0, ${_data.length}).');
}
return Color.fromValue(_data[index]);
}
Image copyRange(int x, int y, int width, int height) { Image copyRange(int x, int y, int width, int height) {
if (width + x > this.width) { if (width + x > this.width) {
throw ArgumentError(''' throw ArgumentError('''
@@ -176,11 +184,11 @@ class Color {
Color.fromValue(this.value); Color.fromValue(this.value);
int get r => (value >> 16) & 0xFF; int get r => value & 0xFF;
int get g => (value >> 8) & 0xFF; int get g => (value >> 8) & 0xFF;
int get b => value & 0xFF; int get b => (value >> 16) & 0xFF;
int get a => (value >> 24) & 0xFF; int get a => (value >> 24) & 0xFF;
} }

View File

@@ -1,33 +1,28 @@
import 'dart:async';
import 'dart:convert';
import 'dart:isolate'; import 'dart:isolate';
import 'package:venera/foundation/app.dart';
import 'package:pdf/widgets.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/utils/image.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:zip_flutter/zip_flutter.dart';
typedef DecodeImage = Future<Image> Function(Uint8List data);
Future<void> _createPdfFromComic({ Future<void> _createPdfFromComic({
required LocalComic comic, required LocalComic comic,
required String savePath, required String savePath,
required String localPath, required String localPath,
required DecodeImage decodeImage,
}) async { }) async {
final pdf = Document( var images = <String>[];
title: comic.title,
author: comic.subTitle ?? "",
producer: "Venera",
);
pdf.document.outline;
var baseDir = comic.directory.contains('/') || comic.directory.contains('\\') var baseDir = comic.directory.contains('/') || comic.directory.contains('\\')
? comic.directory ? comic.directory
: FilePath.join(localPath, comic.directory); : FilePath.join(localPath, comic.directory);
// add cover // add cover
var imageData = File(FilePath.join(baseDir, comic.cover)).readAsBytesSync(); images.add(FilePath.join(baseDir, comic.cover));
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
bool multiChapters = comic.chapters != null; bool multiChapters = comic.chapters != null;
@@ -51,42 +46,360 @@ Future<void> _createPdfFromComic({
reorderFiles(files); reorderFiles(files);
for (var file in files) { for (var file in files) {
var imageData = (file as File).readAsBytesSync(); images.add(file.path);
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
} }
} else { } else {
for (var chapter in comic.chapters!.keys) { for (var chapter in comic.chapters!.keys) {
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) {
var imageData = (file as File).readAsBytesSync(); images.add(file.path);
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
} }
} }
} }
final file = File(savePath); var generator = PdfGenerator(
file.writeAsBytesSync(await pdf.save()); title: comic.title,
author: comic.subtitle,
imagePaths: images,
outputPath: savePath,
decodeImage: decodeImage,
);
await generator.generate();
}
Future<Isolate> _runIsolate(
LocalComic comic, String savePath, SendPort sendPort) {
var localPath = LocalManager().path;
return Isolate.spawn<SendPort>(
(sendPort) => overrideIO(
() async {
var receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
Completer<Image>? completer;
Future<Image> decodeImage(Uint8List data) async {
if (completer != null) {
throw Exception('Another image is being decoded');
}
sendPort.send(data);
completer = Completer();
return completer!.future;
}
receivePort.listen((message) {
if (message is Image) {
if (completer == null) {
throw Exception('No image is being decoded');
}
completer!.complete(message);
completer = null;
}
});
await _createPdfFromComic(
comic: comic,
savePath: savePath,
localPath: localPath,
decodeImage: decodeImage,
);
sendPort.send(null);
},
),
sendPort,
);
} }
Future<void> createPdfFromComicIsolate({ Future<void> createPdfFromComicIsolate({
required LocalComic comic, required LocalComic comic,
required String savePath, required String savePath,
}) async { }) async {
var localPath = LocalManager().path; var receivePort = ReceivePort();
return Isolate.run(() => overrideIO(() async { SendPort? sendPort;
return await _createPdfFromComic( Isolate? isolate;
comic: comic, var completer = Completer<void>();
savePath: savePath, receivePort.listen((message) {
localPath: localPath, if (message is SendPort) {
); sendPort = message;
})); } else if (message is Uint8List) {
Image.decodeImage(message).then((image) {
sendPort!.send(image);
});
} else if (message == null) {
receivePort.close();
completer.complete();
isolate!.kill();
}
});
isolate = await _runIsolate(comic, savePath, receivePort.sendPort);
return completer.future;
}
class PdfGenerator {
final String title;
final String author;
final List<String> imagePaths;
final String outputPath;
final DecodeImage decodeImage;
// PDF文件的对象ID计数器
int _objectId = 1;
// 存储每个对象在PDF中的字节位置
final Map<int, int> _objectOffsets = {};
static const double a4Width = 595.0; // points
static const double a4Height = 842.0; // points
PdfGenerator({
required this.title,
required this.author,
required this.imagePaths,
required this.outputPath,
required this.decodeImage,
});
Future<void> generate() async {
var file = File(outputPath);
final output = file.openWrite();
int length = 0;
void write(String str) {
var data = utf8.encode(str);
output.add(data);
length += data.length;
}
void writeData(Uint8List data) {
output.add(data);
length += data.length;
}
int getCurrentLength() {
return length;
}
// 1. 写入PDF头部
write('%PDF-1.7\n%\xFF\xFF\xFF\xFF\n\n');
// 2. 写入Catalog对象
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Type /Catalog\n');
write('/Pages ${_objectId + 1} 0 R\n');
write('>>\nendobj\n\n');
final catalogId = _objectId++;
// 3. 写入Pages对象
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Type /Pages\n');
write('/Kids [');
final pageIds = <int>[];
for (var i = 0; i < imagePaths.length; i++) {
pageIds.add(_objectId + 1 + i * 3);
write('${_objectId + 1 + i * 3} 0 R ');
}
write(']\n');
write('/Count ${imagePaths.length}\n');
write('>>\nendobj\n\n');
final pagesId = _objectId++;
// 4. 为每个图片创建Page和Image对象
for (var i = 0; i < imagePaths.length; i++) {
final imagePath = imagePaths[i];
final image = await _getImage(imagePath);
// 写入Page对象
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Type /Page\n');
write('/Parent $pagesId 0 R\n');
write('/Resources <<\n');
write('/XObject << /Im${i + 1} ${_objectId + 1} 0 R >>\n');
write('>>\n');
write('/MediaBox [0 0 $a4Width $a4Height]\n');
write('/Contents ${_objectId + 2} 0 R\n');
write('>>\nendobj\n\n');
_objectId++;
// 写入Image对象
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Type /XObject\n');
write('/Subtype /Image\n');
write('/Width ${image.width}\n');
write('/Height ${image.height}\n');
write('/ColorSpace /DeviceRGB\n');
write('/BitsPerComponent 8\n');
write('/Filter /FlateDecode\n');
write('/Length ${image.data.length}\n');
write('>>\nstream\n');
writeData(image.data);
write('\nendstream\nendobj\n\n');
_objectId++;
// 写入Contents对象绘制图片的指令
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
var stream = '';
stream += 'q\n';
// Calculate scaling factors
var scaleX = a4Width / image.width;
var scaleY = a4Height / image.height;
var scale = scaleX < scaleY ? scaleX : scaleY;
// Calculate centering offsets
var offsetX = (a4Width - (image.width * scale)) / 2;
var offsetY = (a4Height - (image.height * scale)) / 2;
// Apply transformation matrix
stream += '1 0 0 1 $offsetX $offsetY cm\n'; // Translate
stream += '${scale * image.width} 0 0 ${scale * image.height} 0 0 cm\n';
stream += '/Im${i + 1} Do\n';
stream += 'Q\n';
var streamData = utf8.encode(stream);
write('/Length ${streamData.length}\n');
write('>>\nstream\n');
writeData(streamData);
write('endstream\nendobj\n\n');
_objectId++;
}
// 5. 写入Info对象元数据
final infoId = _objectId;
_objectOffsets[_objectId] = getCurrentLength();
write('$_objectId 0 obj\n');
write('<<\n');
write('/Title <');
writeData(_toPdfString(title));
write('>\n');
write('/Author <');
writeData(_toPdfString(author));
write('>\n');
write('/Producer (venera v${App.version})\n');
write('/CreationDate (D:${_formatDateTime(DateTime.now())})\n');
write('>>\nendobj\n\n');
_objectId++;
// 6. 写入交叉引用表
final xrefOffset = getCurrentLength();
write('xref\n');
write('0 $_objectId\n');
write('0000000000 65535 f\r\n');
for (var i = 1; i < _objectId; i++) {
final offset = _objectOffsets[i]!;
write('${offset.toString().padLeft(10, '0')} 00000 n\r\n'); // 使用\r\n
}
// 7. 写入文件尾部
write('trailer\n');
write('<<\n');
write('/Size $_objectId\n');
write('/Root $catalogId 0 R\n');
write('/Info $infoId 0 R\n');
write('>>\n');
write('startxref\n');
write('$xrefOffset\n');
write('%%EOF\n');
await output.close();
}
int _codeUnitForDigit(int digit) =>
digit < 10 ? digit + 0x30 : digit + 0x61 - 10;
Uint8List _toPdfString(String str) {
Uint8List data;
try {
data = latin1.encode(str);
} catch (e) {
data = Uint8List.fromList(<int>[0xfe, 0xff] + _encodeUtf16be(str));
}
var result = <int>[];
for (final byte in data) {
result.add(_codeUnitForDigit((byte & 0xF0) >> 4));
result.add(_codeUnitForDigit(byte & 0x0F));
}
return Uint8List.fromList(result);
}
List<int> _encodeUtf16be(String str) {
const unicodeReplacementCharacterCodePoint = 0xfffd;
const unicodeByteZeroMask = 0xff;
const unicodeByteOneMask = 0xff00;
const unicodeValidRangeMax = 0x10ffff;
const unicodePlaneOneMax = 0xffff;
const unicodeUtf16ReservedLo = 0xd800;
const unicodeUtf16ReservedHi = 0xdfff;
const unicodeUtf16Offset = 0x10000;
const unicodeUtf16SurrogateUnit0Base = 0xd800;
const unicodeUtf16SurrogateUnit1Base = 0xdc00;
const unicodeUtf16HiMask = 0xffc00;
const unicodeUtf16LoMask = 0x3ff;
final encoding = <int>[];
void add(int unit) {
encoding.add((unit & unicodeByteOneMask) >> 8);
encoding.add(unit & unicodeByteZeroMask);
}
for (final unit in str.codeUnits) {
if ((unit >= 0 && unit < unicodeUtf16ReservedLo) ||
(unit > unicodeUtf16ReservedHi && unit <= unicodePlaneOneMax)) {
add(unit);
} else if (unit > unicodePlaneOneMax && unit <= unicodeValidRangeMax) {
final base = unit - unicodeUtf16Offset;
add(unicodeUtf16SurrogateUnit0Base +
((base & unicodeUtf16HiMask) >> 10));
add(unicodeUtf16SurrogateUnit1Base + (base & unicodeUtf16LoMask));
} else {
add(unicodeReplacementCharacterCodePoint);
}
}
return encoding;
}
// 格式化日期时间
String _formatDateTime(DateTime dt) {
return dt
.toUtc()
.toString()
.replaceAll('-', '')
.replaceAll(':', '')
.replaceAll(' ', '')
.replaceAll('.', '')
.substring(0, 14);
}
Future<({int width, int height, Uint8List data})> _getImage(
String imagePath) async {
var data = await File(imagePath).readAsBytes();
var image = await decodeImage(data);
var width = image.width;
var height = image.height;
data = Uint8List(width * height * 3);
for (var i = 0; i < width * height; i++) {
var pixel = image.getPixelAtIndex(i);
data[i * 3] = pixel.r;
data[i * 3 + 1] = pixel.g;
data[i * 3 + 2] = pixel.b;
}
data = tdeflCompressData(data, true, true, 9);
return (width: width, height: height, data: data);
}
} }

View File

@@ -33,14 +33,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -57,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003
url: "https://pub.dev"
source: hosted
version: "2.2.8"
battery_plus: battery_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -81,14 +65,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
bidi:
dependency: transitive
description:
name: bidi
sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d"
url: "https://pub.dev"
source: hosted
version: "2.0.12"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -298,6 +274,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
flex_seed_scheme:
dependency: "direct main"
description:
name: flex_seed_scheme
sha256: d3ba3c5c92d2d79d45e94b4c6c71d01fac3c15017da1545880c53864da5dfeb0
url: "https://pub.dev"
source: hosted
version: "3.5.0"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -417,8 +401,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "598d50572a658f8e04775566fe3789954d9a01e3" ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
resolved-ref: "598d50572a658f8e04775566fe3789954d9a01e3" resolved-ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
url: "https://github.com/wgh136/flutter_qjs" url: "https://github.com/wgh136/flutter_qjs"
source: git source: git
version: "0.3.7" version: "0.3.7"
@@ -521,14 +505,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.0" version: "0.1.0"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -690,14 +666,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -746,14 +714,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07"
url: "https://pub.dev"
source: hosted
version: "3.11.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -795,14 +755,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.9.1" version: "3.9.1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
rhttp: rhttp:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -977,14 +929,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.3" version: "0.7.3"
text_scroll:
dependency: "direct main"
description:
name: text_scroll
sha256: "7869d86a6fdd725dee56bdd150216a99f0372b82fbfcac319214dbd5f36e1908"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1142,18 +1086,18 @@ packages:
dependency: "direct main" 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: ea7fdc86c988174ef3bb80dc26e8e8bfdf634c55930e2d18d7e77e991acf0483 sha256: bbf3160062610a43901b7ebbc6f6dd46519540f03a84027dc7b1fff399dda1ac
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.8" version: "0.0.10"
sdks: sdks:
dart: ">=3.6.0 <4.0.0" dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.2" flutter: ">=3.27.3"

View File

@@ -2,18 +2,16 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.2.1+121 version: 1.2.3+123
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.6.0 <4.0.0'
flutter: 3.27.2 flutter: 3.27.3
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
path_provider: any path_provider: any
flutter_localizations:
sdk: flutter
intl: ^0.19.0 intl: ^0.19.0
window_manager: ^0.4.3 window_manager: ^0.4.3
sqlite3: ^2.4.7 sqlite3: ^2.4.7
@@ -21,11 +19,11 @@ dependencies:
flutter_qjs: flutter_qjs:
git: git:
url: https://github.com/wgh136/flutter_qjs url: https://github.com/wgh136/flutter_qjs
ref: 598d50572a658f8e04775566fe3789954d9a01e3 ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3
crypto: ^3.0.6 crypto: ^3.0.6
dio: ^5.7.0 dio: ^5.7.0
html: ^0.15.5 html: ^0.15.5
pointycastle: any pointycastle: ^3.9.1
url_launcher: ^6.3.0 url_launcher: ^6.3.0
path: ^1.9.0 path: ^1.9.0
photo_view: photo_view:
@@ -40,7 +38,6 @@ dependencies:
ref: 09e756b1f1b04e6298318d99ec20a787fb360f59 ref: 09e756b1f1b04e6298318d99ec20a787fb360f59
path: packages/scrollable_positioned_list path: packages/scrollable_positioned_list
flutter_reorderable_grid_view: ^5.4.0 flutter_reorderable_grid_view: ^5.4.0
yaml: any
uuid: ^4.5.1 uuid: ^4.5.1
desktop_webview_window: desktop_webview_window:
git: git:
@@ -51,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.8 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
@@ -67,16 +64,18 @@ dependencies:
git: git:
url: https://github.com/pkuislm/flutter_saf.git url: https://github.com/pkuislm/flutter_saf.git
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
pdf: ^3.11.1
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
shimmer_animation: ^2.1.0 shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1 flutter_memory_info: ^0.0.1
syntax_highlight: ^0.4.0 syntax_highlight: ^0.4.0
text_scroll: ^0.2.0
flutter_7zip: flutter_7zip:
git: git:
url: https://github.com/wgh136/flutter_7zip url: https://github.com/wgh136/flutter_7zip
ref: b33344797f1d2469339e0e1b75f5f954f1da224c ref: b33344797f1d2469339e0e1b75f5f954f1da224c
flex_seed_scheme: ^3.5.0
flutter_localizations:
sdk: flutter
yaml: ^3.1.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: