mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
24155746f2 | ||
340496da30 | |||
28a56b4612 | |||
4e6f71ef36 | |||
739685f60f | |||
8c5dae1e59 | |||
e2c69d882f | |||
0b9f0b7d35 | |||
9ea749a84a | |||
d675af3fb4 | |||
d99a30b7d8 | |||
![]() |
3c3c07b6fb | ||
![]() |
e688ab759a | ||
![]() |
64a3ef352f | ||
ef8dc9e8d4 | |||
![]() |
19af2d79dd | ||
5a11168f98 | |||
1564156e28 | |||
2534c55ffb | |||
ba4eff66db | |||
b43d907763 | |||
f5a814cfe4 | |||
24b9bcd86e | |||
812b36d1e9 | |||
bab2578b65 | |||
5cf2f9f33a | |||
040a5d7ad2 | |||
69da66904a | |||
11e4d7a9f2 | |||
7bd0c2b82a | |||
6b0a5184b9 | |||
864980079b | |||
de51b66d39 | |||
23205c518d | |||
3ae5c7c7f2 | |||
312e991935 | |||
5184130ff8 | |||
e555779419 | |||
5ef973cbfb | |||
8e2520f8e8 | |||
87f0f5bb55 |
@@ -1,5 +1,4 @@
|
||||
# venera
|
||||
|
||||
[](https://flutter.dev/)
|
||||
[](https://github.com/venera-app/venera/blob/master/LICENSE)
|
||||
[](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/)
|
||||
|
||||
## Features
|
||||
|
||||
- Read local comics
|
||||
- Use javascript to create comic 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
|
||||
|
||||
## Build from source
|
||||
|
||||
1. Clone the repository
|
||||
2. Install flutter, see [flutter.dev](https://flutter.dev/docs/get-started/install)
|
||||
3. Install rust, see [rustup.rs](https://rustup.rs/)
|
||||
4. Build for your platform: e.g. `flutter build apk`
|
||||
|
||||
## Create a new comic source
|
||||
|
||||
See [Comic Source](doc/comic_source.md)
|
||||
|
||||
## Thanks
|
||||
|
@@ -83,20 +83,31 @@ android {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
signingConfig signingConfigs.release
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all { output ->
|
||||
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
if (abi != null) {
|
||||
outputFileName = "venera-${variant.versionName}-${abi}.apk"
|
||||
def abiVersionCode = project.ext.abiCodes.get(abi)
|
||||
if (abiVersionCode != null) {
|
||||
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||
}
|
||||
} else {
|
||||
outputFileName = "venera-${variant.versionName}.apk"
|
||||
versionCodeOverride = variant.versionCode * 10
|
||||
}
|
||||
debug {
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
|
||||
}
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.all { output ->
|
||||
def abi = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
if (variant.buildType.name == "release") {
|
||||
if (abi != null) {
|
||||
outputFileName = "venera-${variant.versionName}-${abi}.apk"
|
||||
def abiVersionCode = project.ext.abiCodes.get(abi)
|
||||
if (abiVersionCode != null) {
|
||||
versionCodeOverride = variant.versionCode * 10 + abiVersionCode
|
||||
}
|
||||
} else {
|
||||
outputFileName = "venera-${variant.versionName}.apk"
|
||||
versionCodeOverride = variant.versionCode * 10
|
||||
}
|
||||
} else if (variant.buildType.name == "debug") {
|
||||
versionCodeOverride = variant.versionCode * 10 + 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -160,7 +160,7 @@
|
||||
"Date Desc": "日期降序",
|
||||
"Start": "开始",
|
||||
"Export App Data": "导出应用数据",
|
||||
"Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)",
|
||||
"Import App Data": "导入应用数据",
|
||||
"Export": "导出",
|
||||
"Download Threads": "下载线程数",
|
||||
"Update Time": "更新时间",
|
||||
@@ -229,7 +229,7 @@
|
||||
"Clear History": "清除历史",
|
||||
"Are you sure you want to clear your history?": "确定要清除您的历史记录吗?",
|
||||
"No Explore Pages": "没有探索页面",
|
||||
"Add a comic source in home page": "在主页添加一个漫画源",
|
||||
"Please add some sources": "请添加一些源",
|
||||
"Please check your settings": "请检查您的设置",
|
||||
"No Category Pages": "没有分类页面",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
@@ -314,7 +314,14 @@
|
||||
"New Version": "新版本",
|
||||
"@c updates": "@c 项更新",
|
||||
"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": {
|
||||
"Home": "首頁",
|
||||
@@ -477,7 +484,7 @@
|
||||
"Start": "開始",
|
||||
"Reversed successfully": "反轉成功",
|
||||
"Export App Data": "匯出應用數據",
|
||||
"Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)",
|
||||
"Import App Data": "匯入應用數據",
|
||||
"Export": "匯出",
|
||||
"Download Threads": "下載線程數",
|
||||
"Update Time": "更新時間",
|
||||
@@ -546,7 +553,7 @@
|
||||
"Clear History": "清除歷史",
|
||||
"Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?",
|
||||
"No Explore Pages": "沒有探索頁面",
|
||||
"Add a comic source in home page": "在主頁添加一個漫畫源",
|
||||
"Please add some sources": "請添加一些源",
|
||||
"Please check your settings": "請檢查您的設定",
|
||||
"No Category Pages": "沒有分類頁面",
|
||||
"Chapter @ep": "第 @ep 章",
|
||||
@@ -631,6 +638,13 @@
|
||||
"New Version": "新版本",
|
||||
"@c updates": "@c 項更新",
|
||||
"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": "成功"
|
||||
}
|
||||
}
|
3
debian/gui/venera.desktop
vendored
3
debian/gui/venera.desktop
vendored
@@ -5,4 +5,5 @@ Comment=venera
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility
|
||||
Keywords=Flutter;comic;images;
|
||||
Keywords=Flutter;comic;images;
|
||||
Icon=venera
|
@@ -1,12 +1,14 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class Appbar extends StatefulWidget implements PreferredSizeWidget {
|
||||
const Appbar(
|
||||
{required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
super.key});
|
||||
const Appbar({
|
||||
required this.title,
|
||||
this.leading,
|
||||
this.actions,
|
||||
this.backgroundColor,
|
||||
this.style = AppbarStyle.blur,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget title;
|
||||
|
||||
@@ -16,6 +18,8 @@ class Appbar extends StatefulWidget implements PreferredSizeWidget {
|
||||
|
||||
final Color? backgroundColor;
|
||||
|
||||
final AppbarStyle style;
|
||||
|
||||
@override
|
||||
State<Appbar> createState() => _AppbarState();
|
||||
|
||||
@@ -108,10 +112,18 @@ class _AppbarState extends State<Appbar> {
|
||||
],
|
||||
).paddingTop(context.padding.top),
|
||||
);
|
||||
return BlurEffect(
|
||||
blur: _scrolledUnder ? 15 : 0,
|
||||
child: content,
|
||||
);
|
||||
if (widget.style == AppbarStyle.shadow) {
|
||||
return Material(
|
||||
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 {
|
||||
const FilledTabBar({super.key, this.controller, required this.tabs});
|
||||
class AppTabBar extends StatefulWidget {
|
||||
const AppTabBar({
|
||||
super.key,
|
||||
this.controller,
|
||||
required this.tabs,
|
||||
this.actionButton,
|
||||
});
|
||||
|
||||
final TabController? controller;
|
||||
|
||||
final List<Tab> tabs;
|
||||
|
||||
final Widget? actionButton;
|
||||
|
||||
@override
|
||||
State<FilledTabBar> createState() => _FilledTabBarState();
|
||||
State<AppTabBar> createState() => _AppTabBarState();
|
||||
}
|
||||
|
||||
class _FilledTabBarState extends State<FilledTabBar> {
|
||||
class _AppTabBarState extends State<AppTabBar> {
|
||||
late TabController _controller;
|
||||
|
||||
late List<GlobalKey> keys;
|
||||
@@ -315,7 +334,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FilledTabBar oldWidget) {
|
||||
void didUpdateWidget(covariant AppTabBar oldWidget) {
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
_controller = widget.controller ?? DefaultTabController.of(context);
|
||||
_controller.animation!.addListener(onTabChanged);
|
||||
@@ -366,25 +385,27 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
painter: painter,
|
||||
child: _TabRow(
|
||||
callback: _tabLayoutCallback,
|
||||
children: List.generate(widget.tabs.length, buildTab),
|
||||
children: List.generate(widget.tabs.length, buildTab)
|
||||
..addIfNotNull(widget.actionButton?.padding(tabPadding)),
|
||||
),
|
||||
).paddingHorizontal(4),
|
||||
);
|
||||
},
|
||||
);
|
||||
return Container(
|
||||
key: tabBarKey,
|
||||
height: _kTabHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
key: tabBarKey,
|
||||
height: _kTabHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
child: widget.tabs.isEmpty ? const SizedBox() : child);
|
||||
),
|
||||
child: widget.tabs.isEmpty ? const SizedBox() : child,
|
||||
);
|
||||
}
|
||||
|
||||
int? previousIndex;
|
||||
@@ -544,7 +565,7 @@ class _IndicatorPainter extends CustomPainter {
|
||||
|
||||
var rect = Rect.fromLTWH(
|
||||
tabLeft + padding.left + horizontalPadding,
|
||||
_FilledTabBarState._kTabHeight - 3.6,
|
||||
_AppTabBarState._kTabHeight - 3.6,
|
||||
tabRight - tabLeft - padding.horizontal - horizontalPadding * 2,
|
||||
3,
|
||||
);
|
||||
@@ -621,7 +642,6 @@ class _TabViewBodyState extends State<TabViewBody> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SearchBarController {
|
||||
_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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -55,7 +55,7 @@ class _CodeEditorState extends State<CodeEditor> {
|
||||
|
||||
Widget buildLineNumbers() {
|
||||
return SizedBox(
|
||||
width: 32,
|
||||
width: 36,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 1; i <= lineCount; i++)
|
||||
|
@@ -356,14 +356,13 @@ class ComicTile extends StatelessWidget {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 0),
|
||||
child: TextScroll(
|
||||
child: Text(
|
||||
comic.title.replaceAll('\n', ''),
|
||||
mode: TextScrollMode.endless,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.clip,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
delayBefore: Duration(milliseconds: 500),
|
||||
velocity: const Velocity(pixelsPerSecond: Offset(40, 0)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@@ -9,7 +9,6 @@ import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.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_page_route.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
|
@@ -58,26 +58,12 @@ class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
child: AnimatedPhysicalModel(
|
||||
duration: _fastAnimationDuration,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
boxShadow: isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.outline,
|
||||
blurRadius: 2,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
]
|
||||
: [
|
||||
BoxShadow(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
blurRadius: 1,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
elevation: isHovered ? 3 : 1,
|
||||
color: context.colorScheme.surface,
|
||||
shadowColor: context.colorScheme.shadow,
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
|
@@ -277,35 +277,38 @@ class _AnimatedImageState extends State<AnimatedImage>
|
||||
|
||||
if (_imageInfo != null) {
|
||||
if (widget.part != null) {
|
||||
return CustomPaint(
|
||||
result = CustomPaint(
|
||||
isComplex: true,
|
||||
painter: ImagePainter(
|
||||
image: _imageInfo!.image,
|
||||
part: widget.part!,
|
||||
fit: widget.fit ?? BoxFit.cover,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: widget.width,
|
||||
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) {
|
||||
result = const Center(
|
||||
child: Icon(Icons.error),
|
||||
@@ -362,10 +365,13 @@ class ImagePainter extends CustomPainter {
|
||||
|
||||
final ImagePart part;
|
||||
|
||||
final BoxFit fit;
|
||||
|
||||
/// Render a part of the image.
|
||||
const ImagePainter({
|
||||
required this.image,
|
||||
this.part = const ImagePart(),
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -377,7 +383,8 @@ class ImagePainter extends CustomPainter {
|
||||
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());
|
||||
}
|
||||
|
||||
|
@@ -2,7 +2,10 @@ part of 'components.dart';
|
||||
|
||||
class SliverGridViewWithFixedItemHeight extends StatelessWidget {
|
||||
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;
|
||||
|
||||
@@ -62,7 +65,8 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate {
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true;
|
||||
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || oldDelegate.itemHeight != itemHeight) {
|
||||
if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent ||
|
||||
oldDelegate.itemHeight != itemHeight) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -70,28 +74,29 @@ class SliverGridDelegateWithFixedHeight 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
|
||||
SliverGridLayout getLayout(SliverConstraints constraints) {
|
||||
if (appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode) {
|
||||
if (useBriefMode) {
|
||||
return getBriefModeLayout(
|
||||
constraints,
|
||||
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(),
|
||||
scale,
|
||||
);
|
||||
} else {
|
||||
return getDetailedModeLayout(
|
||||
constraints,
|
||||
scale ?? (appdata.settings['comicTileScale'] as num).toDouble(),
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale) {
|
||||
SliverGridLayout getDetailedModeLayout(
|
||||
SliverConstraints constraints, double scale) {
|
||||
const minCrossAxisExtent = 360;
|
||||
final itemHeight = 152 * scale;
|
||||
final width = constraints.crossAxisExtent;
|
||||
@@ -106,11 +111,14 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
reverseCrossAxis: false);
|
||||
}
|
||||
|
||||
SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale) {
|
||||
SliverGridLayout getBriefModeLayout(
|
||||
SliverConstraints constraints, double scale) {
|
||||
final maxCrossAxisExtent = 192.0 * scale;
|
||||
const childAspectRatio = 0.68;
|
||||
const childAspectRatio = 0.64;
|
||||
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
|
||||
// below when the window size is 0.
|
||||
crossAxisCount = math.max(1, crossAxisCount);
|
||||
@@ -132,6 +140,11 @@ class SliverGridDelegateWithComics extends SliverGridDelegate {
|
||||
|
||||
@override
|
||||
bool shouldRelayout(covariant SliverGridDelegate oldDelegate) {
|
||||
return true;
|
||||
if (oldDelegate is! SliverGridDelegateWithComics) return true;
|
||||
if (oldDelegate.scale != scale ||
|
||||
oldDelegate.useBriefMode != useBriefMode) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ class NetworkError extends StatelessWidget {
|
||||
required this.message,
|
||||
this.retry,
|
||||
this.withAppbar = true,
|
||||
this.buttonText,
|
||||
});
|
||||
|
||||
final String message;
|
||||
@@ -14,6 +15,8 @@ class NetworkError extends StatelessWidget {
|
||||
|
||||
final bool withAppbar;
|
||||
|
||||
final String? buttonText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var cfe = CloudflareException.fromString(message);
|
||||
@@ -54,13 +57,15 @@ class NetworkError extends StatelessWidget {
|
||||
if (cfe != null)
|
||||
FilledButton(
|
||||
onPressed: () => passCloudflare(
|
||||
CloudflareException.fromString(message)!, retry!),
|
||||
CloudflareException.fromString(message)!,
|
||||
retry!,
|
||||
),
|
||||
child: Text('Verify'.tl),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
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) {
|
||||
return res;
|
||||
} else {
|
||||
if(!mounted) return res;
|
||||
if (!mounted) return res;
|
||||
if (retry >= 3) {
|
||||
return res;
|
||||
}
|
||||
@@ -185,7 +190,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
||||
isLoading = true;
|
||||
Future.microtask(() {
|
||||
loadDataWithRetry().then((value) async {
|
||||
if(!mounted) return;
|
||||
if (!mounted) return;
|
||||
if (value.success) {
|
||||
data = value.data;
|
||||
await onDataLoaded();
|
||||
@@ -318,21 +323,11 @@ abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object>
|
||||
}
|
||||
|
||||
Widget buildError(BuildContext context, String error) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(error, maxLines: 3),
|
||||
const SizedBox(height: 12),
|
||||
Button.outlined(
|
||||
onPressed: () {
|
||||
reset();
|
||||
},
|
||||
child: const Text("Retry"),
|
||||
)
|
||||
],
|
||||
),
|
||||
).paddingHorizontal(16);
|
||||
return NetworkError(
|
||||
withAppbar: false,
|
||||
message: error,
|
||||
retry: reset,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -98,8 +98,17 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
_controller.position.maxScrollExtent,
|
||||
);
|
||||
if (_futurePosition == old) return;
|
||||
_controller.animateTo(_futurePosition!,
|
||||
duration: _fastAnimationDuration, curve: Curves.linear);
|
||||
var target = _futurePosition!;
|
||||
_controller.animateTo(
|
||||
_futurePosition!,
|
||||
duration: _fastAnimationDuration,
|
||||
curve: Curves.linear,
|
||||
).then((_) {
|
||||
var current = _controller.position.pixels;
|
||||
if (current == target && current == _futurePosition) {
|
||||
_futurePosition = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ScrollControllerProvider._(
|
||||
|
@@ -10,7 +10,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.2.1";
|
||||
final version = "1.2.3";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_saf/flutter_saf.dart';
|
||||
import 'package:rhttp/rhttp.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/cache_manager.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/log.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/translations.dart';
|
||||
|
||||
import 'foundation/appdata.dart';
|
||||
|
||||
extension FutureInit<T> on Future<T> {
|
||||
extension _FutureInit<T> on Future<T> {
|
||||
/// Prevent unhandled exception
|
||||
///
|
||||
/// 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 {
|
||||
await Rhttp.init();
|
||||
await SAFTaskWorker().init().wait();
|
||||
await AppTranslation.init().wait();
|
||||
await appdata.init().wait();
|
||||
@@ -39,4 +42,11 @@ Future<void> init() async {
|
||||
await ComicSource.init().wait();
|
||||
await LocalManager().init().wait();
|
||||
CacheManager().setLimitSize(appdata.settings['cacheSize']);
|
||||
if (App.isAndroid) {
|
||||
handleLinks();
|
||||
}
|
||||
FlutterError.onError = (details) {
|
||||
Log.error(
|
||||
"Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
};
|
||||
}
|
@@ -1,14 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'package:desktop_webview_window/desktop_webview_window.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/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:rhttp/rhttp.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/auth_page.dart';
|
||||
import 'package:venera/pages/main_page.dart';
|
||||
import 'package:venera/utils/app_links.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'components/components.dart';
|
||||
@@ -18,21 +17,11 @@ import 'foundation/appdata.dart';
|
||||
import 'init.dart';
|
||||
|
||||
void main(List<String> args) {
|
||||
if (runWebViewTitleBarWidget(args)) {
|
||||
return;
|
||||
}
|
||||
if (runWebViewTitleBarWidget(args)) return;
|
||||
overrideIO(() {
|
||||
runZonedGuarded(() async {
|
||||
await Rhttp.init();
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await init();
|
||||
if (App.isAndroid) {
|
||||
handleLinks();
|
||||
}
|
||||
FlutterError.onError = (details) {
|
||||
Log.error(
|
||||
"Unhandled Exception", "${details.exception}\n${details.stack}");
|
||||
};
|
||||
runApp(const MyApp());
|
||||
if (App.isDesktop) {
|
||||
await windowManager.ensureInitialized();
|
||||
@@ -55,7 +44,7 @@ void main(List<String> args) {
|
||||
});
|
||||
}
|
||||
}, (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();
|
||||
}
|
||||
return DynamicColorBuilder(builder: (light, dark) {
|
||||
Color? primary, secondary, tertiary;
|
||||
if (appdata.settings['color'] != 'system' ||
|
||||
light == null ||
|
||||
dark == null) {
|
||||
var color = translateColorSetting();
|
||||
light = ColorScheme.fromSeed(
|
||||
seedColor: color,
|
||||
surface: Colors.white,
|
||||
);
|
||||
dark = ColorScheme.fromSeed(
|
||||
seedColor: color,
|
||||
brightness: Brightness.dark,
|
||||
surface: Colors.black,
|
||||
);
|
||||
primary = translateColorSetting();
|
||||
} else {
|
||||
light = ColorScheme.fromSeed(
|
||||
seedColor: light.primary,
|
||||
surface: Colors.white,
|
||||
);
|
||||
dark = ColorScheme.fromSeed(
|
||||
seedColor: dark.primary,
|
||||
brightness: Brightness.dark,
|
||||
surface: Colors.black,
|
||||
);
|
||||
primary = light.primary;
|
||||
secondary = light.secondary;
|
||||
tertiary = light.tertiary;
|
||||
}
|
||||
return MaterialApp(
|
||||
home: home,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
colorScheme: light,
|
||||
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
|
||||
colorScheme: SeedColorScheme.fromSeeds(
|
||||
primaryKey: primary,
|
||||
secondaryKey: secondary,
|
||||
tertiaryKey: tertiary,
|
||||
tones: FlexTones.vividBackground(Brightness.light),
|
||||
),
|
||||
),
|
||||
navigatorKey: App.rootNavigatorKey,
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: dark,
|
||||
fontFamily: App.isWindows ? "Microsoft YaHei" : null,
|
||||
colorScheme: SeedColorScheme.fromSeeds(
|
||||
primaryKey: primary,
|
||||
secondaryKey: secondary,
|
||||
tertiaryKey: tertiary,
|
||||
brightness: Brightness.dark,
|
||||
tones: FlexTones.vividBackground(Brightness.dark),
|
||||
),
|
||||
),
|
||||
themeMode: switch (appdata.settings['theme_mode']) {
|
||||
'light' => ThemeMode.light,
|
||||
'dark' => ThemeMode.dark,
|
||||
_ => ThemeMode.system
|
||||
},
|
||||
localizationsDelegates: const [
|
||||
localizationsDelegates: [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
locale: () {
|
||||
@@ -215,9 +198,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
};
|
||||
}(),
|
||||
supportedLocales: const [
|
||||
Locale('en'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
Locale('en'),
|
||||
],
|
||||
builder: (context, widget) {
|
||||
ErrorWidget.builder = (details) {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
|
||||
class NetworkCache {
|
||||
final Uri uri;
|
||||
@@ -117,7 +117,7 @@ class NetworkCacheManager implements Interceptor {
|
||||
var o = options.copyWith(
|
||||
method: "HEAD",
|
||||
);
|
||||
var dio = Dio();
|
||||
var dio = AppDio();
|
||||
var response = await dio.fetch(o);
|
||||
if (response.statusCode == 200 &&
|
||||
compareHeaders(cache.responseHeaders, response.headers.map)) {
|
||||
|
@@ -1,9 +1,11 @@
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
import 'package:venera/pages/webview.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
@@ -58,7 +60,7 @@ class CloudflareException implements DioException {
|
||||
class CloudflareInterceptor extends Interceptor {
|
||||
@override
|
||||
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;
|
||||
}
|
||||
handler.next(options);
|
||||
@@ -120,16 +122,25 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
|
||||
var webview = DesktopWebview(
|
||||
initialUrl: url,
|
||||
onTitleChange: (title, controller) async {
|
||||
var res = await controller.evaluateJavascript(
|
||||
"document.head.innerHTML.includes('#challenge-success-text')");
|
||||
if (res == 'false') {
|
||||
var head =
|
||||
await controller.evaluateJavascript("document.head.innerHTML") ??
|
||||
"";
|
||||
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;
|
||||
if (ua != null) {
|
||||
appdata.implicitData['ua'] = ua;
|
||||
appdata.writeImplicitData();
|
||||
}
|
||||
var cookiesMap = await controller.getCookies(url);
|
||||
if(cookiesMap['cf_clearance'] == null) {
|
||||
if (cookiesMap['cf_clearance'] == null) {
|
||||
return;
|
||||
}
|
||||
saveCookies(cookiesMap);
|
||||
@@ -137,30 +148,47 @@ void passCloudflare(CloudflareException e, void Function() onFinished) async {
|
||||
onFinished();
|
||||
}
|
||||
},
|
||||
onClose: onFinished,
|
||||
);
|
||||
webview.open();
|
||||
} 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(
|
||||
() => AppWebview(
|
||||
initialUrl: url,
|
||||
singlePage: true,
|
||||
onTitleChange: (title, controller) async {
|
||||
check(controller);
|
||||
},
|
||||
onLoadStop: (controller) async {
|
||||
var res = await controller.platform.evaluateJavascript(
|
||||
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();
|
||||
}
|
||||
check(controller);
|
||||
},
|
||||
onStarted: (controller) async {
|
||||
var ua = await controller.getUA();
|
||||
|
@@ -59,6 +59,16 @@ abstract class DownloadTask with ChangeNotifier {
|
||||
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 {
|
||||
@@ -220,7 +230,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
runRecorder();
|
||||
|
||||
if (comic == null) {
|
||||
var res = await runWithRetry(() async {
|
||||
_message = "Fetching comic info...";
|
||||
notifyListeners();
|
||||
var res = await _runWithRetry(() async {
|
||||
var r = await source.loadComicInfo!(comicId);
|
||||
if (r.error) {
|
||||
throw r.errorMessage!;
|
||||
@@ -260,7 +272,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
await LocalManager().saveCurrentDownloadingTasks();
|
||||
|
||||
if (_cover == null) {
|
||||
var res = await runWithRetry(() async {
|
||||
_message = "Downloading cover...";
|
||||
notifyListeners();
|
||||
var res = await _runWithRetry(() async {
|
||||
Uint8List? data;
|
||||
await for (var progress
|
||||
in ImageDownloader.loadThumbnail(comic!.cover, source.key)) {
|
||||
@@ -272,8 +286,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
throw "Failed to download cover";
|
||||
}
|
||||
var fileType = detectFileType(data);
|
||||
var file =
|
||||
File(FilePath.join(path!, "cover${fileType.ext}"));
|
||||
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
|
||||
file.writeAsBytesSync(data);
|
||||
return "file://${file.path}";
|
||||
});
|
||||
@@ -290,7 +303,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
|
||||
if (_images == 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);
|
||||
if (r.error) {
|
||||
throw r.errorMessage!;
|
||||
@@ -312,6 +327,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
} else {
|
||||
_images = {};
|
||||
_totalCount = 0;
|
||||
int cpCount = 0;
|
||||
int totalCpCount = chapters?.length ?? comic!.chapters!.length;
|
||||
for (var i in comic!.chapters!.keys) {
|
||||
if (chapters != null && !chapters!.contains(i)) {
|
||||
continue;
|
||||
@@ -320,7 +337,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
_totalCount += _images![i]!.length;
|
||||
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);
|
||||
if (r.error) {
|
||||
throw r.errorMessage!;
|
||||
@@ -458,8 +477,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
}).toList(),
|
||||
directory: Directory(path!).name,
|
||||
chapters: comic!.chapters,
|
||||
cover:
|
||||
File(_cover!.split("file://").last).name,
|
||||
cover: File(_cover!.split("file://").last).name,
|
||||
comicType: ComicType(source.key.hashCode),
|
||||
downloadedChapters: chapters ?? [],
|
||||
createdAt: DateTime.now(),
|
||||
@@ -478,7 +496,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||
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 {
|
||||
for (var i = 0; i < retry; i++) {
|
||||
try {
|
||||
@@ -487,6 +505,7 @@ Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
|
||||
if (i == retry - 1) {
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
await Future.delayed(Duration(seconds: i + 1));
|
||||
}
|
||||
}
|
||||
throw UnimplementedError();
|
||||
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,80 +3,128 @@ import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/state_controller.dart';
|
||||
import 'package:venera/pages/ranking_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 'category_comics_page.dart';
|
||||
import 'comic_source_page.dart';
|
||||
|
||||
class CategoriesPage extends StatelessWidget {
|
||||
class CategoriesPage extends StatefulWidget {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return StateBuilder<SimpleController>(
|
||||
tag: "category",
|
||||
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 buildEmpty();
|
||||
}
|
||||
|
||||
if(categories.isEmpty) {
|
||||
var msg = "No Category Pages".tl;
|
||||
msg += '\n';
|
||||
if(ComicSource.isEmpty) {
|
||||
msg += "Add a comic source in home page".tl;
|
||||
} else {
|
||||
msg += "Please check your settings".tl;
|
||||
}
|
||||
return NetworkError(
|
||||
message: msg,
|
||||
retry: () {
|
||||
controller.update();
|
||||
},
|
||||
withAppbar: false,
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
child: DefaultTabController(
|
||||
length: categories.length,
|
||||
key: Key(categories.toString()),
|
||||
child: Column(
|
||||
children: [
|
||||
FilledTabBar(
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
try {
|
||||
title = getCategoryDataWithKey(e).title;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return Tab(
|
||||
text: title,
|
||||
key: Key(e),
|
||||
);
|
||||
}).toList(),
|
||||
).paddingTop(context.padding.top),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children:
|
||||
categories.map((e) => _CategoryPage(e)).toList()),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
return Material(
|
||||
child: DefaultTabController(
|
||||
length: categories.length,
|
||||
key: Key(categories.toString()),
|
||||
child: Column(
|
||||
children: [
|
||||
AppTabBar(
|
||||
key: PageStorageKey(categories.toString()),
|
||||
tabs: categories.map((e) {
|
||||
String title = e;
|
||||
try {
|
||||
title = getCategoryDataWithKey(e).title;
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
return Tab(
|
||||
text: title,
|
||||
key: Key(e),
|
||||
);
|
||||
}).toList(),
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: categories.map((e) => _CategoryPage(e)).toList(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1283,7 +1283,9 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
y2 = double.parse(r.split('-')[1]);
|
||||
}
|
||||
}
|
||||
} finally {}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2);
|
||||
}
|
||||
return Padding(
|
||||
@@ -1297,30 +1299,29 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
child: InkWell(
|
||||
onTap: () => state.read(null, index + 1),
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
url,
|
||||
sourceKey: state.widget.sourceKey,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
part: part,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
url,
|
||||
sourceKey: state.widget.sourceKey,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
part: part,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1336,7 +1337,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 200,
|
||||
childAspectRatio: 0.65,
|
||||
childAspectRatio: 0.68,
|
||||
),
|
||||
),
|
||||
if (error != null)
|
||||
@@ -2000,6 +2001,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget {
|
||||
}
|
||||
|
||||
return Shimmer(
|
||||
color: context.isDarkMode ? Colors.grey.shade700 : Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Appbar(title: Text(""), backgroundColor: context.colorScheme.surface),
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io' as io;
|
||||
import 'package:flutter/material.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';
|
||||
@@ -7,11 +9,13 @@ import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/log.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/io.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
class ComicSourcePage extends StatefulWidget {
|
||||
class ComicSourcePage extends StatelessWidget {
|
||||
const ComicSourcePage({super.key});
|
||||
|
||||
static Future<int> checkComicSourceUpdate() async {
|
||||
@@ -19,8 +23,7 @@ class ComicSourcePage extends StatefulWidget {
|
||||
return 0;
|
||||
}
|
||||
var dio = AppDio();
|
||||
var res = await dio.get<String>(
|
||||
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
|
||||
var res = await dio.get<String>(appdata.settings['comicSourceListUrl']);
|
||||
if (res.statusCode != 200) {
|
||||
return -1;
|
||||
}
|
||||
@@ -45,11 +48,6 @@ class ComicSourcePage extends StatefulWidget {
|
||||
return shouldUpdate.length;
|
||||
}
|
||||
|
||||
@override
|
||||
State<ComicSourcePage> createState() => _ComicSourcePageState();
|
||||
}
|
||||
|
||||
class _ComicSourcePageState extends State<ComicSourcePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -93,167 +91,19 @@ class _BodyState extends State<_Body> {
|
||||
style: AppbarStyle.shadow,
|
||||
),
|
||||
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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
showConfirmDialog(
|
||||
context: App.rootContext,
|
||||
@@ -298,10 +148,12 @@ class _BodyState extends State<_Body> {
|
||||
//
|
||||
}
|
||||
}
|
||||
context.to(() => _EditFilePage(source.filePath)).then((value) async {
|
||||
await ComicSource.reload();
|
||||
setState(() {});
|
||||
});
|
||||
context.to(
|
||||
() => _EditFilePage(source.filePath, () async {
|
||||
await ComicSource.reload();
|
||||
setState(() {});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> update(ComicSource source) async {
|
||||
@@ -419,7 +271,8 @@ class _BodyState extends State<_Body> {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -521,18 +374,29 @@ class _ComicSourceListState extends State<_ComicSourceList> {
|
||||
var key = json![index]["key"];
|
||||
var action = currentKey.contains(key)
|
||||
? const Icon(Icons.check, size: 20).paddingRight(8)
|
||||
: Tooltip(
|
||||
message: "Add",
|
||||
child: Button.icon(
|
||||
color: context.colorScheme.primary,
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () async {
|
||||
await widget.onAdd(
|
||||
"https://raw.githubusercontent.com/venera-app/venera-configs/master/${json![index]["fileName"]}");
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
);
|
||||
: Button.filled(
|
||||
child: Text("Add".tl),
|
||||
onPressed: () async {
|
||||
var fileName = json![index]["fileName"];
|
||||
var url = json![index]["url"];
|
||||
if (url == null || !(url.toString()).isURL) {
|
||||
var listUrl =
|
||||
appdata.settings['comicSourceListUrl'] as String;
|
||||
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(
|
||||
title: Text(json![index]["name"]),
|
||||
@@ -617,10 +481,12 @@ void _addAllPagesWithComicSource(ComicSource source) {
|
||||
}
|
||||
|
||||
class _EditFilePage extends StatefulWidget {
|
||||
const _EditFilePage(this.path);
|
||||
const _EditFilePage(this.path, this.onExit);
|
||||
|
||||
final String path;
|
||||
|
||||
final void Function() onExit;
|
||||
|
||||
@override
|
||||
State<_EditFilePage> createState() => __EditFilePageState();
|
||||
}
|
||||
@@ -637,6 +503,7 @@ class __EditFilePageState extends State<_EditFilePage> {
|
||||
@override
|
||||
void dispose() {
|
||||
File(widget.path).writeAsStringSync(current);
|
||||
widget.onExit();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@@ -73,6 +73,7 @@ class _CommentsPageState extends State<CommentsPage> {
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: Appbar(
|
||||
title: Text("Comments".tl),
|
||||
style: AppbarStyle.shadow,
|
||||
),
|
||||
body: buildBody(context),
|
||||
);
|
||||
@@ -529,6 +530,7 @@ class _Tag {
|
||||
'u' => style.underline,
|
||||
's' => style.lineThrough,
|
||||
'a' => style.withColor(context.colorScheme.primary),
|
||||
'strong' => style.bold,
|
||||
'span' => () {
|
||||
if (attributes.containsKey('style')) {
|
||||
var s = attributes['style']!;
|
||||
@@ -622,10 +624,14 @@ class RichCommentContent extends StatefulWidget {
|
||||
class _RichCommentContentState extends State<RichCommentContent> {
|
||||
var textSpan = <InlineSpan>[];
|
||||
var images = <_CommentImage>[];
|
||||
bool isRendered = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
render();
|
||||
if (!isRendered) {
|
||||
render();
|
||||
isRendered = true;
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@@ -670,7 +676,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
|
||||
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)) {
|
||||
writeBuffer();
|
||||
if (tagName == 'img') {
|
||||
|
@@ -46,6 +46,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
|
||||
i--;
|
||||
|
||||
return _DownloadTaskTile(
|
||||
key: ValueKey(LocalManager().downloadingTasks[i]),
|
||||
task: LocalManager().downloadingTasks[i],
|
||||
);
|
||||
},
|
||||
@@ -120,7 +121,7 @@ class _DownloadingPageState extends State<DownloadingPage> {
|
||||
}
|
||||
|
||||
class _DownloadTaskTile extends StatefulWidget {
|
||||
const _DownloadTaskTile({required this.task});
|
||||
const _DownloadTaskTile({required this.task, super.key});
|
||||
|
||||
final DownloadTask task;
|
||||
|
||||
@@ -129,20 +130,33 @@ class _DownloadTaskTile extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DownloadTaskTileState extends State<_DownloadTaskTile> {
|
||||
late DownloadTask task;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
widget.task.addListener(update);
|
||||
task = widget.task;
|
||||
task.addListener(update);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.task.removeListener(update);
|
||||
task.removeListener(update);
|
||||
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() {
|
||||
context.findAncestorStateOfType<_DownloadingPageState>()?.update();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -5,7 +5,9 @@ import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/res.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/settings/settings_page.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
@@ -56,6 +58,10 @@ class _ExplorePageState extends State<ExplorePage>
|
||||
}
|
||||
}
|
||||
|
||||
void addPage() {
|
||||
showPopUpWidget(App.rootContext, setExplorePagesWidget());
|
||||
}
|
||||
|
||||
NaviPaneState? naviPane;
|
||||
|
||||
@override
|
||||
@@ -117,15 +123,21 @@ class _ExplorePageState extends State<ExplorePage>
|
||||
Widget buildEmpty() {
|
||||
var msg = "No Explore Pages".tl;
|
||||
msg += '\n';
|
||||
VoidCallback onTap;
|
||||
if (ComicSource.isEmpty) {
|
||||
msg += "Add a comic source in home page".tl;
|
||||
msg += "Please add some sources".tl;
|
||||
onTap = () {
|
||||
context.to(() => ComicSourcePage());
|
||||
};
|
||||
} else {
|
||||
msg += "Please check your settings".tl;
|
||||
onTap = addPage;
|
||||
}
|
||||
return NetworkError(
|
||||
message: msg,
|
||||
retry: onSettingsChanged,
|
||||
retry: onTap,
|
||||
withAppbar: false,
|
||||
buttonText: "Manage".tl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -137,10 +149,15 @@ class _ExplorePageState extends State<ExplorePage>
|
||||
}
|
||||
|
||||
Widget tabBar = Material(
|
||||
child: FilledTabBar(
|
||||
child: AppTabBar(
|
||||
key: PageStorageKey(pages.toString()),
|
||||
tabs: pages.map((e) => buildTab(e)).toList(),
|
||||
controller: controller,
|
||||
actionButton: TabActionButton(
|
||||
icon: const Icon(Icons.add),
|
||||
text: "Add".tl,
|
||||
onPressed: addPage,
|
||||
),
|
||||
),
|
||||
).paddingTop(context.padding.top);
|
||||
|
||||
|
@@ -9,7 +9,6 @@ import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/local.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_source_page.dart';
|
||||
import 'package:venera/pages/downloading_page.dart';
|
||||
@@ -36,7 +35,6 @@ class HomePage extends StatelessWidget {
|
||||
const _History(),
|
||||
const _Local(),
|
||||
const _ComicSourceWidget(),
|
||||
const _AccountsWidget(),
|
||||
const ImageFavorites(),
|
||||
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 {
|
||||
const _AnimatedDownloadingIcon();
|
||||
|
||||
|
@@ -391,7 +391,7 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
|
||||
Widget build(BuildContext context) {
|
||||
Widget tabBar = Material(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: FilledTabBar(
|
||||
child: AppTabBar(
|
||||
key: PageStorageKey(optionTypes),
|
||||
tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(),
|
||||
),
|
||||
|
@@ -111,9 +111,7 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
|
||||
late _ReaderState reader;
|
||||
|
||||
int get totalPages => ((reader.images!.length + reader.imagesPerPage - 1) /
|
||||
reader.imagesPerPage)
|
||||
.ceil();
|
||||
int get totalPages => (reader.images!.length / reader.imagesPerPage).ceil();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -228,6 +226,8 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
? Axis.vertical
|
||||
: Axis.horizontal;
|
||||
|
||||
bool reverse = reader.mode == ReaderMode.galleryRightToLeft;
|
||||
|
||||
List<Widget> imageWidgets = images.map((imageKey) {
|
||||
ImageProvider imageProvider =
|
||||
_createImageProviderFromKey(imageKey, context);
|
||||
@@ -239,6 +239,10 @@ class _GalleryModeState extends State<_GalleryMode>
|
||||
);
|
||||
}).toList();
|
||||
|
||||
if (reverse) {
|
||||
imageWidgets = imageWidgets.reversed.toList();
|
||||
}
|
||||
|
||||
return axis == Axis.vertical
|
||||
? Column(children: imageWidgets)
|
||||
: Row(children: imageWidgets);
|
||||
|
@@ -98,8 +98,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
|
||||
}
|
||||
|
||||
@override
|
||||
int get maxPage =>
|
||||
((images?.length ?? 1) + imagesPerPage - 1) ~/ imagesPerPage;
|
||||
int get maxPage => ((images?.length ?? 1) / imagesPerPage).ceil();
|
||||
|
||||
ComicType get type => widget.type;
|
||||
|
||||
|
@@ -48,7 +48,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
var readerMode = context.reader.mode;
|
||||
if (value == 1 && showFloatingButtonValue == 0) {
|
||||
showFloatingButtonValue = 1;
|
||||
_floatingButtonDragListener = _DragListener(
|
||||
_floatingButtonDragListener = _DragListener(
|
||||
onMove: (offset) {
|
||||
if (readerMode == ReaderMode.continuousTopToBottom) {
|
||||
fABValue.value -= offset.dy;
|
||||
@@ -845,6 +845,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
|
||||
late int _batteryLevel = 100;
|
||||
Timer? _timer;
|
||||
bool _hasBattery = false;
|
||||
BatteryState state = BatteryState.unknown;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -856,29 +857,23 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
|
||||
void _checkBatteryAvailability() async {
|
||||
try {
|
||||
_batteryLevel = await _battery.batteryLevel;
|
||||
if (_batteryLevel != -1) {
|
||||
state = await _battery.batteryState;
|
||||
if (_batteryLevel > 0 && state != BatteryState.unknown) {
|
||||
setState(() {
|
||||
_hasBattery = true;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_battery.batteryLevel.then((level) => {
|
||||
if (_batteryLevel != level)
|
||||
{
|
||||
setState(() {
|
||||
_batteryLevel = level;
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_battery.batteryLevel.then((level) {
|
||||
if (_batteryLevel != level) {
|
||||
setState(() {
|
||||
_batteryLevel = level;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_hasBattery = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_hasBattery = false;
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -900,7 +895,9 @@ class _BatteryWidgetState extends State<_BatteryWidget> {
|
||||
IconData batteryIcon;
|
||||
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;
|
||||
} else if (batteryLevel >= 84) {
|
||||
batteryIcon = Icons.battery_6_bar_sharp;
|
||||
|
@@ -107,7 +107,7 @@ class _AppSettingsState extends State<AppSettings> {
|
||||
actionTitle: 'Export'.tl,
|
||||
).toSliver(),
|
||||
_CallbackSetting(
|
||||
title: "Import App Data (Please restart after success)".tl,
|
||||
title: "Import App Data".tl,
|
||||
callback: () async {
|
||||
var controller = showLoadingDialog(context);
|
||||
var file = await selectFile(ext: ['venera', 'picadata']);
|
||||
@@ -126,6 +126,7 @@ class _AppSettingsState extends State<AppSettings> {
|
||||
context.showMessage(message: "Failed to import data".tl);
|
||||
} finally {
|
||||
cacheFile.deleteIgnoreError();
|
||||
App.forceRebuild();
|
||||
}
|
||||
}
|
||||
controller.close();
|
||||
|
@@ -30,35 +30,11 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
||||
).toSliver(),
|
||||
_PopupWindowSetting(
|
||||
title: "Explore Pages".tl,
|
||||
builder: () {
|
||||
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,
|
||||
);
|
||||
},
|
||||
builder: setExplorePagesWidget,
|
||||
).toSliver(),
|
||||
_PopupWindowSetting(
|
||||
title: "Category Pages".tl,
|
||||
builder: () {
|
||||
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,
|
||||
);
|
||||
},
|
||||
builder: setCategoryPagesWidget,
|
||||
).toSliver(),
|
||||
_PopupWindowSetting(
|
||||
title: "Network Favorite Pages".tl,
|
||||
@@ -132,8 +108,9 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
|
||||
return PopUpWidgetScaffold(
|
||||
title: "Keyword blocking".tl,
|
||||
tailing: [
|
||||
IconButton(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text("Add".tl),
|
||||
onPressed: add,
|
||||
),
|
||||
],
|
||||
@@ -159,7 +136,6 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
|
||||
void add() {
|
||||
showDialog(
|
||||
context: App.rootContext,
|
||||
barrierColor: Colors.black.toOpacity(0.1),
|
||||
builder: (context) {
|
||||
var controller = TextEditingController();
|
||||
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,
|
||||
);
|
||||
}
|
@@ -376,6 +376,14 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
Future.microtask(() {
|
||||
updateSetting();
|
||||
});
|
||||
}
|
||||
|
||||
var reorderWidgetKey = UniqueKey();
|
||||
var scrollController = ScrollController();
|
||||
final _key = GlobalKey();
|
||||
@@ -404,7 +412,6 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
setState(() {
|
||||
keys = List.from(reorderFunc(keys));
|
||||
});
|
||||
updateSetting();
|
||||
},
|
||||
children: tiles,
|
||||
builder: (children) {
|
||||
@@ -424,7 +431,11 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
title: widget.title,
|
||||
tailing: [
|
||||
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,
|
||||
);
|
||||
@@ -438,9 +449,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
setState(() {
|
||||
keys.remove(key);
|
||||
});
|
||||
updateSetting();
|
||||
},
|
||||
icon: const Icon(Icons.delete)),
|
||||
icon: const Icon(Icons.delete_outline)),
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
@@ -463,30 +473,68 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> {
|
||||
canAdd[key] = value;
|
||||
}
|
||||
});
|
||||
var selected = <String>[];
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ContentDialog(
|
||||
title: "Add".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: canAdd.entries
|
||||
.map(
|
||||
(e) => ListTile(
|
||||
title: Text(e.value),
|
||||
key: Key(e.key),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
setState(() {
|
||||
keys.add(e.key);
|
||||
});
|
||||
updateSetting();
|
||||
},
|
||||
),
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return ContentDialog(
|
||||
title: "Add".tl,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: canAdd.entries
|
||||
.map(
|
||||
(e) => CheckboxListTile(
|
||||
value: selected.contains(e.key),
|
||||
title: Text(e.value),
|
||||
key: Key(e.key),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
if (value!) {
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@@ -170,44 +170,78 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: buildRight())
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: buildLeft()),
|
||||
Positioned(
|
||||
left: offset,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Listener(
|
||||
onPointerDown: handlePointerDown,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
reverseDuration: const Duration(milliseconds: 300),
|
||||
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: currentPage == -1
|
||||
? const SizedBox(
|
||||
key: Key("1"),
|
||||
)
|
||||
: buildRight(),
|
||||
),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (child, animation) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constrains) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, _) {
|
||||
var width = constrains.maxWidth;
|
||||
var value = animation.isForwardOrCompleted
|
||||
? 1 - animation.value
|
||||
: 1;
|
||||
var left = width * value;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: left,
|
||||
width: width,
|
||||
child: child,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
child: 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() {
|
||||
final Widget body = switch (currentPage) {
|
||||
return switch (currentPage) {
|
||||
-1 => const SizedBox(),
|
||||
0 => const ExploreSettings(),
|
||||
1 => const ReaderSettings(),
|
||||
@@ -318,10 +352,6 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
6 => const AboutSettings(),
|
||||
_ => throw UnimplementedError()
|
||||
};
|
||||
|
||||
return Material(
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
var canPop = ValueNotifier(true);
|
||||
|
@@ -303,7 +303,10 @@ class DesktopWebview {
|
||||
proxy: AppDio.proxy,
|
||||
));
|
||||
_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);
|
||||
_runTimer();
|
||||
_webview!.onClose.then((value) {
|
||||
|
@@ -55,7 +55,7 @@ class DataSync with ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<Res<bool>> uploadData() async {
|
||||
if(isDownloading) return const Res(true);
|
||||
if (isDownloading) return const Res(true);
|
||||
if (haveWaitingTask) return const Res(true);
|
||||
while (isUploading) {
|
||||
haveWaitingTask = true;
|
||||
@@ -109,7 +109,7 @@ class DataSync with ChangeNotifier {
|
||||
filename += '.venera';
|
||||
var files = await client.readDir('/');
|
||||
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) {
|
||||
await client.remove(old.name!);
|
||||
}
|
||||
@@ -176,8 +176,11 @@ class DataSync with ChangeNotifier {
|
||||
var files = await client.readDir('/');
|
||||
files.sort((a, b) => b.name!.compareTo(a.name!));
|
||||
var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera'));
|
||||
if (file == null) {
|
||||
throw 'No data file found';
|
||||
}
|
||||
var version =
|
||||
file!.name!.split('-').elementAtOrNull(1)?.split('.').first;
|
||||
file.name!.split('-').elementAtOrNull(1)?.split('.').first;
|
||||
if (version != null && int.tryParse(version) != null) {
|
||||
var currentVersion = appdata.settings['dataVersion'];
|
||||
if (currentVersion != null && int.parse(version) <= currentVersion) {
|
||||
|
@@ -26,7 +26,7 @@ class Image {
|
||||
var codec = await ui.instantiateImageCodec(data);
|
||||
var frame = await codec.getNextFrame();
|
||||
codec.dispose();
|
||||
var info = await frame.image.toByteData();
|
||||
var info = await frame.image.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||
if (info == null) {
|
||||
throw Exception('Failed to decode image');
|
||||
}
|
||||
@@ -39,6 +39,14 @@ class 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) {
|
||||
if (width + x > this.width) {
|
||||
throw ArgumentError('''
|
||||
@@ -176,11 +184,11 @@ class Color {
|
||||
|
||||
Color.fromValue(this.value);
|
||||
|
||||
int get r => (value >> 16) & 0xFF;
|
||||
int get r => value & 0xFF;
|
||||
|
||||
int get g => (value >> 8) & 0xFF;
|
||||
|
||||
int get b => value & 0xFF;
|
||||
int get b => (value >> 16) & 0xFF;
|
||||
|
||||
int get a => (value >> 24) & 0xFF;
|
||||
}
|
||||
|
@@ -1,33 +1,28 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:pdf/widgets.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/utils/image.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:zip_flutter/zip_flutter.dart';
|
||||
|
||||
typedef DecodeImage = Future<Image> Function(Uint8List data);
|
||||
|
||||
Future<void> _createPdfFromComic({
|
||||
required LocalComic comic,
|
||||
required String savePath,
|
||||
required String localPath,
|
||||
required DecodeImage decodeImage,
|
||||
}) async {
|
||||
final pdf = Document(
|
||||
title: comic.title,
|
||||
author: comic.subTitle ?? "",
|
||||
producer: "Venera",
|
||||
);
|
||||
|
||||
pdf.document.outline;
|
||||
var images = <String>[];
|
||||
|
||||
var baseDir = comic.directory.contains('/') || comic.directory.contains('\\')
|
||||
? comic.directory
|
||||
: FilePath.join(localPath, comic.directory);
|
||||
|
||||
// add cover
|
||||
var imageData = File(FilePath.join(baseDir, comic.cover)).readAsBytesSync();
|
||||
pdf.addPage(Page(
|
||||
build: (Context context) {
|
||||
return Image(MemoryImage(imageData), fit: BoxFit.contain);
|
||||
},
|
||||
));
|
||||
images.add(FilePath.join(baseDir, comic.cover));
|
||||
|
||||
bool multiChapters = comic.chapters != null;
|
||||
|
||||
@@ -51,42 +46,360 @@ Future<void> _createPdfFromComic({
|
||||
reorderFiles(files);
|
||||
|
||||
for (var file in files) {
|
||||
var imageData = (file as File).readAsBytesSync();
|
||||
pdf.addPage(Page(
|
||||
build: (Context context) {
|
||||
return Image(MemoryImage(imageData), fit: BoxFit.contain);
|
||||
},
|
||||
));
|
||||
images.add(file.path);
|
||||
}
|
||||
} else {
|
||||
for (var chapter in comic.chapters!.keys) {
|
||||
var files = Directory(FilePath.join(baseDir, chapter)).listSync();
|
||||
reorderFiles(files);
|
||||
for (var file in files) {
|
||||
var imageData = (file as File).readAsBytesSync();
|
||||
pdf.addPage(Page(
|
||||
build: (Context context) {
|
||||
return Image(MemoryImage(imageData), fit: BoxFit.contain);
|
||||
},
|
||||
));
|
||||
images.add(file.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final file = File(savePath);
|
||||
file.writeAsBytesSync(await pdf.save());
|
||||
var generator = PdfGenerator(
|
||||
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({
|
||||
required LocalComic comic,
|
||||
required String savePath,
|
||||
}) async {
|
||||
var localPath = LocalManager().path;
|
||||
return Isolate.run(() => overrideIO(() async {
|
||||
return await _createPdfFromComic(
|
||||
comic: comic,
|
||||
savePath: savePath,
|
||||
localPath: localPath,
|
||||
);
|
||||
}));
|
||||
var receivePort = ReceivePort();
|
||||
SendPort? sendPort;
|
||||
Isolate? isolate;
|
||||
var completer = Completer<void>();
|
||||
receivePort.listen((message) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
86
pubspec.lock
86
pubspec.lock
@@ -33,14 +33,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.6.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,14 +49,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
barcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: barcode
|
||||
sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.8"
|
||||
battery_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -81,14 +65,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
bidi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bidi
|
||||
sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.12"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -298,6 +274,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -417,8 +401,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: "598d50572a658f8e04775566fe3789954d9a01e3"
|
||||
resolved-ref: "598d50572a658f8e04775566fe3789954d9a01e3"
|
||||
ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
|
||||
resolved-ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
|
||||
url: "https://github.com/wgh136/flutter_qjs"
|
||||
source: git
|
||||
version: "0.3.7"
|
||||
@@ -521,14 +505,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -690,14 +666,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -746,14 +714,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pdf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pdf
|
||||
sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.11.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -795,14 +755,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.1"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
rhttp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -977,14 +929,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1142,18 +1086,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: yaml
|
||||
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
version: "3.1.3"
|
||||
zip_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: zip_flutter
|
||||
sha256: ea7fdc86c988174ef3bb80dc26e8e8bfdf634c55930e2d18d7e77e991acf0483
|
||||
sha256: bbf3160062610a43901b7ebbc6f6dd46519540f03a84027dc7b1fff399dda1ac
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.8"
|
||||
version: "0.0.10"
|
||||
sdks:
|
||||
dart: ">=3.6.0 <4.0.0"
|
||||
flutter: ">=3.27.2"
|
||||
flutter: ">=3.27.3"
|
||||
|
19
pubspec.yaml
19
pubspec.yaml
@@ -2,18 +2,16 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.2.1+121
|
||||
version: 1.2.3+123
|
||||
|
||||
environment:
|
||||
sdk: '>=3.6.0 <4.0.0'
|
||||
flutter: 3.27.2
|
||||
flutter: 3.27.3
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
path_provider: any
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
intl: ^0.19.0
|
||||
window_manager: ^0.4.3
|
||||
sqlite3: ^2.4.7
|
||||
@@ -21,11 +19,11 @@ dependencies:
|
||||
flutter_qjs:
|
||||
git:
|
||||
url: https://github.com/wgh136/flutter_qjs
|
||||
ref: 598d50572a658f8e04775566fe3789954d9a01e3
|
||||
ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3
|
||||
crypto: ^3.0.6
|
||||
dio: ^5.7.0
|
||||
html: ^0.15.5
|
||||
pointycastle: any
|
||||
pointycastle: ^3.9.1
|
||||
url_launcher: ^6.3.0
|
||||
path: ^1.9.0
|
||||
photo_view:
|
||||
@@ -40,7 +38,6 @@ dependencies:
|
||||
ref: 09e756b1f1b04e6298318d99ec20a787fb360f59
|
||||
path: packages/scrollable_positioned_list
|
||||
flutter_reorderable_grid_view: ^5.4.0
|
||||
yaml: any
|
||||
uuid: ^4.5.1
|
||||
desktop_webview_window:
|
||||
git:
|
||||
@@ -51,7 +48,7 @@ dependencies:
|
||||
sliver_tools: ^0.2.12
|
||||
flutter_file_dialog: ^3.0.2
|
||||
file_selector: ^1.0.3
|
||||
zip_flutter: ^0.0.8
|
||||
zip_flutter: ^0.0.10
|
||||
lodepng_flutter:
|
||||
git:
|
||||
url: https://github.com/venera-app/lodepng_flutter
|
||||
@@ -67,16 +64,18 @@ dependencies:
|
||||
git:
|
||||
url: https://github.com/pkuislm/flutter_saf.git
|
||||
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
|
||||
pdf: ^3.11.1
|
||||
dynamic_color: ^1.7.0
|
||||
shimmer_animation: ^2.1.0
|
||||
flutter_memory_info: ^0.0.1
|
||||
syntax_highlight: ^0.4.0
|
||||
text_scroll: ^0.2.0
|
||||
flutter_7zip:
|
||||
git:
|
||||
url: https://github.com/wgh136/flutter_7zip
|
||||
ref: b33344797f1d2469339e0e1b75f5f954f1da224c
|
||||
flex_seed_scheme: ^3.5.0
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
yaml: ^3.1.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Reference in New Issue
Block a user