46 Commits

Author SHA1 Message Date
d9b23dadf0 Improve linux window. 2025-03-24 21:34:38 +08:00
d05eaf8c7e Improve WebDav UI 2025-03-23 16:42:55 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
9dae28e366 add missing targets (#282)
check for macOS
2025-03-19 18:58:58 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
11e66328c4 add rust-toolchain.toml (#281) 2025-03-19 16:51:24 +08:00
ᡠᠵᡠᡳ ᡠᠵᡠ ᠮᠠᠨᡩ᠋ᠠᠨ
73d4e28ed0 pin rust to 1.85.1 for android (#279) 2025-03-19 13:47:27 +08:00
nyne
169676fd9e Merge pull request #274 from venera-app/v1.3.3-dev
V1.3.3
2025-03-18 17:48:21 +08:00
332497cf90 Increase scroll indicator size. 2025-03-18 17:42:15 +08:00
5f15c08eef Update version code and dependencies. Close #265 2025-03-18 17:09:58 +08:00
3f6b3152b2 [Android] Support opening search page with shared text. Close #261 2025-03-18 16:19:32 +08:00
f5b3b36acb Fix webdav proxy. 2025-03-18 13:08:10 +08:00
fd8607777e Fix deleted comic sources will be restore after webdav sync. 2025-03-18 11:06:30 +08:00
fa951cac95 Support multiple webdav authentication methods. Close #271 2025-03-18 10:52:01 +08:00
55ad652191 Fix an issue where it was impossible to read a new chapter of a downloaded comic. Close #256 2025-03-17 19:18:18 +08:00
533497ead1 Improve aggregated search ui. 2025-03-17 18:51:27 +08:00
角砂糖
00cdc18ddd NDK r28 (#267) 2025-03-16 08:02:05 +08:00
角砂糖
474d9aa6f1 Default to displaying the last read chapter group. (#264) 2025-03-16 07:58:28 +08:00
ffa0c8f887 Prefer noto fonts. 2025-03-15 17:53:52 +08:00
0f3f3ea270 Add continuous mode for comic list. 2025-03-15 17:43:43 +08:00
ɴᴇᴋᴏ
b752caa079 Update translation.json (#257) 2025-03-14 09:34:30 +08:00
309df2143b Make sure the follow updates is initialized correctly. 2025-03-13 16:09:17 +08:00
8e964468ea Fix an issue where error message from webdav is null. 2025-03-13 15:54:18 +08:00
ca8f09807b Add a refresh button to network favorites page. 2025-03-09 16:38:39 +08:00
68b214e295 Fix comic list was not updated after delete a comic in favorites page. 2025-03-09 16:34:50 +08:00
00c0a64de0 Improve scroll bar of favorites page. 2025-03-09 12:55:36 +08:00
nyne
dbc2c27db0 Merge pull request #245 from venera-app/v1.3.2-dev
v1.3.2
2025-03-06 13:16:57 +08:00
fffb3dc973 Use rhttp to make webdav requests. 2025-03-05 21:47:28 +08:00
0ca8a28639 Update version code. 2025-03-05 17:46:07 +08:00
6426ebaf16 Add initial page setting. Close #240 2025-03-05 17:44:20 +08:00
316f61394d Try to fix #241 2025-03-04 22:17:21 +08:00
04ab75cf92 Fix WindowFrame on Android. 2025-03-04 21:50:22 +08:00
4828a57e1a Improve follow updates. Close #235 2025-03-04 19:30:24 +08:00
d089163220 Fix comment overflow. Close #237 2025-03-04 15:36:02 +08:00
7b5c13200d Improve init. Close #236 2025-03-04 15:30:40 +08:00
0f6874f8d7 Close reader when user click the close button on window frame. 2025-03-03 20:50:11 +08:00
4af15b9139 Improve fullscreen 2025-03-03 19:28:20 +08:00
9fe49217dc Add error status to data sync component. 2025-03-03 19:04:16 +08:00
76c56964a5 Fix archive download when using custom download path on Android. 2025-03-02 17:40:04 +08:00
e8afbca7b2 Fix empty reader page when current chapter is last chapter of the chapter group. 2025-03-01 09:29:04 +08:00
5843d7c919 Fix sidebar. 2025-03-01 09:20:33 +08:00
shenmo
de98dfaa1b Fix font problem on Linux ARM64 (#231) 2025-02-27 23:04:36 +08:00
AnxuNA
30cbfb54ef Fix Fullscreen switch (#229)
* Fix Fullscreen switch for windows

* Fix Fullscreen switch for windows
2025-02-26 10:43:28 +08:00
c633021963 Merge remote-tracking branch 'origin/master' into v1.3.2-dev 2025-02-26 09:34:02 +08:00
nyne
4640831e69 Disable blank issue 2025-02-25 21:43:30 +08:00
af7a7c220e Update issue templates. 2025-02-25 21:34:59 +08:00
fd19f6bf7d Fixed crash caused by empty chapter list. 2025-02-23 20:24:43 +08:00
96b4125613 Update issue template 2025-02-23 18:40:07 +08:00
56 changed files with 1232 additions and 515 deletions

View File

@@ -8,9 +8,31 @@ body:
value: |
Thank you for reporting a problem, please complete the title and fill in the following information.
感谢您的反馈,请填写完整标题并填写以下信息。
**Please do not report any issues related to config files.**
To report a bug related to the config file, please send it to the [config repository](https://github.com/venera-app/venera-configs).
**请不要报告与配置文件相关的任何问题。**
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
- type: dropdown
id: bugType
attributes:
label: Bug type
description: What type of bug are you reporting?
options:
- Crash
- UI
- Performance
- Security
- Reader
- JS Engine
- Comic Source
- Other
validations:
required: true
- type: textarea
id: what-happened
attributes:

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -7,6 +7,16 @@ body:
attributes:
value: |
Welcome to make a feature request, please fill in the following information after completing the title.
欢迎提出功能建议,请填写完整标题后填写以下信息。
**Please do not report any issues related to config files.**
**请不要报告与配置文件相关的任何问题。**
This project is a comic reader that allows users write their own config files. And there is no built-in comic source.
本项目是一个漫画阅读器,允许用户编写自己的配置文件,并且没有内置漫画源。
- type: textarea
id: what-happened
attributes:

View File

@@ -1,9 +0,0 @@
name: other
description: Other contents
body:
- type: textarea
id: what-happened
attributes:
label: Content
validations:
required: true

View File

@@ -26,6 +26,9 @@ jobs:
echo "$CERTIFICATE" | base64 --decode > signing_certificate.p12
security import signing_certificate.p12 -k ~/Library/Keychains/login.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
- name: Check rust-toolchain.toml
run: rustup show
# Step 2: Build the Flutter macOS app
- name: Build Flutter macOS App
run: flutter build macos --release
@@ -97,10 +100,8 @@ jobs:
with:
distribution: 'oracle'
java-version: '17'
- name: Setup Rust
run: |
rustup update
rustup default stable
- name: Check rust-toolchain.toml
run: rustup show
- run: flutter pub get
- run: flutter build apk --release
- uses: actions/upload-artifact@v4

View File

@@ -32,7 +32,7 @@ keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
android {
namespace = "com.github.wgh136.venera"
compileSdk = flutter.compileSdkVersion
ndkVersion "25.1.8937393"
ndkVersion "28.0.13004108"
splits{
abi {

View File

@@ -47,6 +47,11 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="exhentai.org" android:pathPrefix="/g" />
</intent-filter>
<intent-filter android:label="@string/share_text">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import android.util.Log
@@ -40,6 +41,41 @@ class MainActivity : FlutterFragmentActivity() {
private val nextLocalRequestCode = AtomicInteger()
private val sharedTexts = ArrayList<String>()
private var textShareHandler: ((String) -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent?.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null)
handleSharedText(text)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == Intent.ACTION_SEND) {
if (intent.type == "text/plain") {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
if (text != null)
handleSharedText(text)
}
}
}
private fun handleSharedText(text: String) {
if (textShareHandler != null) {
textShareHandler?.invoke(text)
} else {
sharedTexts.add(text)
}
}
private fun <I, O> startContractForResult(
contract: ActivityResultContract<I, O>,
input: I,
@@ -134,6 +170,26 @@ class MainActivity : FlutterFragmentActivity() {
val mimeType = req.arguments<String>()
openFile(res, mimeType!!)
}
val shareTextChannel = EventChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/text_share")
shareTextChannel.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
textShareHandler = {text ->
events.success(text)
}
if (sharedTexts.isNotEmpty()) {
for (text in sharedTexts) {
events.success(text)
}
sharedTexts.clear()
}
}
override fun onCancel(arguments: Any?) {
textShareHandler = null
}
})
}
private fun getProxy(): String {

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">搜索</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">搜尋</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="share_text">Search</string>
</resources>

View File

@@ -358,11 +358,22 @@
"Once the operation is successful, app will automatically sync data with the server.": "操作成功后, APP将自动与服务器同步数据",
"Cache cleared": "缓存已清除",
"Disabled": "已禁用",
"WebDAV Auto Sync": "WebDAV 自动同步",
"Auto Sync Data": "自动同步数据",
"Mark all as read": "全部标记为已读",
"Do you want to mark all as read?" : "您要全部标记为已读吗?",
"Swipe down for previous chapter": "向下滑动查看上一章",
"Swipe up for next chapter": "向上滑动查看下一章"
"Swipe up for next chapter": "向上滑动查看下一章",
"Initial Page": "初始页面",
"Home Page": "主页",
"Favorites Page": "收藏页面",
"Explore Page": "探索页面",
"Categories Page": "分类页面",
"Convert to local": "转换为本地",
"Refresh": "刷新",
"Paging": "分页",
"Continuous": "连续",
"Display mode of comic list": "漫画列表的显示模式",
"A valid WebDav directory URL": "有效的WebDav目录URL"
},
"zh_TW": {
"Home": "首頁",
@@ -720,13 +731,24 @@
"All Comics": "全部漫畫",
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
"Disable": "停用",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與服器同步數據",
"Cache cleared": "緩存已清除",
"Disabled": "已用",
"WebDAV Auto Sync": "WebDAV 自動同步",
"Once the operation is successful, app will automatically sync data with the server.": "操作成功後, APP將自動與服器同步資料",
"Cache cleared": "快取已清除",
"Disabled": "已用",
"Auto Sync Data": "自動同步資料",
"Mark all as read": "全部標記為已讀",
"Do you want to mark all as read?" : "您要全部標記為已讀嗎?",
"Swipe down for previous chapter": "向下滑動查看上一章",
"Swipe up for next chapter": "向上滑動查看下一章"
"Swipe up for next chapter": "向上滑動查看下一章",
"Initial Page": "初始頁面",
"Home Page": "首頁",
"Favorites Page": "收藏頁面",
"Explore Page": "探索頁面",
"Categories Page": "分類頁面",
"Convert to local": "轉換為本地",
"Refresh": "刷新",
"Paging": "分頁",
"Continuous": "連續",
"Display mode of comic list": "漫畫列表的顯示模式",
"A valid WebDav directory URL": "有效的WebDav目錄URL"
}
}

View File

@@ -770,7 +770,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
@override
void didUpdateWidget(covariant SliverGridComics oldWidget) {
if (!oldWidget.comics.isEqualTo(widget.comics)) {
if (!comics.isEqualTo(widget.comics)) {
comics.clear();
for (var comic in widget.comics) {
if (isBlocked(comic) == null) {
@@ -879,6 +879,7 @@ class _SliverGridComics extends StatelessWidget {
return comic;
}
return AnimatedContainer(
key: ValueKey(comics[index].id),
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: isSelected
@@ -1140,7 +1141,7 @@ class ComicListState extends State<ComicList> {
setState(() {});
});
}
if (_loading[page] == true) {
if (_data[page] != null || _loading[page] == true) {
return;
}
_loading[page] = true;
@@ -1150,8 +1151,8 @@ class ComicListState extends State<ComicList> {
if (!mounted) return;
if (res.success) {
if (res.data.isEmpty) {
_data[page] = const [];
setState(() {
_data[page] = const [];
_maxPage = page;
});
} else {
@@ -1201,6 +1202,11 @@ class ComicListState extends State<ComicList> {
@override
Widget build(BuildContext context) {
var type = appdata.settings['comicListDisplayMode'];
return type == 'paging' ? buildPagingMode() : buildContinuousMode();
}
Widget buildPagingMode() {
if (_error != null) {
return Column(
children: [
@@ -1249,6 +1255,85 @@ class ComicListState extends State<ComicList> {
],
);
}
Widget buildContinuousMode() {
if (_error != null && _data.isEmpty) {
return Column(
children: [
if (widget.errorLeading != null) widget.errorLeading!,
_buildPageSelector(),
Expanded(
child: NetworkError(
withAppbar: false,
message: _error!,
retry: () {
setState(() {
_error = null;
});
},
),
),
],
);
}
if (_data[_page] == null) {
_loadPage(_page);
return Column(
children: [
if (widget.errorLeading != null) widget.errorLeading!,
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
),
],
);
}
return SmoothCustomScrollView(
key: enablePageStorage ? PageStorageKey('scroll$_page') : null,
controller: widget.controller,
slivers: [
if (widget.leadingSliver != null) widget.leadingSliver!,
SliverGridComics(
comics: _data.values.expand((element) => element).toList(),
menuBuilder: widget.menuBuilder,
onLastItemBuild: () {
if (_error == null && (_maxPage == null || _page < _maxPage!)) {
_loadPage(_data.length + 1);
}
},
),
if (_error != null)
SliverToBoxAdapter(
child: Column(
children: [
Row(
children: [
const Icon(Icons.error_outline),
const SizedBox(width: 8),
Expanded(child: Text(_error!, maxLines: 3)),
],
),
const SizedBox(height: 8),
Center(
child: OutlinedButton(
onPressed: () {
setState(() {
_error = null;
});
},
child: Text("Retry".tl),
),
),
],
).paddingHorizontal(16).paddingVertical(8),
)
else if (_maxPage == null || _page < _maxPage!)
const SliverListLoadingIndicator(),
if (widget.trailingSliver != null) widget.trailingSliver!,
],
);
}
}
class StarRating extends StatelessWidget {
@@ -1535,17 +1620,20 @@ class _SMClipper extends CustomClipper<Rect> {
}
class SimpleComicTile extends StatelessWidget {
const SimpleComicTile({super.key, required this.comic, this.onTap});
const SimpleComicTile(
{super.key, required this.comic, this.onTap, this.withTitle = false});
final Comic comic;
final void Function()? onTap;
final bool withTitle;
@override
Widget build(BuildContext context) {
var image = _findImageProvider(comic);
var child = image == null
Widget child = image == null
? const SizedBox()
: AnimatedImage(
image: image,
@@ -1555,7 +1643,18 @@ class SimpleComicTile extends StatelessWidget {
filterQuality: FilterQuality.medium,
);
return AnimatedTapRegion(
child = Container(
width: 98,
height: 136,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
);
child = AnimatedTapRegion(
borderRadius: 8,
onTap: onTap ??
() {
@@ -1566,16 +1665,29 @@ class SimpleComicTile extends StatelessWidget {
),
);
},
child: Container(
width: 92,
height: 114,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.secondaryContainer,
),
clipBehavior: Clip.antiAlias,
child: child,
);
if (withTitle) {
child = Column(
mainAxisSize: MainAxisSize.min,
children: [
child,
const SizedBox(height: 4),
SizedBox(
width: 92,
child: Center(
child: Text(
comic.title.replaceAll('\n', ''),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
],
);
}
return child;
}
}

View File

@@ -125,11 +125,11 @@ class OverlayWidgetState extends State<OverlayWidget> {
void showDialogMessage(BuildContext context, String title, String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
builder: (context) => ContentDialog(
title: title,
content: Text(message).paddingHorizontal(16),
actions: [
TextButton(
FilledButton(
onPressed: context.pop,
child: Text("OK".tl),
)

View File

@@ -99,11 +99,13 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
);
if (_futurePosition == old) return;
var target = _futurePosition!;
_controller.animateTo(
_controller
.animateTo(
_futurePosition!,
duration: _fastAnimationDuration,
curve: Curves.linear,
).then((_) {
)
.then((_) {
var current = _controller.position.pixels;
if (current == target && current == _futurePosition) {
_futurePosition = null;
@@ -144,3 +146,169 @@ class ScrollControllerProvider extends InheritedWidget {
return oldWidget.controller != controller;
}
}
class AppScrollBar extends StatefulWidget {
const AppScrollBar({
super.key,
required this.controller,
required this.child,
this.topPadding = 0,
});
final ScrollController controller;
final Widget child;
final double topPadding;
@override
State<AppScrollBar> createState() => _AppScrollBarState();
}
class _AppScrollBarState extends State<AppScrollBar> {
late final ScrollController _scrollController;
double minExtent = 0;
double maxExtent = 0;
double position = 0;
double viewHeight = 0;
final _scrollIndicatorSize = App.isDesktop ? 42.0 : 64.0;
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
@override
void initState() {
super.initState();
_scrollController = widget.controller;
_scrollController.addListener(onChanged);
Future.microtask(onChanged);
_dragGestureRecognizer = VerticalDragGestureRecognizer()
..onUpdate = onUpdate;
}
void onUpdate(DragUpdateDetails details) {
if (maxExtent - minExtent <= 0 ||
viewHeight == 0 ||
details.primaryDelta == null) {
return;
}
var offset = details.primaryDelta!;
var positionOffset =
offset / (viewHeight - _scrollIndicatorSize) * (maxExtent - minExtent);
_scrollController.jumpTo((position + positionOffset).clamp(
minExtent,
maxExtent,
));
}
void onChanged() {
if (_scrollController.positions.isEmpty) return;
var position = _scrollController.position;
if (position.minScrollExtent != minExtent ||
position.maxScrollExtent != maxExtent ||
position.pixels != this.position) {
setState(() {
minExtent = position.minScrollExtent;
maxExtent = position.maxScrollExtent;
this.position = position.pixels;
});
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constrains) {
var scrollHeight = (maxExtent - minExtent);
var height = constrains.maxHeight - widget.topPadding;
viewHeight = height;
var top = scrollHeight == 0
? 0.0
: (position - minExtent) /
scrollHeight *
(height - _scrollIndicatorSize);
return Stack(
children: [
Positioned.fill(
child: widget.child,
),
Positioned(
top: top + widget.topPadding,
right: 0,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
_dragGestureRecognizer.addPointer(event);
},
child: SizedBox(
width: _scrollIndicatorSize/2,
height: _scrollIndicatorSize,
child: CustomPaint(
painter: _ScrollIndicatorPainter(
backgroundColor: context.colorScheme.surface,
shadowColor: context.colorScheme.shadow,
),
child: Column(
children: [
const Spacer(),
Icon(Icons.arrow_drop_up, size: 18),
Icon(Icons.arrow_drop_down, size: 18),
const Spacer(),
],
).paddingLeft(4),
),
),
),
),
),
],
);
},
);
}
}
class _ScrollIndicatorPainter extends CustomPainter {
final Color backgroundColor;
final Color shadowColor;
const _ScrollIndicatorPainter({
required this.backgroundColor,
required this.shadowColor,
});
@override
void paint(Canvas canvas, Size size) {
var path = Path()
..moveTo(size.width, 0)
..lineTo(size.width, size.height)
..arcToPoint(
Offset(size.width, 0),
radius: Radius.circular(size.width),
);
canvas.drawShadow(path, shadowColor, 4, true);
var backgroundPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.fill;
path = Path()
..moveTo(size.width, 0)
..lineTo(size.width, size.height)
..arcToPoint(
Offset(size.width, 0),
radius: Radius.circular(size.width),
);
canvas.drawPath(path, backgroundPaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return oldDelegate is! _ScrollIndicatorPainter ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.shadowColor != shadowColor;
}
}

View File

@@ -1,15 +1,13 @@
part of 'components.dart';
class SideBarRoute<T> extends PopupRoute<T> {
SideBarRoute(this.title, this.widget,
SideBarRoute(this.widget,
{this.showBarrier = true,
this.useSurfaceTintColor = false,
required this.width,
this.addBottomPadding = true,
this.addTopPadding = true});
final String? title;
final Widget widget;
final bool showBarrier;
@@ -36,11 +34,7 @@ class SideBarRoute<T> extends PopupRoute<T> {
Animation<double> secondaryAnimation) {
bool showSideBar = MediaQuery.of(context).size.width > width;
Widget body = SidebarBody(
title: title,
widget: widget,
autoChangeTitleBarColor: !useSurfaceTintColor,
);
Widget body = widget;
if (addTopPadding) {
body = Padding(
@@ -129,97 +123,13 @@ class SideBarRoute<T> extends PopupRoute<T> {
}
}
class SidebarBody extends StatefulWidget {
const SidebarBody(
{required this.title,
required this.widget,
required this.autoChangeTitleBarColor,
super.key});
final String? title;
final Widget widget;
final bool autoChangeTitleBarColor;
@override
State<SidebarBody> createState() => _SidebarBodyState();
}
class _SidebarBodyState extends State<SidebarBody> {
bool top = true;
@override
Widget build(BuildContext context) {
Widget body = Expanded(child: widget.widget);
if (widget.autoChangeTitleBarColor) {
body = NotificationListener<ScrollNotification>(
onNotification: (notifications) {
if (notifications.metrics.pixels ==
notifications.metrics.minScrollExtent &&
!top) {
setState(() {
top = true;
});
} else if (notifications.metrics.pixels !=
notifications.metrics.minScrollExtent &&
top) {
setState(() {
top = false;
});
}
return false;
},
child: body,
);
}
return Column(
children: [
if (widget.title != null)
Container(
height: 60 + MediaQuery.of(context).padding.top,
color: top
? null
: Theme.of(context).colorScheme.surfaceTint.withAlpha(20),
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: Row(
children: [
const SizedBox(
width: 8,
),
Tooltip(
message: "Back".tl,
child: IconButton(
iconSize: 25,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
),
const SizedBox(
width: 10,
),
Text(
widget.title!,
style: const TextStyle(fontSize: 22),
)
],
),
),
body
],
);
}
}
Future<void> showSideBar(BuildContext context, Widget widget,
{String? title,
bool showBarrier = true,
{bool showBarrier = true,
bool useSurfaceTintColor = false,
double width = 500,
bool addTopPadding = false}) {
return Navigator.of(context).push(
SideBarRoute(
title,
widget,
showBarrier: showBarrier,
useSurfaceTintColor: useSurfaceTintColor,

View File

@@ -10,6 +10,34 @@ import 'package:window_manager/window_manager.dart';
const _kTitleBarHeight = 36.0;
class WindowFrameController extends InheritedWidget {
/// Whether the window frame is hidden.
final bool isWindowFrameHidden;
/// Sets the visibility of the window frame.
final void Function(bool) setWindowFrame;
/// Adds a listener that will be called when close button is clicked.
/// The listener should return `true` to allow the window to be closed.
final void Function(WindowCloseListener listener) addCloseListener;
/// Removes a close listener.
final void Function(WindowCloseListener listener) removeCloseListener;
const WindowFrameController._create({
required this.isWindowFrameHidden,
required this.setWindowFrame,
required this.addCloseListener,
required this.removeCloseListener,
required super.child,
});
@override
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
return false;
}
}
class WindowFrame extends StatefulWidget {
const WindowFrame(this.child, {super.key});
@@ -17,26 +45,63 @@ class WindowFrame extends StatefulWidget {
@override
State<WindowFrame> createState() => _WindowFrameState();
static WindowFrameController of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
}
}
typedef WindowCloseListener = bool Function();
class _WindowFrameState extends State<WindowFrame> {
bool isHideWindowFrame = false;
bool isWindowFrameHidden = false;
bool useDarkTheme = false;
var closeListeners = <WindowCloseListener>[];
/// Sets the visibility of the window frame.
void setWindowFrame(bool show) {
setState(() {
isWindowFrameHidden = !show;
});
}
/// Adds a listener that will be called when close button is clicked.
/// The listener should return `true` to allow the window to be closed.
void addCloseListener(WindowCloseListener listener) {
closeListeners.add(listener);
}
/// Removes a close listener.
void removeCloseListener(WindowCloseListener listener) {
closeListeners.remove(listener);
}
void _onClose() {
for (var listener in closeListeners) {
if (!listener()) {
return;
}
}
windowManager.close();
}
@override
Widget build(BuildContext context) {
if (App.isMobile) return widget.child;
if (isHideWindowFrame) return widget.child;
var body = Stack(
Widget body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
padding: isWindowFrameHidden
? null
: const EdgeInsets.only(top: _kTitleBarHeight),
),
child: widget.child,
),
),
if (!isWindowFrameHidden)
Positioned(
top: 0,
left: 0,
@@ -82,7 +147,10 @@ class _WindowFrameState extends State<WindowFrame> {
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) const WindowButtons()
if (!App.isMacOS)
_WindowButtons(
onClose: _onClose,
)
],
),
);
@@ -94,21 +162,29 @@ class _WindowFrameState extends State<WindowFrame> {
);
if (App.isLinux) {
return VirtualWindowFrame(child: body);
} else {
return body;
body = VirtualWindowFrame(child: body);
}
return WindowFrameController._create(
isWindowFrameHidden: isWindowFrameHidden,
setWindowFrame: setWindowFrame,
addCloseListener: addCloseListener,
removeCloseListener: removeCloseListener,
child: body,
);
}
}
class WindowButtons extends StatefulWidget {
const WindowButtons({super.key});
class _WindowButtons extends StatefulWidget {
const _WindowButtons({required this.onClose});
final void Function() onClose;
@override
State<WindowButtons> createState() => _WindowButtonsState();
State<_WindowButtons> createState() => _WindowButtonsState();
}
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
bool isMaximized = false;
@override
@@ -197,9 +273,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
color: !dark ? Colors.white : Colors.black,
),
hoverColor: Colors.red,
onPressed: () {
windowManager.close();
},
onPressed: widget.onClose,
)
],
),
@@ -486,22 +560,19 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
}
Widget _buildVirtualWindowFrame(BuildContext context) {
return DecoratedBox(
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_isMaximized ? 0 : 8),
color: Colors.transparent,
border: Border.all(
color: Theme.of(context).dividerColor,
width: (_isMaximized || _isFullScreen) ? 0 : 1,
),
boxShadow: <BoxShadow>[
if (!_isMaximized && !_isFullScreen)
BoxShadow(
color: Colors.black.toOpacity(0.1),
offset: Offset(0.0, _isFocused ? 4 : 2),
blurRadius: 6,
color: Colors.black.toOpacity(_isFocused ? 0.4 : 0.2),
offset: Offset(0.0, 2),
blurRadius: 4,
)
],
),
clipBehavior: Clip.antiAlias,
child: widget.child,
);
}
@@ -510,7 +581,10 @@ class _VirtualWindowFrameState extends State<VirtualWindowFrame>
Widget build(BuildContext context) {
return DragToResizeArea(
enableResizeEdges: (_isMaximized || _isFullScreen) ? [] : null,
child: Padding(
padding: EdgeInsets.all(_isMaximized ? 0 : 4),
child: _buildVirtualWindowFrame(context),
),
);
}
@@ -567,5 +641,5 @@ TransitionBuilder VirtualWindowFrameInit() {
}
void debug() {
ComicSource.reload();
ComicSourceManager().reload();
}

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.3.1";
final version = "1.3.3";
bool get isAndroid => Platform.isAndroid;
@@ -77,10 +77,15 @@ class _App {
Future<void> init() async {
cachePath = (await getApplicationCacheDirectory()).path;
dataPath = (await getApplicationSupportDirectory()).path;
await data.init();
await history.init();
await favorites.init();
await local.init();
}
Future<void> initComponents() async {
await Future.wait([
data.init(),
history.init(),
favorites.init(),
local.init(),
]);
}
Function? _forceRebuildHandler;

View File

@@ -4,10 +4,13 @@ import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart';
class Appdata {
final Settings settings = Settings();
class Appdata with Init {
Appdata._create();
final Settings settings = Settings._create();
var searchHistory = <String>[];
@@ -51,28 +54,6 @@ class Appdata {
saveData();
}
Future<void> init() async {
var dataPath = (await getApplicationSupportDirectory()).path;
var file = File(FilePath.join(
dataPath,
'appdata.json',
));
if (!await file.exists()) {
return;
}
var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
}
}
searchHistory = List.from(json['searchHistory']);
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
}
}
Map<String, dynamic> toJson() {
return {
'settings': settings._data,
@@ -108,12 +89,35 @@ class Appdata {
var file = File(FilePath.join(App.dataPath, 'implicitData.json'));
file.writeAsString(jsonEncode(implicitData));
}
@override
Future<void> doInit() async {
var dataPath = (await getApplicationSupportDirectory()).path;
var file = File(FilePath.join(
dataPath,
'appdata.json',
));
if (!await file.exists()) {
return;
}
var json = jsonDecode(await file.readAsString());
for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if (json['settings'][key] != null) {
settings[key] = json['settings'][key];
}
}
searchHistory = List.from(json['searchHistory']);
var implicitDataFile = File(FilePath.join(dataPath, 'implicitData.json'));
if (await implicitDataFile.exists()) {
implicitData = jsonDecode(await implicitDataFile.readAsString());
}
}
}
final appdata = Appdata();
final appdata = Appdata._create();
class Settings with ChangeNotifier {
Settings();
Settings._create();
final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief
@@ -158,9 +162,12 @@ class Settings with ChangeNotifier {
'customImageProcessing': defaultCustomImageProcessing,
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'comicSourceListUrl':
"https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'preloadImageCount': 4,
'followUpdatesFolder': null,
'initialPage': '0',
'comicListDisplayMode': 'paging', // paging, continuous
};
operator [](String key) {

View File

@@ -145,7 +145,7 @@ class RandomCategoryPartWithRuntimeData extends BaseCategoryPart {
}
CategoryData getCategoryDataWithKey(String key) {
for (var source in ComicSource._sources) {
for (var source in ComicSource.all()) {
if (source.categoryData?.key == key) {
return source.categoryData!;
}

View File

@@ -13,6 +13,7 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/init.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
@@ -27,81 +28,29 @@ part 'parser.dart';
part 'models.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
part 'types.dart';
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
String? next);
class ComicSourceManager with ChangeNotifier, Init {
final List<ComicSource> _sources = [];
typedef LoginFunction = Future<Res<bool>> Function(String, String);
static ComicSourceManager? _instance;
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
ComicSourceManager._create();
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
String id, String? ep);
factory ComicSourceManager() => _instance ??= ComicSourceManager._create();
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
String id, String? subId, int page, String? replyTo);
List<ComicSource> all() => List.from(_sources);
typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo);
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
String comicId, String? next);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
String comicId, bool isLiking);
/// [isLiking] is true if the user is liking the comment, false if unliking.
/// return the new likes count or null.
typedef LikeCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isLiking);
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
/// return the new vote count or null.
typedef VoteCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef HandleClickTagEvent = Map<String, String> Function(
String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
class ComicSource {
static final List<ComicSource> _sources = [];
static final List<Function> _listeners = [];
static void addListener(Function listener) {
_listeners.add(listener);
}
static void removeListener(Function listener) {
_listeners.remove(listener);
}
static void notifyListeners() {
for (var listener in _listeners) {
listener();
}
}
static List<ComicSource> all() => List.from(_sources);
static ComicSource? find(String key) =>
ComicSource? find(String key) =>
_sources.firstWhereOrNull((element) => element.key == key);
static ComicSource? fromIntKey(int key) =>
ComicSource? fromIntKey(int key) =>
_sources.firstWhereOrNull((element) => element.key.hashCode == key);
static Future<void> init() async {
@override
@protected
Future<void> doInit() async {
await JsEngine().ensureInit();
final path = "${App.dataPath}/comic_source";
if (!(await Directory(path).exists())) {
Directory(path).create();
@@ -120,26 +69,49 @@ class ComicSource {
}
}
static Future reload() async {
Future reload() async {
_sources.clear();
JsEngine().runCode("ComicSource.sources = {};");
await init();
await doInit();
notifyListeners();
}
static void add(ComicSource source) {
void add(ComicSource source) {
_sources.add(source);
notifyListeners();
}
static void remove(String key) {
void remove(String key) {
_sources.removeWhere((element) => element.key == key);
notifyListeners();
}
static final availableUpdates = <String, String>{};
bool get isEmpty => _sources.isEmpty;
static bool get isEmpty => _sources.isEmpty;
/// Key is the source key, value is the version.
final _availableUpdates = <String, String>{};
void updateAvailableUpdates(Map<String, String> updates) {
_availableUpdates.addAll(updates);
notifyListeners();
}
Map<String, String> get availableUpdates => Map.from(_availableUpdates);
void notifyStateChange() {
notifyListeners();
}
}
class ComicSource {
static List<ComicSource> all() => ComicSourceManager().all();
static ComicSource? find(String key) => ComicSourceManager().find(key);
static ComicSource? fromIntKey(int key) =>
ComicSourceManager().fromIntKey(key);
static bool get isEmpty => ComicSourceManager().isEmpty;
/// Name of this source.
final String name;

View File

@@ -111,6 +111,9 @@ class Comic {
@override
int get hashCode => id.hashCode ^ sourceKey.hashCode;
@override
toString() => "$sourceKey@$id";
}
class ComicDetails with HistoryMixin {
@@ -336,8 +339,10 @@ class ComicChapters {
}
if (chapters.isNotEmpty) {
return ComicChapters(chapters);
} else {
} else if (groupedChapters.isNotEmpty) {
return ComicChapters.grouped(groupedChapters);
} else {
throw ArgumentError("Empty chapter list");
}
}

View File

@@ -0,0 +1,48 @@
part of 'comic_source.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit.
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page);
/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page.
typedef ComicListBuilderWithNext = Future<Res<List<Comic>>> Function(
String? next);
typedef LoginFunction = Future<Res<bool>> Function(String, String);
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
typedef LoadComicPagesFunc = Future<Res<List<String>>> Function(
String id, String? ep);
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
String id, String? subId, int page, String? replyTo);
typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo);
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
String comicId, String? next);
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
String comicId, bool isLiking);
/// [isLiking] is true if the user is liking the comment, false if unliking.
/// return the new likes count or null.
typedef LikeCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isLiking);
/// [isUp] is true if the user is upvoting the comment, false if downvoting.
/// return the new vote count or null.
typedef VoteCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef HandleClickTagEvent = Map<String, String> Function(
String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);

View File

@@ -224,7 +224,8 @@ class LocalFavoritesManager with ChangeNotifier {
source_folder text
);
""");
for (var folder in _getFolderNamesWithDB()) {
var folderNames = _getFolderNamesWithDB();
for (var folder in folderNames) {
var columns = _db.select("""
pragma table_info("$folder");
""");
@@ -246,6 +247,15 @@ class LocalFavoritesManager with ChangeNotifier {
break;
}
}
await appdata.ensureInit();
// Make sure the follow updates folder is ready
var followUpdateFolder = appdata.settings['followUpdatesFolder'];
if (followUpdateFolder is String &&
folderNames.contains(followUpdateFolder)) {
prepareTableForFollowUpdates(followUpdateFolder, false);
} else {
appdata.settings['followUpdatesFolder'] = null;
}
}
List<String> find(String id, ComicType type) {
@@ -849,7 +859,7 @@ class LocalFavoritesManager with ChangeNotifier {
}
}
void prepareTableForFollowUpdates(String table) {
void prepareTableForFollowUpdates(String table, [bool clearData = true]) {
// check if the table has the column "last_update_time" "has_new_update" "last_check_time"
var columns = _db.select("""
pragma table_info("$table");
@@ -866,10 +876,12 @@ class LocalFavoritesManager with ChangeNotifier {
add column has_new_update int;
""");
}
if (clearData) {
_db.execute("""
update "$table"
set has_new_update = 0;
""");
}
if (!columns.any((element) => element["name"] == "last_check_time")) {
_db.execute("""
alter table "$table"

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart';
import 'package:html/parser.dart' as html;
import 'package:html/dom.dart' as dom;
@@ -24,6 +25,7 @@ import 'package:venera/components/js_ui.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/utils/init.dart';
import 'comic_source/comic_source.dart';
import 'consts.dart';
@@ -40,7 +42,7 @@ class JavaScriptRuntimeException implements Exception {
}
}
class JsEngine with _JSEngineApi, JsUiApi {
class JsEngine with _JSEngineApi, JsUiApi, Init {
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
static JsEngine? _cache;
@@ -64,7 +66,9 @@ class JsEngine with _JSEngineApi, JsUiApi {
responseType: ResponseType.plain, validateStatus: (status) => true));
}
Future<void> init() async {
@override
@protected
Future<void> doInit() async {
if (!_closed) {
return;
}

View File

@@ -265,6 +265,7 @@ class LocalManager with ChangeNotifier {
}
_checkPathValidation();
_checkNoMedia();
await ComicSourceManager().ensureInit();
restoreDownloadingTasks();
}
@@ -421,12 +422,30 @@ class LocalManager with ChangeNotifier {
return files.map((e) => "file://${e.path}").toList();
}
bool isDownloaded(String id, ComicType type, [int? ep]) {
bool isDownloaded(String id, ComicType type,
[int? ep, ComicChapters? chapters]) {
var comic = find(id, type);
if (comic == null) return false;
if (comic.chapters == null || ep == null) return true;
if (chapters != null) {
if (comic.chapters?.length != chapters.length) {
// update
add(LocalComic(
id: comic.id,
title: comic.title,
subtitle: comic.subtitle,
tags: comic.tags,
directory: comic.directory,
chapters: chapters,
cover: comic.cover,
comicType: comic.comicType,
downloadedChapters: comic.downloadedChapters,
createdAt: comic.createdAt,
));
}
}
return comic.downloadedChapters
.contains(comic.chapters!.ids.elementAt(ep - 1));
.contains((chapters ?? comic.chapters)!.ids.elementAtOrNull(ep - 1));
}
List<DownloadTask> downloadingTasks = [];

View File

@@ -11,6 +11,7 @@ import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/handle_text_share.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'foundation/appdata.dart';
@@ -30,19 +31,22 @@ extension _FutureInit<T> on Future<T> {
Future<void> init() async {
await App.init().wait();
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
await SingleInstanceCookieJar.createInstance();
var futures = [
Rhttp.init(),
App.initComponents(),
SAFTaskWorker().init().wait(),
AppTranslation.init().wait(),
TagsTranslation.readData().wait(),
JsEngine().init().then((_) => ComicSource.init()).wait(),
JsEngine().init().wait(),
ComicSourceManager().init().wait(),
];
await Future.wait(futures);
CacheManager().setLimitSize(appdata.settings['cacheSize']);
_checkOldConfigs();
if (App.isAndroid) {
handleLinks();
handleTextShare();
}
FlutterError.onError = (details) {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");

View File

@@ -34,13 +34,10 @@ void main(List<String> args) {
await windowManager.setBackgroundColor(Colors.transparent);
}
await windowManager.setMinimumSize(const Size(500, 600));
if (!App.isLinux) {
// https://github.com/leanflutter/window_manager/issues/460
var placement = await WindowPlacement.loadFromFile();
await placement.applyToWindow();
await windowManager.show();
WindowPlacement.loop();
}
});
}
}, (error, stack) {
@@ -141,13 +138,15 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
) {
String? font;
List<String>? fallback;
if (App.isWindows) {
font = 'Segoe UI';
if (App.isLinux || App.isWindows) {
font = 'Noto Sans CJK';
fallback = [
'Segoe UI',
'Noto Sans SC',
'Noto Sans TC',
'Noto Sans',
'Microsoft YaHei',
'PingFang SC',
'Noto Sans CJK',
'Arial',
'sans-serif'
];
@@ -199,6 +198,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
'dark' => ThemeMode.dark,
_ => ThemeMode.system
},
color: Colors.transparent,
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
@@ -246,6 +246,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
);
}
return _SystemUiProvider(Material(
color: App.isLinux ? Colors.transparent : null,
child: widget,
));
}

View File

@@ -257,18 +257,7 @@ class RHttpAdapter implements HttpClientAdapter {
Future<void>? cancelFuture,
) async {
var res = await rhttp.Rhttp.request(
method: switch (options.method) {
'GET' => rhttp.HttpMethod.get,
'POST' => rhttp.HttpMethod.post,
'PUT' => rhttp.HttpMethod.put,
'PATCH' => rhttp.HttpMethod.patch,
'DELETE' => rhttp.HttpMethod.delete,
'HEAD' => rhttp.HttpMethod.head,
'OPTIONS' => rhttp.HttpMethod.options,
'TRACE' => rhttp.HttpMethod.trace,
'CONNECT' => rhttp.HttpMethod.connect,
_ => throw ArgumentError('Unsupported method: ${options.method}'),
},
method: rhttp.HttpMethod(options.method),
url: options.uri.toString(),
settings: settings,
expectBody: rhttp.HttpExpectBody.stream,
@@ -293,9 +282,27 @@ class RHttpAdapter implements HttpClientAdapter {
return ResponseBody(
res.body,
res.statusCode,
statusMessage: null,
statusMessage: _getStatusMessage(res.statusCode),
isRedirect: false,
headers: headers,
);
}
static String _getStatusMessage(int statusCode) {
return switch(statusCode) {
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "No Content",
206 => "Partial Content",
301 => "Moved Permanently",
302 => "Found",
400 => "Invalid Status Code 400: The Request is invalid.",
401 => "Invalid Status Code 401: The Request is unauthorized.",
403 => "Invalid Status Code 403: No permission to access the resource. Check your account or network.",
404 => "Invalid Status Code 404: Not found.",
429 => "Invalid Status Code 429: Too many requests. Please try again later.",
_ => "Invalid Status Code $statusCode",
};
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart';
@@ -200,6 +201,11 @@ class SingleInstanceCookieJar extends CookieJarSql {
SingleInstanceCookieJar._create(super.path);
static SingleInstanceCookieJar? instance;
static Future<void> createInstance() async {
var dataPath = (await getApplicationSupportDirectory()).path;
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
}
}
class CookieManagerSql extends Interceptor {

View File

@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:flutter_saf/flutter_saf.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
@@ -739,11 +741,12 @@ class ArchiveDownloadTask extends DownloadTask {
path = dir.path;
}
var resultFile = File(FilePath.join(path!, "archive.zip"));
var archiveFile =
File(FilePath.join(App.dataPath, "archive_downloading.zip"));
Log.info("Download", "Downloading $archiveUrl");
_downloader = FileDownloader(archiveUrl, resultFile.path);
_downloader = FileDownloader(archiveUrl, archiveFile.path);
bool isDownloaded = false;
@@ -772,22 +775,33 @@ class ArchiveDownloadTask extends DownloadTask {
}
try {
await extractArchive(path!);
await _extractArchive(archiveFile.path, path!);
} catch (e) {
_setError("Failed to extract archive: $e");
return;
}
await resultFile.deleteIgnoreError();
await archiveFile.deleteIgnoreError();
LocalManager().completeTask(this);
}
static Future<void> extractArchive(String path) async {
var resultFile = FilePath.join(path, "archive.zip");
static Future<void> _extractArchive(String archive, String outDir) async {
var out = Directory(outDir);
if (out is AndroidDirectory) {
// Saf directory can't be accessed by native code.
var cacheDir = FilePath.join(App.cachePath, "archive_downloading");
Directory(cacheDir).forceCreateSync();
await Isolate.run(() {
ZipFile.openAndExtract(resultFile, path);
ZipFile.openAndExtract(archive, cacheDir);
});
await copyDirectoryIsolate(Directory(cacheDir), Directory(outDir));
await Directory(cacheDir).deleteIgnoreError(recursive: true);
} else {
await Isolate.run(() {
ZipFile.openAndExtract(archive, outDir);
});
}
}
@override

View File

@@ -90,7 +90,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
with AutomaticKeepAliveClientMixin {
bool isLoading = true;
static const _kComicHeight = 132.0;
static const _kComicHeight = 162.0;
get _comicWidth => _kComicHeight * 0.7;
@@ -152,7 +152,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
}
Widget buildComic(Comic c) {
return SimpleComicTile(comic: c)
return SimpleComicTile(comic: c, withTitle: true)
.paddingLeft(_kLeftPadding)
.paddingBottom(2);
}

View File

@@ -186,12 +186,17 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
late TabController tabController;
int index = 0;
late int index;
@override
void initState() {
super.initState();
history = widget.history;
if (history?.group != null) {
index = history!.group! - 1;
} else {
index = 0;
}
}
@override
@@ -199,6 +204,7 @@ class _GroupedComicChaptersState extends State<_GroupedComicChapters>
state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.chapters!;
tabController = TabController(
initialIndex: index,
length: chapters.ids.length,
vsync: this,
);

View File

@@ -651,10 +651,16 @@ class _CommentImage {
}
class RichCommentContent extends StatefulWidget {
const RichCommentContent({super.key, required this.text});
const RichCommentContent({
super.key,
required this.text,
this.showImages = true,
});
final String text;
final bool showImages;
@override
State<RichCommentContent> createState() => _RichCommentContentState();
}
@@ -808,7 +814,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
children: textSpan,
),
);
if (images.isNotEmpty) {
if (images.isNotEmpty && widget.showImages) {
content = Column(
mainAxisSize: MainAxisSize.min,
children: [

View File

@@ -138,7 +138,10 @@ class _CommentWidget extends StatelessWidget {
),
const SizedBox(height: 4),
Expanded(
child: RichCommentContent(text: comment.content).fixWidth(324),
child: RichCommentContent(
text: comment.content,
showImages: false,
).fixWidth(324),
),
const SizedBox(height: 4),
if (comment.time != null)

View File

@@ -40,10 +40,11 @@ class ComicSourcePage extends StatelessWidget {
}
}
if (shouldUpdate.isNotEmpty) {
var updates = <String, String>{};
for (var key in shouldUpdate) {
ComicSource.availableUpdates[key] = versions[key]!;
updates[key] = versions[key]!;
}
ComicSource.notifyListeners();
ComicSourceManager().updateAvailableUpdates(updates);
}
return shouldUpdate.length;
}
@@ -73,13 +74,13 @@ class _BodyState extends State<_Body> {
@override
void initState() {
super.initState();
ComicSource.addListener(updateUI);
ComicSourceManager().addListener(updateUI);
}
@override
void dispose() {
super.dispose();
ComicSource.removeListener(updateUI);
ComicSourceManager().removeListener(updateUI);
}
@override
@@ -115,7 +116,7 @@ class _BodyState extends State<_Body> {
onConfirm: () {
var file = File(source.filePath);
file.delete();
ComicSource.remove(source.key);
ComicSourceManager().remove(source.key);
_validatePages();
App.forceRebuild();
},
@@ -136,7 +137,7 @@ class _BodyState extends State<_Body> {
child: const Text("cancel")),
TextButton(
onPressed: () async {
await ComicSource.reload();
await ComicSourceManager().reload();
App.forceRebuild();
},
child: const Text("continue")),
@@ -150,7 +151,7 @@ class _BodyState extends State<_Body> {
}
context.to(
() => _EditFilePage(source.filePath, () async {
await ComicSource.reload();
await ComicSourceManager().reload();
setState(() {});
}),
);
@@ -162,7 +163,7 @@ class _BodyState extends State<_Body> {
App.rootContext.showMessage(message: "Invalid url config");
return;
}
ComicSource.remove(source.key);
ComicSourceManager().remove(source.key);
bool cancel = false;
LoadingDialogController? controller;
if (showLoading) {
@@ -179,14 +180,14 @@ class _BodyState extends State<_Body> {
controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!);
if (ComicSource.availableUpdates.containsKey(source.key)) {
ComicSource.availableUpdates.remove(source.key);
if (ComicSourceManager().availableUpdates.containsKey(source.key)) {
ComicSourceManager().availableUpdates.remove(source.key);
}
} catch (e) {
if (cancel) return;
App.rootContext.showMessage(message: e.toString());
}
await ComicSource.reload();
await ComicSourceManager().reload();
App.forceRebuild();
}
@@ -304,7 +305,7 @@ class _BodyState extends State<_Body> {
Future<void> addSource(String js, String fileName) async {
var comicSource = await ComicSourceParser().createAndParse(js, fileName);
ComicSource.add(comicSource);
ComicSourceManager().add(comicSource);
_addAllPagesWithComicSource(comicSource);
appdata.saveData();
App.forceRebuild();
@@ -563,7 +564,7 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
}
void showUpdateDialog() async {
var text = ComicSource.availableUpdates.entries.map((e) {
var text = ComicSourceManager().availableUpdates.entries.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}";
}).join("\n");
bool doUpdate = false;
@@ -592,9 +593,9 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
withProgress: true,
);
int current = 0;
int total = ComicSource.availableUpdates.length;
int total = ComicSourceManager().availableUpdates.length;
try {
var shouldUpdate = ComicSource.availableUpdates.keys.toList();
var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList();
for (var key in shouldUpdate) {
var source = ComicSource.find(key)!;
await _BodyState.update(source, false);
@@ -692,7 +693,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
@override
Widget build(BuildContext context) {
var newVersion = ComicSource.availableUpdates[source.key];
var newVersion = ComicSourceManager().availableUpdates[source.key];
bool hasUpdate =
newVersion != null && compareSemVer(newVersion, source.version);
@@ -960,7 +961,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> {
source.data["account"] = null;
source.account?.logout();
source.saveData();
ComicSource.notifyListeners();
ComicSourceManager().notifyStateChange();
setState(() {});
},
trailing: const Icon(Icons.logout),

View File

@@ -518,11 +518,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
),
],
);
body = Scrollbar(
body = AppScrollBar(
topPadding: 48,
controller: scrollController,
thickness: App.isDesktop ? 8 : 12,
radius: const Radius.circular(8),
interactive: true,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: body,

View File

@@ -110,6 +110,15 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
child: Text(widget.data.title),
),
actions: [
Tooltip(
message: "Refresh".tl,
child: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
comicListKey.currentState!.refresh();
},
),
),
MenuButton(entries: [
MenuEntry(
icon: Icons.sync,

View File

@@ -703,8 +703,9 @@ abstract class FollowUpdatesService {
if (_isInitialized) return;
_isInitialized = true;
_check();
DataSync().addListener(updateFollowUpdatesUI);
// A short interval will not affect the performance since every comic has a check time.
Timer.periodic(const Duration(minutes: 5), (timer) {
Timer.periodic(const Duration(minutes: 10), (timer) {
_check();
});
}

View File

@@ -162,16 +162,50 @@ class _SyncDataWidgetState extends State<_SyncDataWidget>
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (DataSync().lastError != null)
InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
showDialogMessage(
App.rootContext,
"Error".tl,
DataSync().lastError!,
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Colors.red,
size: 18,
),
const SizedBox(width: 4),
Text('Error'.tl, style: ts.s12),
],
),
),
).paddingRight(4),
IconButton(
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}),
},
),
IconButton(
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}),
},
),
],
),
),
@@ -263,7 +297,7 @@ class _HistoryState extends State<_History> {
).paddingHorizontal(16),
if (history.isNotEmpty)
SizedBox(
height: 128,
height: 136,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: history.length,
@@ -366,13 +400,14 @@ class _LocalState extends State<_Local> {
).paddingHorizontal(16),
if (local.isNotEmpty)
SizedBox(
height: 128,
height: 136,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: local.length,
itemBuilder: (context, index) {
return SimpleComicTile(comic: local[index])
.paddingHorizontal(8);
.paddingHorizontal(8)
.paddingVertical(2);
},
),
).paddingHorizontal(8),
@@ -538,7 +573,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
],
),
onPressed: () {
launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/import_comic.md");
},
).fixWidth(90).paddingRight(8),
Button.filled(
@@ -595,19 +631,19 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
@override
void initState() {
comicSources = ComicSource.all().map((e) => e.name).toList();
ComicSource.addListener(onComicSourceChange);
ComicSourceManager().addListener(onComicSourceChange);
super.initState();
}
@override
void dispose() {
ComicSource.removeListener(onComicSourceChange);
ComicSourceManager().removeListener(onComicSourceChange);
super.dispose();
}
int get _availableUpdates {
int c = 0;
ComicSource.availableUpdates.forEach((key, version) {
ComicSourceManager().availableUpdates.forEach((key, version) {
var source = ComicSource.find(key);
if (source != null) {
if (compareSemVer(version, source.version)) {
@@ -697,14 +733,24 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.update, color: context.colorScheme.primary, size: 20,),
Icon(
Icons.update,
color: context.colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text("@c updates".tlParams({
Text(
"@c updates".tlParams({
'c': _availableUpdates,
}), style: ts.withColor(context.colorScheme.primary),),
}),
style: ts.withColor(context.colorScheme.primary),
),
],
),
).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8),
)
.toAlign(Alignment.centerLeft)
.paddingHorizontal(16)
.paddingBottom(8),
],
),
),
@@ -844,7 +890,8 @@ class _ImageFavoritesState extends State<ImageFavorites> {
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
color:
Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
@@ -304,7 +305,9 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
}
});
} else {
(c as LocalComic).read();
// prevent dirty data
var comic = LocalManager().find(c.id, ComicType(c.sourceKey.hashCode))!;
comic.read();
}
},
menuBuilder: (c) {

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/pages/categories_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
@@ -39,6 +40,7 @@ class _MainPageState extends State<MainPage> {
_observer = NaviObserver();
_navigatorKey = GlobalKey();
App.mainNavigatorKey = _navigatorKey;
index = int.tryParse(appdata.settings['initialPage'].toString()) ?? 0;
super.initState();
}
@@ -60,6 +62,7 @@ class _MainPageState extends State<MainPage> {
@override
Widget build(BuildContext context) {
return NaviPane(
initialPage: index,
observer: _observer,
navigatorKey: _navigatorKey!,
paneItems: [

View File

@@ -26,7 +26,7 @@ class _ReaderImagesState extends State<_ReaderImages> {
inProgress = true;
if (reader.type == ComicType.local ||
(LocalManager()
.isDownloaded(reader.cid, reader.type, reader.chapter))) {
.isDownloaded(reader.cid, reader.type, reader.chapter, reader.widget.chapters))) {
try {
var images = await LocalManager()
.getImages(reader.cid, reader.type, reader.chapter);

View File

@@ -15,6 +15,7 @@ import 'package:photo_view/photo_view_gallery.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:venera/components/components.dart';
import 'package:venera/components/custom_slider.dart';
import 'package:venera/components/window_frame.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart';
@@ -114,7 +115,7 @@ class _ReaderState extends State<Reader>
String get cid => widget.cid;
String get eid => widget.chapters?.ids.elementAt(chapter - 1) ?? '0';
String get eid => widget.chapters?.ids.elementAtOrNull(chapter - 1) ?? '0';
List<String>? images;
@@ -169,6 +170,7 @@ class _ReaderState extends State<Reader>
void didChangeDependencies() {
super.didChangeDependencies();
initImagesPerPage(widget.initialPage ?? 1);
initReaderWindow();
}
void setImageCacheSize() async {
@@ -191,6 +193,9 @@ class _ReaderState extends State<Reader>
@override
void dispose() {
if (isFullscreen) {
fullscreen();
}
autoPageTurningTimer?.cancel();
focusNode.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -199,6 +204,7 @@ class _ReaderState extends State<Reader>
DataSync().onDataChanged();
});
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20;
disposeReaderWindow();
super.dispose();
}
@@ -218,6 +224,9 @@ class _ReaderState extends State<Reader>
}
void onKeyEvent(KeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.f12 && event is KeyUpEvent) {
fullscreen();
}
_imageViewController?.handleKeyEvent(event);
}
@@ -429,12 +438,9 @@ abstract mixin class _ReaderLocation {
bool toPage(int page) {
if (_validatePage(page)) {
if (page == this.page) {
if (!(chapter == 1 && page == 1) &&
!(chapter == maxChapter && page == maxPage)) {
if (page == this.page && page != 1 && page != maxPage) {
return false;
}
}
this.page = page;
update();
if (enablePageAnimation) {
@@ -495,9 +501,38 @@ abstract mixin class _ReaderLocation {
mixin class _ReaderWindow {
bool isFullscreen = false;
void fullscreen() {
windowManager.setFullScreen(!isFullscreen);
late WindowFrameController windowFrame;
bool _isInit = false;
void initReaderWindow() {
if (!App.isDesktop || _isInit) return;
windowFrame = WindowFrame.of(App.rootContext);
windowFrame.addCloseListener(onWindowClose);
_isInit = true;
}
void fullscreen() async {
if (!App.isDesktop) return;
await windowManager.hide();
await windowManager.setFullScreen(!isFullscreen);
await windowManager.show();
isFullscreen = !isFullscreen;
WindowFrame.of(App.rootContext).setWindowFrame(!isFullscreen);
}
bool onWindowClose() {
if (Navigator.of(App.rootContext).canPop()) {
Navigator.of(App.rootContext).pop();
return false;
} else {
return true;
}
}
void disposeReaderWindow() {
if (!App.isDesktop) return;
windowFrame.removeCloseListener(onWindowClose);
}
}

View File

@@ -330,11 +330,10 @@ class _WebdavSettingState extends State<_WebdavSetting> {
String url = "";
String user = "";
String pass = "";
bool autoSync = false;
bool autoSync = true;
bool isTesting = false;
bool upload = true;
bool isEnabled = false;
@override
void initState() {
@@ -349,8 +348,7 @@ class _WebdavSettingState extends State<_WebdavSetting> {
url = configs[0];
user = configs[1];
pass = configs[2];
isEnabled = true;
autoSync = appdata.implicitData['webdavAutoSync'] ?? false;
autoSync = appdata.implicitData['webdavAutoSync'] ?? true;
}
void onAutoSyncChanged(bool value) {
@@ -368,16 +366,11 @@ class _WebdavSettingState extends State<_WebdavSetting> {
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 12),
SwitchListTile(
title: Text("WebDAV Auto Sync".tl),
value: autoSync,
onChanged: onAutoSyncChanged,
),
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: "URL",
hintText: "A valid WebDav directory URL".tl,
border: OutlineInputBorder(),
),
controller: TextEditingController(text: url),
@@ -402,6 +395,16 @@ class _WebdavSettingState extends State<_WebdavSetting> {
onChanged: (value) => pass = value,
),
const SizedBox(height: 12),
ListTile(
leading: Icon(Icons.sync),
title: Text("Auto Sync Data".tl),
contentPadding: EdgeInsets.zero,
trailing: Switch(
value: autoSync,
onChanged: onAutoSyncChanged,
),
),
const SizedBox(height: 12),
Row(
children: [
Text("Operation".tl),
@@ -428,7 +431,10 @@ class _WebdavSettingState extends State<_WebdavSetting> {
],
),
const SizedBox(height: 16),
Container(
AnimatedSize(
duration: const Duration(milliseconds: 200),
child: autoSync
? Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
@@ -439,10 +445,14 @@ class _WebdavSettingState extends State<_WebdavSetting> {
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text("Once the operation is successful, app will automatically sync data with the server.".tl),
child: Text(
"Once the operation is successful, app will automatically sync data with the server."
.tl),
),
],
),
)
: const SizedBox.shrink(),
),
const SizedBox(height: 16),
Center(

View File

@@ -80,6 +80,24 @@ class _ExploreSettingsState extends State<ExploreSettings> {
'japanese': "Japanese",
},
).toSliver(),
SelectSetting(
title: "Initial Page".tl,
settingKey: "initialPage",
optionTranslation: {
'0': "Home Page".tl,
'1': "Favorites Page".tl,
'2': "Explore Page".tl,
'3': "Categories Page".tl,
},
).toSliver(),
SelectSetting(
title: "Display mode of comic list".tl,
settingKey: "comicListDisplayMode",
optionTranslation: {
"paging": "Paging".tl,
"Continuous": "Continuous".tl,
},
).toSliver(),
],
);
}

View File

@@ -177,7 +177,7 @@ abstract class CBZ {
tags: metaData.tags,
comicType: ComicType.local,
directory: dest.name,
chapters: ComicChapters.fromJson(cpMap),
chapters: ComicChapters.fromJsonOrNull(cpMap),
downloadedChapters: cpMap?.keys.toList() ?? [],
cover: 'cover.${coverFile.extension}',
createdAt: DateTime.now(),

View File

@@ -95,15 +95,17 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
}
var comicSourceDir = FilePath.join(cacheDirPath, "comic_source");
if (Directory(comicSourceDir).existsSync()) {
Directory(FilePath.join(App.dataPath, "comic_source"))
.deleteIfExistsSync(recursive: true);
Directory(FilePath.join(App.dataPath, "comic_source")).createSync();
for (var file in Directory(comicSourceDir).listSync()) {
if (file is File) {
var targetFile =
FilePath.join(App.dataPath, "comic_source", file.name);
File(targetFile).deleteIfExistsSync();
await file.copy(targetFile);
}
}
await ComicSource.reload();
await ComicSourceManager().reload();
}
} finally {
cacheDir.deleteIgnoreError(recursive: true);

View File

@@ -1,4 +1,3 @@
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
@@ -10,6 +9,7 @@ import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/data.dart';
import 'package:venera/utils/ext.dart';
import 'package:webdav_client/webdav_client.dart' hide File;
import 'package:rhttp/rhttp.dart' as rhttp;
import 'io.dart';
@@ -19,7 +19,7 @@ class DataSync with ChangeNotifier {
downloadData();
}
LocalFavoritesManager().addListener(onDataChanged);
ComicSource.addListener(onDataChanged);
ComicSourceManager().addListener(onDataChanged);
}
void onDataChanged() {
@@ -40,7 +40,11 @@ class DataSync with ChangeNotifier {
bool get isUploading => _isUploading;
bool haveWaitingTask = false;
bool _haveWaitingTask = false;
String? _lastError;
String? get lastError => _lastError;
bool get isEnabled {
var config = appdata.settings['webdav'];
@@ -64,17 +68,19 @@ class DataSync with ChangeNotifier {
Future<Res<bool>> uploadData() async {
if (isDownloading) return const Res(true);
if (haveWaitingTask) return const Res(true);
if (_haveWaitingTask) return const Res(true);
while (isUploading) {
haveWaitingTask = true;
_haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
_haveWaitingTask = false;
_isUploading = true;
_lastError = null;
notifyListeners();
try {
var config = _validateConfig();
if (config == null) {
_lastError = 'Invalid WebDAV configuration';
return const Res.error('Invalid WebDAV configuration');
}
if (config.isEmpty) {
@@ -90,21 +96,14 @@ class DataSync with ChangeNotifier {
url,
user: user,
password: pass,
adapter: IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
adapter: RHttpAdapter(
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
),
),
);
try {
await client.ping();
} catch (e) {
Log.error("Upload Data", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try {
appdata.settings['dataVersion']++;
await appdata.saveData(false);
@@ -131,6 +130,7 @@ class DataSync with ChangeNotifier {
return const Res(true);
} catch (e, s) {
Log.error("Upload Data", e, s);
_lastError = e.toString();
return Res.error(e.toString());
}
} finally {
@@ -140,17 +140,19 @@ class DataSync with ChangeNotifier {
}
Future<Res<bool>> downloadData() async {
if (haveWaitingTask) return const Res(true);
if (_haveWaitingTask) return const Res(true);
while (isDownloading || isUploading) {
haveWaitingTask = true;
_haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
_haveWaitingTask = false;
_isDownloading = true;
_lastError = null;
notifyListeners();
try {
var config = _validateConfig();
if (config == null) {
_lastError = 'Invalid WebDAV configuration';
return const Res.error('Invalid WebDAV configuration');
}
if (config.isEmpty) {
@@ -166,21 +168,14 @@ class DataSync with ChangeNotifier {
url,
user: user,
password: pass,
adapter: IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
adapter: RHttpAdapter(
rhttp.ClientSettings(
proxySettings:
proxy == null ? null : rhttp.ProxySettings.proxy(proxy),
),
),
);
try {
await client.ping();
} catch (e) {
Log.error("Data Sync", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try {
var files = await client.readDir('/');
files.sort((a, b) => b.name!.compareTo(a.name!));
@@ -206,6 +201,7 @@ class DataSync with ChangeNotifier {
return const Res(true);
} catch (e, s) {
Log.error("Data Sync", e, s);
_lastError = e.toString();
return Res.error(e.toString());
}
} finally {

View File

@@ -0,0 +1,22 @@
import 'package:flutter/services.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/pages/aggregated_search_page.dart';
bool _isHandling = false;
/// Handle text share event.
/// App will navigate to [AggregatedSearchPage] with the shared text as keyword.
void handleTextShare() async {
if (_isHandling) return;
_isHandling = true;
var channel = EventChannel('venera/text_share');
await for (var event in channel.receiveBroadcastStream()) {
if (App.mainNavigatorKey == null) {
await Future.delayed(const Duration(milliseconds: 200));
}
if (event is String) {
App.rootContext.to(() => AggregatedSearchPage(keyword: event));
}
}
}

40
lib/utils/init.dart Normal file
View File

@@ -0,0 +1,40 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
/// A mixin class that provides a way to ensure the class is initialized.
abstract mixin class Init {
bool _isInit = false;
final _initCompleter = <Completer<void>>[];
/// Ensure the class is initialized.
Future<void> ensureInit() async {
if (_isInit) {
return;
}
var completer = Completer<void>();
_initCompleter.add(completer);
return completer.future;
}
Future<void> _markInit() async {
_isInit = true;
for (var completer in _initCompleter) {
completer.complete();
}
_initCompleter.clear();
}
@protected
Future<void> doInit();
/// Initialize the class.
Future<void> init() async {
if (_isInit) {
return;
}
await doInit();
await _markInit();
}
}

View File

@@ -40,6 +40,7 @@ extension FileSystemEntityExt on FileSystemEntity {
return p.basename(path);
}
/// Delete the file or directory and ignore errors.
Future<void> deleteIgnoreError({bool recursive = false}) async {
try {
await delete(recursive: recursive);
@@ -48,12 +49,14 @@ extension FileSystemEntityExt on FileSystemEntity {
}
}
/// Delete the file or directory if it exists.
Future<void> deleteIfExists({bool recursive = false}) async {
if (existsSync()) {
await delete(recursive: recursive);
}
}
/// Delete the file or directory if it exists.
void deleteIfExistsSync({bool recursive = false}) {
if (existsSync()) {
deleteSync(recursive: recursive);
@@ -74,12 +77,14 @@ extension FileExtension on File {
await newFile.writeAsBytes(await readAsBytes());
}
/// Get the base name of the file without the extension.
String get basenameWithoutExt {
return p.basenameWithoutExtension(path);
}
}
extension DirectoryExtension on Directory {
/// Calculate the size of the directory.
Future<int> get size async {
if (!existsSync()) return 0;
int total = 0;
@@ -91,6 +96,7 @@ extension DirectoryExtension on Directory {
return total;
}
/// Change the base name of the directory.
Directory renameX(String newName) {
newName = sanitizeFileName(newName);
return renameSync(path.replaceLast(name, newName));
@@ -100,6 +106,7 @@ extension DirectoryExtension on Directory {
return File(FilePath.join(path, name));
}
/// Delete the contents of the directory.
void deleteContentsSync({recursive = true}) {
if (!existsSync()) return;
for (var f in listSync()) {
@@ -107,14 +114,24 @@ extension DirectoryExtension on Directory {
}
}
/// Delete the contents of the directory.
Future<void> deleteContents({recursive = true}) async {
if (!existsSync()) return;
for (var f in listSync()) {
await f.deleteIfExists(recursive: recursive);
}
}
/// Create the directory. If the directory already exists, delete it first.
void forceCreateSync() {
if (existsSync()) {
deleteSync(recursive: true);
}
createSync(recursive: true);
}
}
/// Sanitize the file name. Remove invalid characters and trim the file name.
String sanitizeFileName(String fileName) {
if (fileName.endsWith('.')) {
fileName = fileName.substring(0, fileName.length - 1);
@@ -157,6 +174,8 @@ Future<void> copyDirectory(Directory source, Directory destination) async {
}
}
/// Copy the **contents** of the source directory to the destination directory.
/// This function is executed in an isolate to prevent the UI from freezing.
Future<void> copyDirectoryIsolate(
Directory source, Directory destination) async {
await Isolate.run(() => overrideIO(() => copyDirectory(source, destination)));

View File

@@ -48,6 +48,12 @@ static void my_application_activate(GApplication* application) {
}
gtk_window_set_default_size(window, 1280, 720);
GdkVisual* visual;
gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE);
visual = gdk_screen_get_rgba_visual(screen);
if (visual != NULL && gdk_screen_is_composited(screen)) {
gtk_widget_set_visual(GTK_WIDGET(window), visual);
}
gtk_widget_show(GTK_WIDGET(window));
g_autoptr(FlDartProject) project = fl_dart_project_new();
@@ -58,6 +64,7 @@ static void my_application_activate(GApplication* application) {
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_hide(GTK_WIDGET(window));
gtk_widget_grab_focus(GTK_WIDGET(view));
}

View File

@@ -408,8 +408,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
resolved-ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3"
ref: "8feae95df7fb00455df129ad7a0dfec1d0e8d8e4"
resolved-ref: "8feae95df7fb00455df129ad7a0dfec1d0e8d8e4"
url: "https://github.com/wgh136/flutter_qjs"
source: git
version: "0.3.7"
@@ -425,17 +425,17 @@ packages:
dependency: transitive
description:
name: flutter_rust_bridge
sha256: "3292ad6085552987b8b3b9a7e5805567f4013372d302736b702801acb001ee00"
sha256: "5a5c7a5deeef2cc2ffe6076a33b0429f4a20ceac22a397297aed2b1eb067e611"
url: "https://pub.dev"
source: hosted
version: "2.7.1"
version: "2.9.0"
flutter_saf:
dependency: "direct main"
description:
path: "."
ref: "7637b8b67d0a831f3cd7e702b8173e300880d32e"
resolved-ref: "7637b8b67d0a831f3cd7e702b8173e300880d32e"
url: "https://github.com/pkuislm/flutter_saf.git"
ref: "690a03a954f1603e0149cfd479c8961b88f21336"
resolved-ref: "690a03a954f1603e0149cfd479c8961b88f21336"
url: "https://github.com/venera-app/flutter_saf"
source: git
version: "0.0.1"
flutter_test:
@@ -612,8 +612,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "9a784b193af5d55b2a35e58fa390bda3e4f35d00"
resolved-ref: "9a784b193af5d55b2a35e58fa390bda3e4f35d00"
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
resolved-ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
url: "https://github.com/venera-app/lodepng_flutter"
source: git
version: "0.0.1"
@@ -758,10 +758,10 @@ packages:
dependency: "direct main"
description:
name: rhttp
sha256: "3deabc6c3384b4efa252dfb4a5059acc6530117fdc1b10f5f67ff9768c9af75a"
sha256: "037e9b59a68bb4ba664db1cbb4601e878cf5a2fc1cb3d0a9c58e3776609dec4d"
url: "https://pub.dev"
source: hosted
version: "0.10.0"
version: "0.11.0"
screen_retriever:
dependency: transitive
description:
@@ -1044,8 +1044,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
resolved-ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
ref: "2f669c98fb81cff1c64fee93466a1475c77e4273"
resolved-ref: "2f669c98fb81cff1c64fee93466a1475c77e4273"
url: "https://github.com/wgh136/webdav_client"
source: git
version: "1.2.2"
@@ -1093,10 +1093,10 @@ packages:
dependency: "direct main"
description:
name: zip_flutter
sha256: bbf3160062610a43901b7ebbc6f6dd46519540f03a84027dc7b1fff399dda1ac
sha256: c4d5a34c5803def866bc550926bb16fe89717c9b7304695d5b2ede30964eb8a8
url: "https://pub.dev"
source: hosted
version: "0.0.10"
version: "0.0.12"
sdks:
dart: ">=3.7.0 <4.0.0"
flutter: ">=3.29.0"
flutter: ">=3.29.2"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.3.1+131
version: 1.3.3+133
environment:
sdk: '>=3.6.0 <4.0.0'
flutter: 3.29.0
flutter: 3.29.2
dependencies:
flutter:
@@ -19,7 +19,7 @@ dependencies:
flutter_qjs:
git:
url: https://github.com/wgh136/flutter_qjs
ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3
ref: 8feae95df7fb00455df129ad7a0dfec1d0e8d8e4
crypto: ^3.0.6
dio: ^5.8.0+1
html: ^0.15.5
@@ -52,22 +52,22 @@ dependencies:
sliver_tools: ^0.2.12
flutter_file_dialog: ^3.0.2
file_selector: ^1.0.3
zip_flutter: ^0.0.10
zip_flutter: ^0.0.12
lodepng_flutter:
git:
url: https://github.com/venera-app/lodepng_flutter
ref: 9a784b193af5d55b2a35e58fa390bda3e4f35d00
rhttp: 0.10.0
ref: ac7d05dde32e8d728102a9ff66e6b55f05d94ba1
rhttp: ^0.11.0
webdav_client:
git:
url: https://github.com/wgh136/webdav_client
ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1
ref: 2f669c98fb81cff1c64fee93466a1475c77e4273
battery_plus: ^6.2.1
local_auth: ^2.3.0
flutter_saf:
git:
url: https://github.com/pkuislm/flutter_saf.git
ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e
url: https://github.com/venera-app/flutter_saf
ref: 690a03a954f1603e0149cfd479c8961b88f21336
dynamic_color: ^1.7.0
shimmer_animation: ^2.1.0
flutter_memory_info: ^0.0.1

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.85.1"
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]