mirror of
https://github.com/venera-app/venera.git
synced 2025-12-15 22:51:15 +00:00
Compare commits
41 Commits
v1.5.3-dev
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49abf92724 | ||
|
|
38376c5b2e | ||
|
|
4053faa186 | ||
|
|
17fd9b3606 | ||
|
|
792c41fdc3 | ||
|
|
05e661b101 | ||
|
|
46131fcf41 | ||
|
|
59750332cd | ||
|
|
fd017a35f9 | ||
|
|
3834d0211f | ||
|
|
10bec09c80 | ||
|
|
62dd742280 | ||
|
|
03603a53e1 | ||
|
|
2847af91ff | ||
|
|
0bc01f718a | ||
|
|
b60119170a | ||
|
|
f4af6f3954 | ||
|
|
9e9d1ac3b1 | ||
|
|
b3b9199cc3 | ||
| dd00ba11c8 | |||
| e87fb535b8 | |||
|
|
df1649def6 | ||
|
|
99559eaff8 | ||
|
|
39a834815d | ||
|
|
a9e76201f3 | ||
|
|
0044d95e97 | ||
|
|
9636cf62cb | ||
| 5ccf0eea43 | |||
| e8d98e8274 | |||
|
|
d22501198a | ||
|
|
be23c4fe68 | ||
|
|
a8422780a0 | ||
|
|
75c2a3a417 | ||
|
|
3d194d7f6a | ||
| 09a1d2821c | |||
|
|
7842b5a1ac | ||
|
|
079f574e2f | ||
|
|
b08f11f6ac | ||
|
|
cd925df125 | ||
|
|
8c87c4a906 | ||
|
|
c234a53518 |
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
||||
- run: |
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
dart pub global activate -s git https://github.com/venera-app/flutter_to_debian.git
|
||||
- run: python3 debian/build.py x64
|
||||
- run: dart run flutter_to_arch
|
||||
- run: |
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
flutter pub get
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
|
||||
dart pub global activate flutter_to_debian
|
||||
dart pub global activate -s git https://github.com/venera-app/flutter_to_debian.git
|
||||
- name: "Patch font"
|
||||
run: |
|
||||
dart run patch/font.dart
|
||||
|
||||
46
.github/workflows/update_alt_store.yml
vendored
46
.github/workflows/update_alt_store.yml
vendored
@@ -31,30 +31,30 @@ jobs:
|
||||
- name: Update AltStore source
|
||||
id: update_source
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python update_alt_store.py
|
||||
git config --global user.name 'GitHub Action'
|
||||
git config --global user.email 'action@github.com'
|
||||
git add alt_store.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# Create a new branch for the PR
|
||||
branch_name="update-altstore-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -m "Updated source with latest release"
|
||||
git push -u origin "$branch_name"
|
||||
|
||||
# Create PR using GitHub CLI
|
||||
gh pr create \
|
||||
--title "Update AltStore source with latest release" \
|
||||
--body "This PR updates the alt_store.json file with the latest release information." \
|
||||
--head "$branch_name" \
|
||||
--base main
|
||||
|
||||
echo "changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
python update_alt_store.py
|
||||
git config --global user.name 'GitHub Action'
|
||||
git config --global user.email 'action@github.com'
|
||||
git add alt_store.json
|
||||
if git diff --staged --quiet; then
|
||||
echo "changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# Create a new branch for the PR
|
||||
branch_name="update-altstore-$(date +%Y%m%d-%H%M%S)"
|
||||
git checkout -b "$branch_name"
|
||||
git commit -m "Updated source with latest release"
|
||||
git push -u origin "$branch_name"
|
||||
|
||||
# Create PR using GitHub CLI
|
||||
gh pr create \
|
||||
--title "Update AltStore source with latest release" \
|
||||
--body "This PR updates the alt_store.json file with the latest release information." \
|
||||
--head "$branch_name" \
|
||||
--base master
|
||||
|
||||
echo "changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Calculate job duration
|
||||
id: duration
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
"bundleIdentifier": "com.github.wgh136.venera",
|
||||
"developerName": "wgh136",
|
||||
"subtitle": "A comic reader that supports reading local and network comics",
|
||||
"version": "1.4.5",
|
||||
"versionDate": "2025-06-18",
|
||||
"versionDescription": "1. Fixed an abnormal single image height issue when \"imagesPerPage > 1\". 379 \r\n2. Fixed an invalid page calculation issue when \"showSingleImageOnFirstPage\" is enabled. \r\n3. Fixed an issue with incorrect reading history when displaying a single image on the first page. \r\n4. Fixed abnormal history recording when pages are not flipped. 392 \r\n5. Fixed an issue where the download task would stop after exiting the reader. 387 \r\n6. Fixed a \"RangeError\" when translating tags. 356 \r\n7. Reset the current folder to null on the favorites page if the folder is invalid. 389 \r\n8. Fixed various issues when using a custom download path on Android. 400 \r\n9. Set the initial chapter to the first downloaded chapter if no history exists when starting to read a local comic. 405 \r\n10. Removed the config file repository URL from the app.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.4.5/venera-ios-1.4.5%2B145.ipa",
|
||||
"version": "1.5.3",
|
||||
"versionDate": "2025-10-13",
|
||||
"versionDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"localizedDescription": "A comic reader that supports reading local and network comics",
|
||||
"iconURL": "https://raw.githubusercontent.com/venera-app/venera/master/assets/app_icon.png",
|
||||
"tintColor": "#0784FC",
|
||||
"category": "utilities",
|
||||
"size": 14960268,
|
||||
"size": 15047841,
|
||||
"appPermissions": {
|
||||
"entitlements": [
|
||||
"application-identifier",
|
||||
@@ -39,6 +39,13 @@
|
||||
}
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.5.3",
|
||||
"date": "2025-10-13",
|
||||
"localizedDescription": "1. Fix an issue where the app freezes after swiping back on Android. 544\r\n2. Enable minification when building for Android. 547\r\n3. Prevent the app from creating an archive download task when the archive URL is an empty string.",
|
||||
"downloadURL": "https://github.com/venera-app/venera/releases/download/v1.5.3/venera-ios-1.5.3%2B153.ipa",
|
||||
"size": 15047841
|
||||
},
|
||||
{
|
||||
"version": "1.4.5",
|
||||
"date": "2025-06-18",
|
||||
@@ -59,6 +66,16 @@
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.4.5 - Venera 18/06/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.4.5"
|
||||
},
|
||||
{
|
||||
"appID": "com.github.wgh136.venera",
|
||||
"caption": "Update of Venera just got released!",
|
||||
"date": "2025-10-13T12:47:27Z",
|
||||
"identifier": "release-v1.5.3",
|
||||
"notify": true,
|
||||
"tintColor": "#0784FC",
|
||||
"title": "v1.5.3 - Venera 13/10/25",
|
||||
"url": "https://github.com/venera-app/venera/releases/tag/v1.5.3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1334,7 +1334,7 @@ let UI = {
|
||||
* Show an input dialog
|
||||
* @param title {string}
|
||||
* @param validator {(string) => string | null | undefined} - A function that validates the input. If the function returns a string, the dialog will show the error message.
|
||||
* @param image {string?} - Available since 1.4.6. An optional image to show in the dialog. You can use this to show a captcha.
|
||||
* @param image {string | ArrayBuffer | null | undefined} - Since 1.4.6, you can pass an image url to show an image in the dialog. Since 1.5.3, you can also pass an ArrayBuffer to show a custom image.
|
||||
* @returns {Promise<string | null>} - The input value. If the dialog is canceled, return null.
|
||||
*/
|
||||
showInputDialog: (title, validator, image) => {
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"Move to folder": "移动到文件夹",
|
||||
"Copy to folder": "复制到文件夹",
|
||||
"Delete Comic": "删除漫画",
|
||||
"Jump to Detail": "跳转详情",
|
||||
"Delete @c comics?": "删除 @c 本漫画?",
|
||||
"Add comic source": "添加漫画源",
|
||||
"Delete comic source '@n' ?": "删除漫画源 '@n' ?",
|
||||
@@ -69,6 +70,9 @@
|
||||
"Next": "前进",
|
||||
"Login with webview": "通过网页登录",
|
||||
"Read": "阅读",
|
||||
"Completed": "已完成",
|
||||
"UnCompleted": "未完成",
|
||||
"Filter reading status": "过滤阅读状态",
|
||||
"Download": "下载",
|
||||
"Favorite": "收藏",
|
||||
"Comments": "评论",
|
||||
@@ -379,6 +383,8 @@
|
||||
"Continuous": "连续",
|
||||
"Display mode of comic list": "漫画列表的显示模式",
|
||||
"Show Page Number": "显示页码",
|
||||
"Show Chapter Comments": "显示章节评论",
|
||||
"Chapter Comments": "章节评论",
|
||||
"Jump to page": "跳转到页面",
|
||||
"Page": "页面",
|
||||
"Jump": "跳转",
|
||||
@@ -464,6 +470,7 @@
|
||||
"Move": "移動",
|
||||
"Move to folder": "移動到資料夾",
|
||||
"Copy to folder": "複製到資料夾",
|
||||
"Jump to Detail": "跳轉詳情",
|
||||
"Delete Comic": "刪除漫畫",
|
||||
"Delete @c comics?": "刪除 @c 本漫畫?",
|
||||
"Add comic source": "添加漫畫源",
|
||||
@@ -487,6 +494,9 @@
|
||||
"Next": "前進",
|
||||
"Login with webview": "透過網頁登入",
|
||||
"Read": "閱讀",
|
||||
"Completed": "已完成",
|
||||
"UnCompleted": "未完成",
|
||||
"Filter reading status": "過濾閱讀狀態",
|
||||
"Download": "下載",
|
||||
"Favorite": "收藏",
|
||||
"Comments": "評論",
|
||||
@@ -796,6 +806,8 @@
|
||||
"Continuous": "連續",
|
||||
"Display mode of comic list": "漫畫列表的顯示模式",
|
||||
"Show Page Number": "顯示頁碼",
|
||||
"Show Chapter Comments": "顯示章節評論",
|
||||
"Chapter Comments": "章節評論",
|
||||
"Jump to page": "跳轉到頁面",
|
||||
"Page": "頁面",
|
||||
"Jump": "跳轉",
|
||||
|
||||
@@ -553,6 +553,51 @@ If `load` function is implemented, `loadNext` function will be ignored.
|
||||
*/
|
||||
sendComment: async (comicId, subId, content, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] load chapter comments
|
||||
*
|
||||
* Chapter comments are displayed in the reader.
|
||||
* Same rich text support as loadComments.
|
||||
*
|
||||
* Note: To control reply functionality:
|
||||
* - If a comment does not support replies, set its `id` to null/undefined
|
||||
* - Or set its `replyCount` to null/undefined
|
||||
* - The reply button will only show when both `id` and `replyCount` are present
|
||||
*
|
||||
* @param comicId {string}
|
||||
* @param epId {string} - chapter id
|
||||
* @param page {number}
|
||||
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
|
||||
* @returns {Promise<{comments: Comment[], maxPage: number?}>}
|
||||
*
|
||||
* @example
|
||||
* // Example for comments without reply support:
|
||||
* return {
|
||||
* comments: data.list.map(e => ({
|
||||
* userName: e.user_name,
|
||||
* avatar: e.user_avatar,
|
||||
* content: e.comment,
|
||||
* time: e.create_at,
|
||||
* replyCount: null, // or undefined - no reply support
|
||||
* id: null, // or undefined - no reply support
|
||||
* })),
|
||||
* maxPage: Math.ceil(total / 20)
|
||||
* }
|
||||
*/
|
||||
loadChapterComments: async (comicId, epId, page, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] send a chapter comment, return any value to indicate success
|
||||
* @param comicId {string}
|
||||
* @param epId {string} - chapter id
|
||||
* @param content {string}
|
||||
* @param replyTo {string?} - commentId to reply, not null when reply to a comment
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
sendChapterComment: async (comicId, epId, content, replyTo) => {
|
||||
|
||||
},
|
||||
/**
|
||||
* [Optional] like or unlike a comment
|
||||
|
||||
@@ -753,9 +753,9 @@ class SliverGridComics extends StatefulWidget {
|
||||
|
||||
final List<MenuEntry> Function(Comic)? menuBuilder;
|
||||
|
||||
final void Function(Comic)? onTap;
|
||||
final void Function(Comic, int heroID)? onTap;
|
||||
|
||||
final void Function(Comic)? onLongPressed;
|
||||
final void Function(Comic, int heroID)? onLongPressed;
|
||||
|
||||
@override
|
||||
State<SliverGridComics> createState() => _SliverGridComicsState();
|
||||
@@ -856,52 +856,51 @@ class _SliverGridComics extends StatelessWidget {
|
||||
|
||||
final List<MenuEntry> Function(Comic)? menuBuilder;
|
||||
|
||||
final void Function(Comic)? onTap;
|
||||
final void Function(Comic, int heroID)? onTap;
|
||||
|
||||
final void Function(Comic)? onLongPressed;
|
||||
final void Function(Comic, int heroID)? onLongPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index == comics.length - 1) {
|
||||
onLastItemBuild?.call();
|
||||
}
|
||||
var badge = badgeBuilder?.call(comics[index]);
|
||||
var isSelected =
|
||||
selection == null ? false : selection![comics[index]] ?? false;
|
||||
var comic = ComicTile(
|
||||
comic: comics[index],
|
||||
badge: badge,
|
||||
menuOptions: menuBuilder?.call(comics[index]),
|
||||
onTap: onTap != null ? () => onTap!(comics[index]) : null,
|
||||
onLongPressed: onLongPressed != null
|
||||
? () => onLongPressed!(comics[index])
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (index == comics.length - 1) {
|
||||
onLastItemBuild?.call();
|
||||
}
|
||||
var badge = badgeBuilder?.call(comics[index]);
|
||||
var isSelected = selection == null
|
||||
? false
|
||||
: selection![comics[index]] ?? false;
|
||||
var comic = ComicTile(
|
||||
comic: comics[index],
|
||||
badge: badge,
|
||||
menuOptions: menuBuilder?.call(comics[index]),
|
||||
onTap: onTap != null
|
||||
? () => onTap!(comics[index], heroIDs[index])
|
||||
: null,
|
||||
onLongPressed: onLongPressed != null
|
||||
? () => onLongPressed!(comics[index], heroIDs[index])
|
||||
: null,
|
||||
heroID: heroIDs[index],
|
||||
);
|
||||
if (selection == null) {
|
||||
return comic;
|
||||
}
|
||||
return AnimatedContainer(
|
||||
key: ValueKey(comics[index].id),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.secondaryContainer.toOpacity(0.72)
|
||||
: null,
|
||||
heroID: heroIDs[index],
|
||||
);
|
||||
if (selection == null) {
|
||||
return comic;
|
||||
}
|
||||
return AnimatedContainer(
|
||||
key: ValueKey(comics[index].id),
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.secondaryContainer
|
||||
.toOpacity(0.72)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: comic,
|
||||
);
|
||||
},
|
||||
childCount: comics.length,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
margin: const EdgeInsets.all(4),
|
||||
child: comic,
|
||||
);
|
||||
}, childCount: comics.length),
|
||||
gridDelegate: SliverGridDelegateWithComics(),
|
||||
);
|
||||
}
|
||||
@@ -1159,7 +1158,7 @@ class ComicListState extends State<ComicList> {
|
||||
if (res.data.isEmpty) {
|
||||
setState(() {
|
||||
_data[page] = const [];
|
||||
_maxPage = page;
|
||||
_maxPage ??= page;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
@@ -1282,8 +1281,8 @@ class ComicListState extends State<ComicList> {
|
||||
],
|
||||
);
|
||||
}
|
||||
if (_data[_page] == null) {
|
||||
_loadPage(_page);
|
||||
if (_data[1] == null) {
|
||||
_loadPage(1);
|
||||
return Column(
|
||||
children: [
|
||||
if (widget.errorLeading != null) widget.errorLeading!,
|
||||
@@ -1304,7 +1303,7 @@ class ComicListState extends State<ComicList> {
|
||||
comics: _data.values.expand((element) => element).toList(),
|
||||
menuBuilder: widget.menuBuilder,
|
||||
onLastItemBuild: () {
|
||||
if (_error == null && (_maxPage == null || _page < _maxPage!)) {
|
||||
if (_error == null && (_maxPage == null || _data.length < _maxPage!)) {
|
||||
_loadPage(_data.length + 1);
|
||||
}
|
||||
},
|
||||
@@ -1334,7 +1333,7 @@ class ComicListState extends State<ComicList> {
|
||||
],
|
||||
).paddingHorizontal(16).paddingVertical(8),
|
||||
)
|
||||
else if (_maxPage == null || _page < _maxPage!)
|
||||
else if (_maxPage == null || _data.length < _maxPage!)
|
||||
const SliverListLoadingIndicator(),
|
||||
if (widget.trailingSliver != null) widget.trailingSliver!,
|
||||
],
|
||||
@@ -1627,7 +1626,7 @@ class _SMClipper extends CustomClipper<Rect> {
|
||||
|
||||
class SimpleComicTile extends StatelessWidget {
|
||||
const SimpleComicTile(
|
||||
{super.key, required this.comic, this.onTap, this.withTitle = false});
|
||||
{super.key, required this.comic, this.onTap, this.withTitle = false, this.heroID});
|
||||
|
||||
final Comic comic;
|
||||
|
||||
@@ -1635,6 +1634,8 @@ class SimpleComicTile extends StatelessWidget {
|
||||
|
||||
final bool withTitle;
|
||||
|
||||
final int? heroID;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var image = _findImageProvider(comic);
|
||||
@@ -1660,6 +1661,13 @@ class SimpleComicTile extends StatelessWidget {
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (heroID != null) {
|
||||
child = Hero(
|
||||
tag: "cover$heroID",
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = AnimatedTapRegion(
|
||||
borderRadius: 8,
|
||||
onTap: onTap ??
|
||||
@@ -1668,6 +1676,9 @@ class SimpleComicTile extends StatelessWidget {
|
||||
() => ComicPage(
|
||||
id: comic.id,
|
||||
sourceKey: comic.sourceKey,
|
||||
cover: comic.cover,
|
||||
title: comic.title,
|
||||
heroID: heroID,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -40,7 +42,6 @@ mixin class JsUiApi {
|
||||
var image = message['image'];
|
||||
if (title is! String) return;
|
||||
if (validator != null && validator is! JSInvokable) return;
|
||||
if (image != null && image is! String) return;
|
||||
return _showInputDialog(title, validator, image);
|
||||
case 'showSelectDialog':
|
||||
var title = message['title'];
|
||||
@@ -126,13 +127,25 @@ mixin class JsUiApi {
|
||||
controller?.close();
|
||||
}
|
||||
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, String? image) async {
|
||||
Future<String?> _showInputDialog(String title, JSInvokable? validator, dynamic image) async {
|
||||
String? result;
|
||||
var func = validator == null ? null : JSAutoFreeFunction(validator);
|
||||
String? imageUrl;
|
||||
Uint8List? imageData;
|
||||
if (image != null) {
|
||||
if (image is String) {
|
||||
imageUrl = image;
|
||||
} else if (image is Uint8List) {
|
||||
imageData = image;
|
||||
} else if (image is List<int>) {
|
||||
imageData = Uint8List.fromList(image);
|
||||
}
|
||||
}
|
||||
await showInputDialog(
|
||||
context: App.rootContext,
|
||||
title: title,
|
||||
image: image,
|
||||
image: imageUrl,
|
||||
imageData: imageData,
|
||||
onConfirm: (v) {
|
||||
if (func != null) {
|
||||
var res = func.call([v]);
|
||||
|
||||
@@ -360,6 +360,7 @@ Future<void> showInputDialog({
|
||||
String cancelText = "Cancel",
|
||||
RegExp? inputValidator,
|
||||
String? image,
|
||||
Uint8List? imageData,
|
||||
}) {
|
||||
var controller = TextEditingController(text: initialValue);
|
||||
bool isLoading = false;
|
||||
@@ -379,6 +380,11 @@ Future<void> showInputDialog({
|
||||
height: 108,
|
||||
child: Image.network(image, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
if (image == null && imageData != null)
|
||||
SizedBox(
|
||||
height: 108,
|
||||
child: Image.memory(imageData, fit: BoxFit.none),
|
||||
).paddingBottom(8),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -241,6 +241,10 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
|
||||
late final VerticalDragGestureRecognizer _dragGestureRecognizer;
|
||||
|
||||
bool _isVisible = false;
|
||||
Timer? _hideTimer;
|
||||
static const _hideDuration = Duration(seconds: 2);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -248,7 +252,41 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
_scrollController.addListener(onChanged);
|
||||
Future.microtask(onChanged);
|
||||
_dragGestureRecognizer = VerticalDragGestureRecognizer()
|
||||
..onUpdate = onUpdate;
|
||||
..onUpdate = onUpdate
|
||||
..onStart = (_) {
|
||||
_showScrollbar();
|
||||
}
|
||||
..onEnd = (_) {
|
||||
_scheduleHide();
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
_scrollController.removeListener(onChanged);
|
||||
_dragGestureRecognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showScrollbar() {
|
||||
if (!_isVisible && mounted) {
|
||||
setState(() {
|
||||
_isVisible = true;
|
||||
});
|
||||
}
|
||||
_hideTimer?.cancel();
|
||||
}
|
||||
|
||||
void _scheduleHide() {
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = Timer(_hideDuration, () {
|
||||
if (mounted && _isVisible) {
|
||||
setState(() {
|
||||
_isVisible = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onUpdate(DragUpdateDetails details) {
|
||||
@@ -269,14 +307,24 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
void onChanged() {
|
||||
if (_scrollController.positions.isEmpty) return;
|
||||
var position = _scrollController.position;
|
||||
|
||||
bool hasChanged = false;
|
||||
if (position.minScrollExtent != minExtent ||
|
||||
position.maxScrollExtent != maxExtent ||
|
||||
position.pixels != this.position) {
|
||||
setState(() {
|
||||
minExtent = position.minScrollExtent;
|
||||
maxExtent = position.maxScrollExtent;
|
||||
this.position = position.pixels;
|
||||
});
|
||||
hasChanged = true;
|
||||
minExtent = position.minScrollExtent;
|
||||
maxExtent = position.maxScrollExtent;
|
||||
this.position = position.pixels;
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
_showScrollbar();
|
||||
_scheduleHide();
|
||||
}
|
||||
|
||||
if (hasChanged && mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,29 +348,35 @@ class _AppScrollBarState extends State<AppScrollBar> {
|
||||
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: AnimatedOpacity(
|
||||
opacity: _isVisible ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => _showScrollbar(),
|
||||
onExit: (_) => _scheduleHide(),
|
||||
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),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Icon(Icons.arrow_drop_up, size: 18),
|
||||
Icon(Icons.arrow_drop_down, size: 18),
|
||||
const Spacer(),
|
||||
],
|
||||
).paddingLeft(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,7 @@ export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.5.3";
|
||||
final version = "1.6.0";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
@@ -19,6 +20,7 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
|
||||
super.allowSnapshotting = true,
|
||||
super.barrierDismissible = false,
|
||||
this.enableIOSGesture = true,
|
||||
this.iosFullScreenPopGesture = true,
|
||||
this.preventRebuild = true,
|
||||
}) {
|
||||
assert(opaque);
|
||||
@@ -48,6 +50,9 @@ class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
|
||||
@override
|
||||
final bool enableIOSGesture;
|
||||
|
||||
@override
|
||||
final bool iosFullScreenPopGesture;
|
||||
|
||||
@override
|
||||
final bool preventRebuild;
|
||||
}
|
||||
@@ -74,6 +79,8 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
|
||||
bool get enableIOSGesture;
|
||||
|
||||
bool get iosFullScreenPopGesture;
|
||||
|
||||
bool get preventRebuild;
|
||||
|
||||
Widget? _child;
|
||||
@@ -121,20 +128,22 @@ mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
builder = PredictiveBackPageTransitionsBuilder();
|
||||
} else {
|
||||
builder = SlidePageTransitionBuilder();
|
||||
}
|
||||
}
|
||||
|
||||
return builder.buildTransitions(
|
||||
return builder.buildTransitions(
|
||||
this,
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
enableIOSGesture && App.isIOS
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
onStartPopGesture: () => _startPopGesture(this),
|
||||
child: child)
|
||||
: child);
|
||||
enableIOSGesture && App.isIOS
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
onStartPopGesture: () => _startPopGesture(this),
|
||||
fullScreen: iosFullScreenPopGesture,
|
||||
child: child,
|
||||
)
|
||||
: child);
|
||||
}
|
||||
|
||||
IOSBackGestureController _startPopGesture(PageRoute<T> route) {
|
||||
@@ -206,6 +215,7 @@ class IOSBackGestureDetector extends StatefulWidget {
|
||||
required this.child,
|
||||
required this.gestureWidth,
|
||||
required this.onStartPopGesture,
|
||||
this.fullScreen = false,
|
||||
super.key});
|
||||
|
||||
final double gestureWidth;
|
||||
@@ -216,6 +226,8 @@ class IOSBackGestureDetector extends StatefulWidget {
|
||||
|
||||
final Widget child;
|
||||
|
||||
final bool fullScreen;
|
||||
|
||||
@override
|
||||
State<IOSBackGestureDetector> createState() => _IOSBackGestureDetectorState();
|
||||
}
|
||||
@@ -247,26 +259,40 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
? MediaQuery.of(context).padding.left
|
||||
: MediaQuery.of(context).padding.right;
|
||||
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth);
|
||||
final Widget gestureListener = widget.fullScreen
|
||||
? Positioned.fill(
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
)
|
||||
: Positioned(
|
||||
width: dragAreaWidth,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: Directionality.of(context) == TextDirection.ltr ? 0.0 : null,
|
||||
right: Directionality.of(context) == TextDirection.rtl ? 0.0 : null,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
widget.child,
|
||||
Positioned(
|
||||
width: dragAreaWidth,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
gestureListener,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (widget.enabledCallback()) _recognizer.addPointer(event);
|
||||
if (!widget.enabledCallback()) return;
|
||||
if (widget.fullScreen && _isPointerOverHorizontalScrollable(event)) {
|
||||
return;
|
||||
}
|
||||
_recognizer.addPointer(event);
|
||||
}
|
||||
|
||||
void _handleDragCancel() {
|
||||
@@ -304,6 +330,28 @@ class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
|
||||
bool _isPointerOverHorizontalScrollable(PointerDownEvent event) {
|
||||
final HitTestResult result = HitTestResult();
|
||||
WidgetsBinding.instance.hitTest(result, event.position);
|
||||
for (final entry in result.path) {
|
||||
final target = entry.target;
|
||||
if (target is RenderViewport) {
|
||||
if (_isAxisHorizontal(target.axisDirection)) {
|
||||
return true;
|
||||
}
|
||||
} else if (target is RenderSliver) {
|
||||
if (_isAxisHorizontal(target.constraints.axisDirection)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isAxisHorizontal(AxisDirection direction) {
|
||||
return direction == AxisDirection.left || direction == AxisDirection.right;
|
||||
}
|
||||
}
|
||||
|
||||
class SlidePageTransitionBuilder extends PageTransitionsBuilder {
|
||||
@@ -314,30 +362,31 @@ class SlidePageTransitionBuilder extends PageTransitionsBuilder {
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child) {
|
||||
final Animation<double> primaryAnimation = App.isIOS
|
||||
? animation
|
||||
: CurvedAnimation(parent: animation, curve: Curves.ease);
|
||||
final Animation<double> secondaryCurve = App.isIOS
|
||||
? secondaryAnimation
|
||||
: CurvedAnimation(parent: secondaryAnimation, curve: Curves.ease);
|
||||
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(primaryAnimation),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.ease,
|
||||
)),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: const Offset(-0.4, 0),
|
||||
).animate(CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: Curves.ease,
|
||||
)),
|
||||
child: PhysicalModel(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.zero,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
elevation: 6,
|
||||
child: Material(child: child,),
|
||||
),
|
||||
)
|
||||
begin: Offset.zero,
|
||||
end: const Offset(-0.4, 0),
|
||||
).animate(secondaryCurve),
|
||||
child: PhysicalModel(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.zero,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
elevation: 6,
|
||||
child: Material(child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ class Settings with ChangeNotifier {
|
||||
'readerScrollSpeed': 1.0, // 0.5 - 3.0
|
||||
'localFavoritesFirst': true,
|
||||
'autoCloseFavoritePanel': false,
|
||||
'showChapterComments': true, // show chapter comments in reader
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
@@ -207,7 +208,11 @@ class Settings with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void setEnabledComicSpecificSettings(String comicId, String sourceKey, bool enabled) {
|
||||
void setEnabledComicSpecificSettings(
|
||||
String comicId,
|
||||
String sourceKey,
|
||||
bool enabled,
|
||||
) {
|
||||
setReaderSetting(comicId, sourceKey, "enabled", enabled);
|
||||
}
|
||||
|
||||
@@ -215,7 +220,8 @@ class Settings with ChangeNotifier {
|
||||
if (comicId == null || sourceKey == null) {
|
||||
return false;
|
||||
}
|
||||
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] == true;
|
||||
return _data['comicSpecificSettings']["$comicId@$sourceKey"]?["enabled"] ==
|
||||
true;
|
||||
}
|
||||
|
||||
dynamic getReaderSetting(String comicId, String sourceKey, String key) {
|
||||
|
||||
@@ -61,8 +61,10 @@ class ComicSourceManager with ChangeNotifier, Init {
|
||||
await for (var entity in Directory(path).list()) {
|
||||
if (entity is File && entity.path.endsWith(".js")) {
|
||||
try {
|
||||
var source = await ComicSourceParser()
|
||||
.parse(await entity.readAsString(), entity.absolute.path);
|
||||
var source = await ComicSourceParser().parse(
|
||||
await entity.readAsString(),
|
||||
entity.absolute.path,
|
||||
);
|
||||
_sources.add(source);
|
||||
} catch (e, s) {
|
||||
Log.error("ComicSource", "$e\n$s");
|
||||
@@ -154,7 +156,7 @@ class ComicSource {
|
||||
final GetImageLoadingConfigFunc? getImageLoadingConfig;
|
||||
|
||||
final Map<String, dynamic> Function(String imageKey)?
|
||||
getThumbnailLoadingConfig;
|
||||
getThumbnailLoadingConfig;
|
||||
|
||||
var data = <String, dynamic>{};
|
||||
|
||||
@@ -170,6 +172,10 @@ class ComicSource {
|
||||
|
||||
final SendCommentFunc? sendCommentFunc;
|
||||
|
||||
final ChapterCommentsLoader? chapterCommentsLoader;
|
||||
|
||||
final SendChapterCommentFunc? sendChapterCommentFunc;
|
||||
|
||||
final RegExp? idMatcher;
|
||||
|
||||
final LikeOrUnlikeComicFunc? likeOrUnlikeComic;
|
||||
@@ -256,6 +262,8 @@ class ComicSource {
|
||||
this.version,
|
||||
this.commentsLoader,
|
||||
this.sendCommentFunc,
|
||||
this.chapterCommentsLoader,
|
||||
this.sendChapterCommentFunc,
|
||||
this.likeOrUnlikeComic,
|
||||
this.voteCommentFunc,
|
||||
this.likeCommentFunc,
|
||||
@@ -367,11 +375,19 @@ enum ExplorePageType {
|
||||
override,
|
||||
}
|
||||
|
||||
typedef SearchFunction = Future<Res<List<Comic>>> Function(
|
||||
String keyword, int page, List<String> searchOption);
|
||||
typedef SearchFunction =
|
||||
Future<Res<List<Comic>>> Function(
|
||||
String keyword,
|
||||
int page,
|
||||
List<String> searchOption,
|
||||
);
|
||||
|
||||
typedef SearchNextFunction = Future<Res<List<Comic>>> Function(
|
||||
String keyword, String? next, List<String> searchOption);
|
||||
typedef SearchNextFunction =
|
||||
Future<Res<List<Comic>>> Function(
|
||||
String keyword,
|
||||
String? next,
|
||||
List<String> searchOption,
|
||||
);
|
||||
|
||||
class SearchPageData {
|
||||
/// If this is not null, the default value of search options will be first element.
|
||||
@@ -398,11 +414,19 @@ class SearchOptions {
|
||||
String get defaultValue => defaultVal ?? options.keys.firstOrNull ?? "";
|
||||
}
|
||||
|
||||
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
|
||||
String category, String? param, List<String> options, int page);
|
||||
typedef CategoryComicsLoader =
|
||||
Future<Res<List<Comic>>> Function(
|
||||
String category,
|
||||
String? param,
|
||||
List<String> options,
|
||||
int page,
|
||||
);
|
||||
|
||||
typedef CategoryOptionsLoader = Future<Res<List<CategoryComicsOptions>>> Function(
|
||||
String category, String? param);
|
||||
typedef CategoryOptionsLoader =
|
||||
Future<Res<List<CategoryComicsOptions>>> Function(
|
||||
String category,
|
||||
String? param,
|
||||
);
|
||||
|
||||
class CategoryComicsData {
|
||||
/// options
|
||||
@@ -419,7 +443,12 @@ class CategoryComicsData {
|
||||
|
||||
final RankingData? rankingData;
|
||||
|
||||
const CategoryComicsData({this.options, this.optionsLoader, required this.load, this.rankingData});
|
||||
const CategoryComicsData({
|
||||
this.options,
|
||||
this.optionsLoader,
|
||||
required this.load,
|
||||
this.rankingData,
|
||||
});
|
||||
}
|
||||
|
||||
class RankingData {
|
||||
@@ -428,7 +457,7 @@ class RankingData {
|
||||
final Future<Res<List<Comic>>> Function(String option, int page)? load;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(String option, String? next)?
|
||||
loadWithNext;
|
||||
loadWithNext;
|
||||
|
||||
const RankingData(this.options, this.load, this.loadWithNext);
|
||||
}
|
||||
@@ -447,7 +476,12 @@ class CategoryComicsOptions {
|
||||
|
||||
final List<String>? showWhen;
|
||||
|
||||
const CategoryComicsOptions(this.label, this.options, this.notShowWhen, this.showWhen);
|
||||
const CategoryComicsOptions(
|
||||
this.label,
|
||||
this.options,
|
||||
this.notShowWhen,
|
||||
this.showWhen,
|
||||
);
|
||||
}
|
||||
|
||||
class LinkHandler {
|
||||
|
||||
@@ -542,6 +542,7 @@ class PageJumpTarget {
|
||||
sourceKey: sourceKey,
|
||||
options: List.from(attributes?["options"] ?? []),
|
||||
),
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
} else if (page == "category") {
|
||||
var key = ComicSource.find(sourceKey)!.categoryData!.key;
|
||||
|
||||
@@ -151,6 +151,8 @@ class ComicSourceParser {
|
||||
version ?? "1.0.0",
|
||||
_parseCommentsLoader(),
|
||||
_parseSendCommentFunc(),
|
||||
_parseChapterCommentsLoader(),
|
||||
_parseSendChapterCommentFunc(),
|
||||
_parseLikeFunc(),
|
||||
_parseVoteCommentFunc(),
|
||||
_parseLikeCommentFunc(),
|
||||
@@ -560,12 +562,16 @@ class ComicSourceParser {
|
||||
res = await res;
|
||||
}
|
||||
if (res is! List) {
|
||||
return Res.error("Invalid data:\nExpected: List\nGot: ${res.runtimeType}");
|
||||
return Res.error(
|
||||
"Invalid data:\nExpected: List\nGot: ${res.runtimeType}",
|
||||
);
|
||||
}
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in res) {
|
||||
if (element is! Map) {
|
||||
return Res.error("Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}");
|
||||
return Res.error(
|
||||
"Invalid option data:\nExpected: Map\nGot: ${element.runtimeType}",
|
||||
);
|
||||
}
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"] ?? []) {
|
||||
@@ -582,13 +588,14 @@ class ComicSourceParser {
|
||||
element["label"] ?? "",
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"]),
|
||||
element["showWhen"] == null
|
||||
? null
|
||||
: List.from(element["showWhen"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Res(options);
|
||||
}
|
||||
catch(e) {
|
||||
} catch (e) {
|
||||
Log.error("Data Analysis", "Failed to load category options.\n$e");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
@@ -1005,6 +1012,54 @@ class ComicSourceParser {
|
||||
};
|
||||
}
|
||||
|
||||
ChapterCommentsLoader? _parseChapterCommentsLoader() {
|
||||
if (!_checkExists("comic.loadChapterComments")) return null;
|
||||
return (comicId, epId, page, replyTo) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.loadChapterComments(
|
||||
${jsonEncode(comicId)}, ${jsonEncode(epId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return Res(
|
||||
(res["comments"] as List).map((e) => Comment.fromJson(e)).toList(),
|
||||
subData: res["maxPage"],
|
||||
);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
SendChapterCommentFunc? _parseSendChapterCommentFunc() {
|
||||
if (!_checkExists("comic.sendChapterComment")) return null;
|
||||
return (comicId, epId, content, replyTo) async {
|
||||
Future<Res<bool>> func() async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.sendChapterComment(
|
||||
${jsonEncode(comicId)}, ${jsonEncode(epId)}, ${jsonEncode(content)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
var res = await func();
|
||||
if (res.error && res.errorMessage!.contains("Login expired")) {
|
||||
var reLoginRes = await ComicSource.find(_key!)!.reLogin();
|
||||
if (!reLoginRes) {
|
||||
return const Res.error("Login expired and re-login failed");
|
||||
} else {
|
||||
return func();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc() {
|
||||
if (!_checkExists("comic.onImageLoad")) {
|
||||
return null;
|
||||
|
||||
@@ -4,50 +4,90 @@ part of 'comic_source.dart';
|
||||
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 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 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 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 ChapterCommentsLoader =
|
||||
Future<Res<List<Comment>>> Function(
|
||||
String comicId,
|
||||
String epId,
|
||||
int page,
|
||||
String? replyTo,
|
||||
);
|
||||
|
||||
typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
|
||||
String imageKey, String comicId, String epId)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
typedef SendCommentFunc =
|
||||
Future<Res<bool>> Function(
|
||||
String id,
|
||||
String? subId,
|
||||
String content,
|
||||
String? replyTo,
|
||||
);
|
||||
|
||||
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(
|
||||
String comicId, String? next);
|
||||
typedef SendChapterCommentFunc =
|
||||
Future<Res<bool>> Function(
|
||||
String comicId,
|
||||
String epId,
|
||||
String content,
|
||||
String? replyTo,
|
||||
);
|
||||
|
||||
typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
|
||||
String comicId, bool isLiking);
|
||||
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);
|
||||
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 VoteCommentFunc =
|
||||
Future<Res<int?>> Function(
|
||||
String comicId,
|
||||
String? subId,
|
||||
String commentId,
|
||||
bool isUp,
|
||||
bool isCancel,
|
||||
);
|
||||
|
||||
typedef HandleClickTagEvent = PageJumpTarget? Function(
|
||||
String namespace, String tag);
|
||||
typedef HandleClickTagEvent =
|
||||
PageJumpTarget? Function(String namespace, String tag);
|
||||
|
||||
/// Handle tag suggestion selection event. Should return the text to insert
|
||||
/// into the search field.
|
||||
typedef TagSuggestionSelectFunc = String Function(
|
||||
String namespace, String tag);
|
||||
typedef TagSuggestionSelectFunc = 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);
|
||||
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
|
||||
|
||||
@@ -14,14 +14,20 @@ extension Navigation on BuildContext {
|
||||
return Navigator.of(this).canPop();
|
||||
}
|
||||
|
||||
Future<T?> to<T>(Widget Function() builder) {
|
||||
return Navigator.of(this)
|
||||
.push<T>(AppPageRoute(builder: (context) => builder()));
|
||||
Future<T?> to<T>(Widget Function() builder,
|
||||
{bool enableIOSGesture = true, bool iosFullScreenGesture = true}) {
|
||||
return Navigator.of(this).push<T>(AppPageRoute(
|
||||
builder: (context) => builder(),
|
||||
enableIOSGesture: enableIOSGesture,
|
||||
iosFullScreenPopGesture: iosFullScreenGesture));
|
||||
}
|
||||
|
||||
Future<void> toReplacement<T>(Widget Function() builder) {
|
||||
return Navigator.of(this)
|
||||
.pushReplacement(AppPageRoute(builder: (context) => builder()));
|
||||
Future<void> toReplacement<T>(Widget Function() builder,
|
||||
{bool enableIOSGesture = true, bool iosFullScreenGesture = true}) {
|
||||
return Navigator.of(this).pushReplacement(AppPageRoute(
|
||||
builder: (context) => builder(),
|
||||
enableIOSGesture: enableIOSGesture,
|
||||
iosFullScreenPopGesture: iosFullScreenGesture));
|
||||
}
|
||||
|
||||
double get width => MediaQuery.of(this).size.width;
|
||||
|
||||
@@ -441,7 +441,7 @@ class ImageFavoriteManager with ChangeNotifier {
|
||||
for (var comic in comics) {
|
||||
count += comic.images.length;
|
||||
for (var tag in comic.tags) {
|
||||
String finalTag = tag;
|
||||
String finalTag = tag.split(":").last;
|
||||
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,8 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
author: subtitle,
|
||||
tags: tags,
|
||||
),
|
||||
enableIOSGesture: false,
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ abstract class ImageDownloader {
|
||||
responseType: ResponseType.stream,
|
||||
));
|
||||
|
||||
var req = await dio.request<ResponseBody>(configs['url'] ?? url,
|
||||
String requestUrl = configs['url'] ?? url;
|
||||
if (requestUrl.startsWith('//')) {
|
||||
requestUrl = 'https:$requestUrl';
|
||||
}
|
||||
var req = await dio.request<ResponseBody>(requestUrl,
|
||||
data: configs['data']);
|
||||
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
||||
int? expectedBytes = req.data!.contentLength;
|
||||
@@ -181,7 +185,15 @@ abstract class ImageDownloader {
|
||||
}
|
||||
|
||||
if (configs['onResponse'] is JSInvokable) {
|
||||
buffer = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
|
||||
dynamic result = (configs['onResponse'] as JSInvokable)([Uint8List.fromList(buffer)]);
|
||||
if (result is Future) {
|
||||
result = await result;
|
||||
}
|
||||
if (result is List<int>) {
|
||||
buffer = result;
|
||||
} else {
|
||||
throw "Error: Invalid onResponse result.";
|
||||
}
|
||||
(configs['onResponse'] as JSInvokable).free();
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ class _SliverSearchResultState extends State<_SliverSearchResult>
|
||||
text: widget.keyword,
|
||||
sourceKey: widget.source.key,
|
||||
),
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
|
||||
@@ -116,6 +116,8 @@ abstract mixin class _ComicPageActions {
|
||||
author: comic.findAuthor() ?? '',
|
||||
tags: comic.plainTags,
|
||||
),
|
||||
enableIOSGesture: false,
|
||||
iosFullScreenGesture: false,
|
||||
)
|
||||
.then((_) {
|
||||
onReadEnd();
|
||||
|
||||
@@ -236,7 +236,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
author: localComic.subTitle ?? '',
|
||||
tags: localComic.tags,
|
||||
);
|
||||
});
|
||||
}, enableIOSGesture: false, iosFullScreenGesture: false);
|
||||
App.mainNavigatorKey!.currentContext!.pop();
|
||||
});
|
||||
isFirst = false;
|
||||
|
||||
@@ -197,11 +197,12 @@ class _NetworkSectionState extends State<_NetworkSection> {
|
||||
if (res.subData is List) {
|
||||
final list = List<String>.from(res.subData);
|
||||
if (list.isNotEmpty) {
|
||||
addedFolders = {list.first};
|
||||
addedFolders = list.toSet();
|
||||
localIsFavorite = true;
|
||||
} else {
|
||||
addedFolders.clear();
|
||||
localIsFavorite = false;
|
||||
}
|
||||
localIsFavorite = addedFolders.isNotEmpty;
|
||||
} else {
|
||||
addedFolders.clear();
|
||||
localIsFavorite = false;
|
||||
@@ -352,62 +353,6 @@ class _NetworkSectionState extends State<_NetworkSection> {
|
||||
}
|
||||
|
||||
Widget _buildMultiFolder() {
|
||||
if (localIsFavorite == true &&
|
||||
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text("Network Favorites".tl),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text("Added".tl, style: ts.s12),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: _HoverButton(
|
||||
isFavorite: true,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
var res = await widget
|
||||
.comicSource
|
||||
.favoriteData!
|
||||
.addOrDelFavorite!(widget.cid, '', false, null);
|
||||
if (res.success) {
|
||||
// Invalidate network cache so subsequent loads see latest
|
||||
NetworkCacheManager().clear();
|
||||
setState(() {
|
||||
localIsFavorite = false;
|
||||
});
|
||||
widget.onFavorite(false);
|
||||
App.rootContext.showMessage(message: "Removed".tl);
|
||||
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
|
||||
context.pop();
|
||||
}
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -425,8 +370,10 @@ class _NetworkSectionState extends State<_NetworkSection> {
|
||||
var name = entry.value;
|
||||
var id = entry.key;
|
||||
var isAdded = addedFolders.contains(id);
|
||||
var hasSelection = addedFolders.isNotEmpty;
|
||||
var enabled = !hasSelection || isAdded;
|
||||
// When `singleFolderForSingleComic` is `false`, all add and remove buttons are clickable.
|
||||
// When `singleFolderForSingleComic` is `true`, the remove button is always clickable,
|
||||
// while the add button is only clickable if the comic has not been added to any list.
|
||||
var enabled = !(widget.comicSource.favoriteData!.singleFolderForSingleComic && addedFolders.isNotEmpty && !isAdded);
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
@@ -469,11 +416,9 @@ class _NetworkSectionState extends State<_NetworkSection> {
|
||||
NetworkCacheManager().clear();
|
||||
setState(() {
|
||||
if (isAdded) {
|
||||
addedFolders.clear();
|
||||
addedFolders.remove(id);
|
||||
} else {
|
||||
addedFolders
|
||||
..clear()
|
||||
..add(id);
|
||||
addedFolders.add(id);
|
||||
}
|
||||
// sync local flag for single-folder-per-comic logic and parent
|
||||
localIsFavorite = addedFolders.isNotEmpty;
|
||||
|
||||
@@ -200,6 +200,7 @@ class _BodyState extends State<_Body> {
|
||||
await ComicSourceManager().reload();
|
||||
setState(() {});
|
||||
}),
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1245,6 +1246,15 @@ class _LoginPageState extends State<_LoginPage> {
|
||||
if (widget.config.checkLoginStatus != null &&
|
||||
widget.config.checkLoginStatus!(url, title)) {
|
||||
var cookies = (await c.getCookies(url)) ?? [];
|
||||
var localStorageItems = await c.webStorage.localStorage.getItems();
|
||||
var mappedLocalStorage = <String, dynamic>{};
|
||||
for (var item in localStorageItems) {
|
||||
if (item.key != null) {
|
||||
mappedLocalStorage[item.key!] = item.value;
|
||||
}
|
||||
}
|
||||
widget.source.data['_localStorage'] = mappedLocalStorage;
|
||||
await widget.source.saveData();
|
||||
SingleInstanceCookieJar.instance?.saveFromResponse(
|
||||
Uri.parse(url),
|
||||
cookies,
|
||||
@@ -1306,6 +1316,20 @@ class _LoginPageState extends State<_LoginPage> {
|
||||
Uri.parse(url),
|
||||
cookies,
|
||||
);
|
||||
var localStorageJson = await webview.evaluateJavascript(
|
||||
"JSON.stringify(window.localStorage);",
|
||||
);
|
||||
var localStorage = <String, dynamic>{};
|
||||
try {
|
||||
var decoded = jsonDecode(localStorageJson ?? '');
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
localStorage = decoded;
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error("ComicSourcePage", "Failed to parse localStorage JSON\n$e");
|
||||
}
|
||||
widget.source.data['_localStorage'] = localStorage;
|
||||
await widget.source.saveData();
|
||||
success = true;
|
||||
widget.config.onLoginWithWebviewSuccess?.call();
|
||||
webview.close();
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
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/foundation/res.dart';
|
||||
|
||||
@@ -30,6 +30,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
late List<String> added = [];
|
||||
|
||||
String keyword = "";
|
||||
bool searchHasUpper = false;
|
||||
|
||||
bool searchMode = false;
|
||||
|
||||
@@ -43,6 +44,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
|
||||
bool isLoading = false;
|
||||
|
||||
late String readFilterSelect;
|
||||
|
||||
var searchResults = <FavoriteItem>[];
|
||||
|
||||
void updateSearchResult() {
|
||||
@@ -104,27 +107,40 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
List<FavoriteItem> filterComics(List<FavoriteItem> curComics) {
|
||||
return curComics.where((comic) {
|
||||
var history =
|
||||
HistoryManager().find(comic.id, ComicType(comic.sourceKey.hashCode));
|
||||
if (readFilterSelect == "UnCompleted") {
|
||||
return history == null || history.page != history.maxPage;
|
||||
} else if (readFilterSelect == "Completed") {
|
||||
return history != null && history.page == history.maxPage;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
bool matchKeyword(String keyword, FavoriteItem comic) {
|
||||
var list = keyword.split(" ");
|
||||
for (var k in list) {
|
||||
if (k.isEmpty) continue;
|
||||
if (comic.title.contains(k)) {
|
||||
if (checkKeyWordMatch(k, comic.title, false)) {
|
||||
continue;
|
||||
} else if (comic.subtitle != null && comic.subtitle!.contains(k)) {
|
||||
} else if (comic.subtitle != null && checkKeyWordMatch(k, comic.subtitle!, false)) {
|
||||
continue;
|
||||
} else if (comic.tags.any((tag) {
|
||||
if (tag == k) {
|
||||
if (checkKeyWordMatch(k, tag, true)) {
|
||||
return true;
|
||||
} else if (tag.contains(':') && tag.split(':')[1] == k) {
|
||||
} else if (tag.contains(':') && checkKeyWordMatch(k, tag.split(':')[1], true)) {
|
||||
return true;
|
||||
} else if (App.locale.languageCode != 'en' &&
|
||||
tag.translateTagsToCN == k) {
|
||||
checkKeyWordMatch(k, tag.translateTagsToCN, true)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})) {
|
||||
continue;
|
||||
} else if (comic.author == k) {
|
||||
} else if (checkKeyWordMatch(k, comic.author, true)) {
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
@@ -132,6 +148,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool checkKeyWordMatch(String keyword, String compare, bool needEqual) {
|
||||
String temp = compare;
|
||||
// 没有大写的话, 就转成小写比较, 避免搜索需要注意大小写
|
||||
if (!searchHasUpper) {
|
||||
temp = temp.toLowerCase();
|
||||
}
|
||||
if (needEqual) {
|
||||
return keyword == temp;
|
||||
}
|
||||
return temp.contains(keyword);
|
||||
}
|
||||
// Convert keyword to traditional Chinese to match comics
|
||||
bool matchKeywordT(String keyword, FavoriteItem comic) {
|
||||
if (!OpenCC.hasChineseSimplified(keyword)) {
|
||||
@@ -149,9 +176,10 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
keyword = OpenCC.traditionalToSimplified(keyword);
|
||||
return matchKeyword(keyword, comic);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
readFilterSelect = appdata.implicitData["local_favorites_read_filter"] ??
|
||||
readFilterList[0];
|
||||
favPage = context.findAncestorStateOfType<_FavoritesPageState>()!;
|
||||
if (!isAllFolder) {
|
||||
var (a, b) = LocalFavoritesManager().findLinked(widget.folder);
|
||||
@@ -320,6 +348,31 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Filter".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.sort_rounded),
|
||||
color: readFilterSelect != readFilterList[0]
|
||||
? context.colorScheme.primaryContainer
|
||||
: null,
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _LocalFavoritesFilterDialog(
|
||||
initReadFilterSelect: readFilterSelect,
|
||||
updateConfig: (readFilter) {
|
||||
setState(() {
|
||||
readFilterSelect = readFilter;
|
||||
});
|
||||
updateComics();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Search".tl,
|
||||
child: IconButton(
|
||||
@@ -454,15 +507,15 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
actions: [
|
||||
MenuButton(entries: [
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.drive_file_move,
|
||||
text: "Move to folder".tl,
|
||||
onClick: () => favoriteOption('move')),
|
||||
MenuEntry(
|
||||
icon: Icons.drive_file_move,
|
||||
text: "Move to folder".tl,
|
||||
onClick: () => favoriteOption('move')),
|
||||
if (!isAllFolder)
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
text: "Copy to folder".tl,
|
||||
onClick: () => favoriteOption('add')),
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
text: "Copy to folder".tl,
|
||||
onClick: () => favoriteOption('add')),
|
||||
MenuEntry(
|
||||
icon: Icons.select_all,
|
||||
text: "Select All".tl,
|
||||
@@ -519,9 +572,27 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
onClick: () {
|
||||
final c = selectedComics.keys.first as FavoriteItem;
|
||||
App.rootContext.to(() => ReaderWithLoading(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
));
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
),
|
||||
enableIOSGesture: false,
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (selectedComics.length == 1)
|
||||
MenuEntry(
|
||||
icon: Icons.arrow_forward_ios,
|
||||
text: "Jump to Detail".tl,
|
||||
onClick: () {
|
||||
final c = selectedComics.keys.first as FavoriteItem;
|
||||
App.mainNavigatorKey?.currentContext?.to(() => ComicPage(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
),
|
||||
enableIOSGesture: false,
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
]),
|
||||
@@ -553,6 +624,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
),
|
||||
onChanged: (v) {
|
||||
keyword = v;
|
||||
searchHasUpper = keyword.contains(RegExp(r'[A-Z]'));
|
||||
updateSearchResult();
|
||||
},
|
||||
).paddingBottom(8).paddingRight(8),
|
||||
@@ -568,7 +640,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
)
|
||||
else
|
||||
SliverGridComics(
|
||||
comics: searchMode ? searchResults : comics,
|
||||
comics: searchMode ? searchResults : filterComics(comics),
|
||||
selections: selectedComics,
|
||||
menuBuilder: (c) {
|
||||
return [
|
||||
@@ -622,12 +694,14 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
),
|
||||
enableIOSGesture: false,
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
},
|
||||
onTap: (c) {
|
||||
onTap: (c, heroID) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as FavoriteItem)) {
|
||||
@@ -639,18 +713,24 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
|
||||
lastSelectedIndex = comics.indexOf(c);
|
||||
});
|
||||
} else if (appdata.settings["onClickFavorite"] == "viewDetail") {
|
||||
App.mainNavigatorKey?.currentContext
|
||||
?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey));
|
||||
} else {
|
||||
App.mainNavigatorKey?.currentContext?.to(
|
||||
() => ReaderWithLoading(
|
||||
() => ComicPage(
|
||||
id: c.id,
|
||||
sourceKey: c.sourceKey,
|
||||
cover: c.cover,
|
||||
title: c.title,
|
||||
heroID: heroID,
|
||||
),
|
||||
enableIOSGesture: false,
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
} else {
|
||||
App.mainNavigatorKey?.currentContext?.to(
|
||||
() => ReaderWithLoading(id: c.id, sourceKey: c.sourceKey),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPressed: (c) {
|
||||
onLongPressed: (c, heroID) {
|
||||
setState(() {
|
||||
if (!multiSelectMode) {
|
||||
multiSelectMode = true;
|
||||
@@ -1075,3 +1155,78 @@ class _SelectUpdatePageNumState extends State<_SelectUpdatePageNum> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalFavoritesFilterDialog extends StatefulWidget {
|
||||
const _LocalFavoritesFilterDialog({
|
||||
required this.initReadFilterSelect,
|
||||
required this.updateConfig,
|
||||
});
|
||||
|
||||
final String initReadFilterSelect;
|
||||
final Function updateConfig;
|
||||
|
||||
@override
|
||||
State<_LocalFavoritesFilterDialog> createState() =>
|
||||
_LocalFavoritesFilterDialogState();
|
||||
}
|
||||
|
||||
const readFilterList = ['All', 'UnCompleted', 'Completed'];
|
||||
|
||||
class _LocalFavoritesFilterDialogState
|
||||
extends State<_LocalFavoritesFilterDialog> {
|
||||
List<String> optionTypes = ['Filter'];
|
||||
late var readFilter = widget.initReadFilterSelect;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget tabBar = Material(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AppTabBar(
|
||||
key: PageStorageKey(optionTypes),
|
||||
tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(),
|
||||
),
|
||||
).paddingTop(context.padding.top);
|
||||
return ContentDialog(
|
||||
content: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
tabBar,
|
||||
TabViewBody(children: [
|
||||
Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Filter reading status".tl),
|
||||
trailing: Select(
|
||||
current: readFilter.tl,
|
||||
values: readFilterList.map((e) => e.tl).toList(),
|
||||
minWidth: 64,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
readFilter = readFilterList[index];
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
appdata.implicitData["local_favorites_read_filter"] = readFilter;
|
||||
appdata.writeImplicitData();
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
widget.updateConfig(readFilter);
|
||||
}
|
||||
},
|
||||
child: Text("Confirm".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +299,7 @@ class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
|
||||
);
|
||||
}
|
||||
updateFollowUpdatesUI();
|
||||
appdata.saveData();
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -211,7 +211,7 @@ class _HistoryPageState extends State<HistoryPage> {
|
||||
selections: selectedComics,
|
||||
onLongPressed: null,
|
||||
onTap: multiSelectMode
|
||||
? (c) {
|
||||
? (c, heroID) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as History)) {
|
||||
selectedComics.remove(c);
|
||||
|
||||
@@ -302,13 +302,18 @@ class _HistoryState extends State<_History> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: history.length,
|
||||
itemBuilder: (context, index) {
|
||||
final heroID = history[index].id.hashCode;
|
||||
return SimpleComicTile(
|
||||
comic: history[index],
|
||||
heroID: heroID,
|
||||
onTap: () {
|
||||
context.to(
|
||||
() => ComicPage(
|
||||
id: history[index].id,
|
||||
sourceKey: history[index].type.sourceKey,
|
||||
cover: history[index].cover,
|
||||
title: history[index].title,
|
||||
heroID: heroID,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -386,7 +391,9 @@ class _LocalState extends State<_Local> {
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -405,9 +412,22 @@ class _LocalState extends State<_Local> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: local.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SimpleComicTile(comic: local[index])
|
||||
.paddingHorizontal(8)
|
||||
.paddingVertical(2);
|
||||
final heroID = local[index].id.hashCode;
|
||||
return SimpleComicTile(
|
||||
comic: local[index],
|
||||
heroID: heroID,
|
||||
onTap: () {
|
||||
context.to(
|
||||
() => ComicPage(
|
||||
id: local[index].id,
|
||||
sourceKey: local[index].sourceKey,
|
||||
cover: local[index].cover,
|
||||
title: local[index].title,
|
||||
heroID: heroID,
|
||||
),
|
||||
);
|
||||
},
|
||||
).paddingHorizontal(8).paddingVertical(2);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
@@ -874,7 +894,10 @@ class _ImageFavoritesState extends State<ImageFavorites> {
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
context.to(() => const ImageFavoritesPage());
|
||||
context.to(
|
||||
() => const ImageFavoritesPage(),
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -993,7 +1016,10 @@ class _ImageFavoritesState extends State<ImageFavorites> {
|
||||
maxCount: maxCount,
|
||||
enableTranslation: displayType != 2,
|
||||
onTap: (text) {
|
||||
context.to(() => ImageFavoritesPage(initialKeyword: text));
|
||||
context.to(
|
||||
() => ImageFavoritesPage(initialKeyword: text),
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
@@ -37,6 +37,8 @@ class _ImageFavoritesItemState extends State<_ImageFavoritesItem> {
|
||||
initialEp: ep,
|
||||
initialPage: page,
|
||||
),
|
||||
enableIOSGesture: false,
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,8 @@ class _ImageFavoritesPhotoViewState extends State<ImageFavoritesPhotoView> {
|
||||
initialEp: ep,
|
||||
initialPage: page,
|
||||
),
|
||||
enableIOSGesture: false,
|
||||
iosFullScreenGesture: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -285,13 +285,13 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||
SliverGridComics(
|
||||
comics: comics,
|
||||
selections: selectedComics,
|
||||
onLongPressed: (c) {
|
||||
onLongPressed: (c, heroID) {
|
||||
setState(() {
|
||||
multiSelectMode = true;
|
||||
selectedComics[c as LocalComic] = true;
|
||||
});
|
||||
},
|
||||
onTap: (c) {
|
||||
onTap: (c, heroID) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
if (selectedComics.containsKey(c as LocalComic)) {
|
||||
|
||||
573
lib/pages/reader/chapter_comments.dart
Normal file
573
lib/pages/reader/chapter_comments.dart
Normal file
@@ -0,0 +1,573 @@
|
||||
part of 'reader.dart';
|
||||
|
||||
class ChapterCommentsPage extends StatefulWidget {
|
||||
const ChapterCommentsPage({
|
||||
super.key,
|
||||
required this.comicId,
|
||||
required this.epId,
|
||||
required this.source,
|
||||
required this.comicTitle,
|
||||
required this.chapterTitle,
|
||||
this.replyComment,
|
||||
});
|
||||
|
||||
final String comicId;
|
||||
final String epId;
|
||||
final ComicSource source;
|
||||
final String comicTitle;
|
||||
final String chapterTitle;
|
||||
final Comment? replyComment;
|
||||
|
||||
@override
|
||||
State<ChapterCommentsPage> createState() => _ChapterCommentsPageState();
|
||||
}
|
||||
|
||||
class _ChapterCommentsPageState extends State<ChapterCommentsPage> {
|
||||
bool _loading = true;
|
||||
List<Comment>? _comments;
|
||||
String? _error;
|
||||
int _page = 1;
|
||||
int? maxPage;
|
||||
var controller = TextEditingController();
|
||||
bool sending = false;
|
||||
|
||||
void firstLoad() async {
|
||||
var res = await widget.source.chapterCommentsLoader!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
1,
|
||||
widget.replyComment?.id,
|
||||
);
|
||||
if (res.error) {
|
||||
setState(() {
|
||||
_error = res.errorMessage;
|
||||
_loading = false;
|
||||
});
|
||||
} else if (mounted) {
|
||||
setState(() {
|
||||
_comments = res.data;
|
||||
_loading = false;
|
||||
maxPage = res.subData;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void loadMore() async {
|
||||
var res = await widget.source.chapterCommentsLoader!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
_page + 1,
|
||||
widget.replyComment?.id,
|
||||
);
|
||||
if (res.error) {
|
||||
context.showMessage(message: res.errorMessage ?? "Unknown Error");
|
||||
} else {
|
||||
setState(() {
|
||||
_comments!.addAll(res.data);
|
||||
_page++;
|
||||
if (maxPage == null && res.data.isEmpty) {
|
||||
maxPage = _page;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: Appbar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text("Chapter Comments".tl, style: ts.s18),
|
||||
Text(widget.chapterTitle, style: ts.s12),
|
||||
],
|
||||
),
|
||||
style: AppbarStyle.shadow,
|
||||
),
|
||||
body: buildBody(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBody(BuildContext context) {
|
||||
if (_loading) {
|
||||
firstLoad();
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (_error != null) {
|
||||
return NetworkError(
|
||||
message: _error!,
|
||||
retry: () {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
});
|
||||
},
|
||||
withAppbar: false,
|
||||
);
|
||||
} else {
|
||||
var showAvatar = _comments!.any((e) {
|
||||
return e.avatar != null;
|
||||
});
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SmoothScrollProvider(
|
||||
builder: (context, controller, physics) {
|
||||
return ListView.builder(
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
primary: false,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _comments!.length + 2,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == 0) {
|
||||
if (widget.replyComment != null) {
|
||||
return Column(
|
||||
children: [
|
||||
_ChapterCommentTile(
|
||||
comment: widget.replyComment!,
|
||||
source: widget.source,
|
||||
comicId: widget.comicId,
|
||||
epId: widget.epId,
|
||||
showAvatar: showAvatar,
|
||||
showActions: false,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text("Replies".tl, style: ts.s18),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
index--;
|
||||
|
||||
if (index == _comments!.length) {
|
||||
if (_page < (maxPage ?? _page + 1)) {
|
||||
loadMore();
|
||||
return const ListLoadingIndicator();
|
||||
} else {
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
||||
return _ChapterCommentTile(
|
||||
comment: _comments![index],
|
||||
source: widget.source,
|
||||
comicId: widget.comicId,
|
||||
epId: widget.epId,
|
||||
showAvatar: showAvatar,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
buildBottom(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildBottom(BuildContext context) {
|
||||
if (widget.source.sendChapterCommentFunc == null) {
|
||||
return const SizedBox(height: 0);
|
||||
}
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
hintText: "Comment".tl,
|
||||
),
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
),
|
||||
),
|
||||
if (sending)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
if (controller.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
sending = true;
|
||||
});
|
||||
var b = await widget.source.sendChapterCommentFunc!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
controller.text,
|
||||
widget.replyComment?.id,
|
||||
);
|
||||
if (!b.error) {
|
||||
controller.text = "";
|
||||
setState(() {
|
||||
sending = false;
|
||||
_loading = true;
|
||||
_comments?.clear();
|
||||
_page = 1;
|
||||
maxPage = null;
|
||||
});
|
||||
} else {
|
||||
context.showMessage(message: b.errorMessage ?? "Error");
|
||||
setState(() {
|
||||
sending = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.send,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingLeft(16).paddingRight(4),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChapterCommentTile extends StatefulWidget {
|
||||
const _ChapterCommentTile({
|
||||
required this.comment,
|
||||
required this.source,
|
||||
required this.comicId,
|
||||
required this.epId,
|
||||
required this.showAvatar,
|
||||
this.showActions = true,
|
||||
});
|
||||
|
||||
final Comment comment;
|
||||
final ComicSource source;
|
||||
final String comicId;
|
||||
final String epId;
|
||||
final bool showAvatar;
|
||||
final bool showActions;
|
||||
|
||||
@override
|
||||
State<_ChapterCommentTile> createState() => _ChapterCommentTileState();
|
||||
}
|
||||
|
||||
class _ChapterCommentTileState extends State<_ChapterCommentTile> {
|
||||
@override
|
||||
void initState() {
|
||||
likes = widget.comment.score ?? 0;
|
||||
isLiked = widget.comment.isLiked ?? false;
|
||||
voteStatus = widget.comment.voteStatus;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.showAvatar)
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
child: widget.comment.avatar == null
|
||||
? null
|
||||
: AnimatedImage(
|
||||
image: CachedImageProvider(
|
||||
widget.comment.avatar!,
|
||||
sourceKey: widget.source.key,
|
||||
),
|
||||
),
|
||||
).paddingRight(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.comment.userName, style: ts.bold),
|
||||
if (widget.comment.time != null)
|
||||
Text(widget.comment.time!, style: ts.s12),
|
||||
const SizedBox(height: 4),
|
||||
_CommentContent(text: widget.comment.content),
|
||||
buildActions(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildActions() {
|
||||
if (!widget.showActions) {
|
||||
return const SizedBox();
|
||||
}
|
||||
if (widget.comment.score == null && widget.comment.replyCount == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (widget.comment.score != null &&
|
||||
widget.source.voteCommentFunc != null)
|
||||
buildVote(),
|
||||
if (widget.comment.score != null &&
|
||||
widget.source.likeCommentFunc != null)
|
||||
buildLike(),
|
||||
// Only show reply button if comment has both id and replyCount
|
||||
if (widget.comment.replyCount != null && widget.comment.id != null)
|
||||
buildReply(),
|
||||
],
|
||||
),
|
||||
).paddingTop(8);
|
||||
}
|
||||
|
||||
Widget buildReply() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
// Get the parent page's widget to access comicTitle and chapterTitle
|
||||
var parentState = context.findAncestorStateOfType<_ChapterCommentsPageState>();
|
||||
showSideBar(
|
||||
context,
|
||||
ChapterCommentsPage(
|
||||
comicId: widget.comicId,
|
||||
epId: widget.epId,
|
||||
source: widget.source,
|
||||
comicTitle: parentState?.widget.comicTitle ?? '',
|
||||
chapterTitle: parentState?.widget.chapterTitle ?? '',
|
||||
replyComment: widget.comment,
|
||||
),
|
||||
showBarrier: false,
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.insert_comment_outlined, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(widget.comment.replyCount.toString()),
|
||||
],
|
||||
).padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 4)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool isLiking = false;
|
||||
bool isLiked = false;
|
||||
var likes = 0;
|
||||
|
||||
Widget buildLike() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () async {
|
||||
if (isLiking) return;
|
||||
setState(() {
|
||||
isLiking = true;
|
||||
});
|
||||
var res = await widget.source.likeCommentFunc!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
widget.comment.id!,
|
||||
!isLiked,
|
||||
);
|
||||
if (res.success) {
|
||||
isLiked = !isLiked;
|
||||
likes += isLiked ? 1 : -1;
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage ?? "Error");
|
||||
}
|
||||
setState(() {
|
||||
isLiking = false;
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isLiking)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
else if (isLiked)
|
||||
Icon(
|
||||
Icons.favorite,
|
||||
size: 16,
|
||||
color: context.useTextColor(Colors.red),
|
||||
)
|
||||
else
|
||||
const Icon(Icons.favorite_border, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(likes.toString()),
|
||||
],
|
||||
).padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 4)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int? voteStatus;
|
||||
bool isVotingUp = false;
|
||||
bool isVotingDown = false;
|
||||
|
||||
void vote(bool isUp) async {
|
||||
if (isVotingUp || isVotingDown) return;
|
||||
setState(() {
|
||||
if (isUp) {
|
||||
isVotingUp = true;
|
||||
} else {
|
||||
isVotingDown = true;
|
||||
}
|
||||
});
|
||||
var isCancel = (isUp && voteStatus == 1) || (!isUp && voteStatus == -1);
|
||||
var res = await widget.source.voteCommentFunc!(
|
||||
widget.comicId,
|
||||
widget.epId,
|
||||
widget.comment.id!,
|
||||
isUp,
|
||||
isCancel,
|
||||
);
|
||||
if (res.success) {
|
||||
if (isCancel) {
|
||||
voteStatus = 0;
|
||||
} else {
|
||||
if (isUp) {
|
||||
voteStatus = 1;
|
||||
} else {
|
||||
voteStatus = -1;
|
||||
}
|
||||
}
|
||||
widget.comment.voteStatus = voteStatus;
|
||||
widget.comment.score = res.data ?? widget.comment.score;
|
||||
} else {
|
||||
context.showMessage(message: res.errorMessage ?? "Error");
|
||||
}
|
||||
setState(() {
|
||||
isVotingUp = false;
|
||||
isVotingDown = false;
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildVote() {
|
||||
var upColor = context.colorScheme.outline;
|
||||
if (voteStatus == 1) {
|
||||
upColor = context.useTextColor(Colors.red);
|
||||
}
|
||||
var downColor = context.colorScheme.outline;
|
||||
if (voteStatus == -1) {
|
||||
downColor = context.useTextColor(Colors.blue);
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Button.icon(
|
||||
isLoading: isVotingUp,
|
||||
icon: const Icon(Icons.arrow_upward),
|
||||
size: 18,
|
||||
color: upColor,
|
||||
onPressed: () => vote(true),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(widget.comment.score.toString()),
|
||||
const SizedBox(width: 4),
|
||||
Button.icon(
|
||||
isLoading: isVotingDown,
|
||||
icon: const Icon(Icons.arrow_downward),
|
||||
size: 18,
|
||||
color: downColor,
|
||||
onPressed: () => vote(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommentContent extends StatelessWidget {
|
||||
const _CommentContent({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!text.contains('<') && !text.contains('http')) {
|
||||
return SelectableText(text);
|
||||
} else {
|
||||
// Use the RichCommentContent from comments_page.dart
|
||||
// For simplicity, we'll just show plain text here
|
||||
// In a real implementation, you'd need to import or duplicate the RichCommentContent class
|
||||
return SelectableText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/favorites.dart';
|
||||
import 'package:venera/foundation/global_state.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/image_provider/cached_image.dart';
|
||||
import 'package:venera/foundation/image_provider/reader_image.dart';
|
||||
import 'package:venera/foundation/local.dart';
|
||||
import 'package:venera/foundation/log.dart';
|
||||
@@ -54,6 +55,8 @@ part 'loading.dart';
|
||||
|
||||
part 'chapters.dart';
|
||||
|
||||
part 'chapter_comments.dart';
|
||||
|
||||
extension _ReaderContext on BuildContext {
|
||||
_ReaderState get reader => findAncestorStateOfType<_ReaderState>()!;
|
||||
|
||||
@@ -163,14 +166,27 @@ class _ReaderState extends State<Reader>
|
||||
}
|
||||
if (widget.initialPage != null) {
|
||||
page = widget.initialPage!;
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
}
|
||||
// mode = ReaderMode.fromKey(appdata.settings['readerMode']);
|
||||
mode = ReaderMode.fromKey(appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'));
|
||||
mode = ReaderMode.fromKey(
|
||||
appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerMode'),
|
||||
);
|
||||
history = widget.history;
|
||||
if (!appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSystemStatusBar')) {
|
||||
if (!appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'showSystemStatusBar',
|
||||
)) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
if (appdata.settings.getReaderSetting(cid, type.sourceKey, 'enableTurnPageByVolumeKey')) {
|
||||
if (appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'enableTurnPageByVolumeKey',
|
||||
)) {
|
||||
handleVolumeEvent();
|
||||
}
|
||||
setImageCacheSize();
|
||||
@@ -208,8 +224,10 @@ class _ReaderState extends State<Reader>
|
||||
} else {
|
||||
maxImageCacheSize = 500 << 20;
|
||||
}
|
||||
Log.info("Reader",
|
||||
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize");
|
||||
Log.info(
|
||||
"Reader",
|
||||
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize",
|
||||
);
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
|
||||
}
|
||||
|
||||
@@ -239,13 +257,15 @@ class _ReaderState extends State<Reader>
|
||||
onKeyEvent: onKeyEvent,
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return _ReaderScaffold(
|
||||
child: _ReaderGestureDetector(
|
||||
child: _ReaderImages(key: Key(chapter.toString())),
|
||||
),
|
||||
);
|
||||
})
|
||||
OverlayEntry(
|
||||
builder: (context) {
|
||||
return _ReaderScaffold(
|
||||
child: _ReaderGestureDetector(
|
||||
child: _ReaderImages(key: Key(chapter.toString())),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -382,16 +402,29 @@ abstract mixin class _ImagePerPageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
bool showSingleImageOnFirstPage() =>
|
||||
appdata.settings.getReaderSetting(cid, type.sourceKey, 'showSingleImageOnFirstPage');
|
||||
bool showSingleImageOnFirstPage() => appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'showSingleImageOnFirstPage',
|
||||
);
|
||||
|
||||
/// The number of images displayed on one screen
|
||||
int get imagesPerPage {
|
||||
if (mode.isContinuous) return 1;
|
||||
if (isPortrait) {
|
||||
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForPortrait') ?? 1;
|
||||
return appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'readerScreenPicNumberForPortrait',
|
||||
) ??
|
||||
1;
|
||||
} else {
|
||||
return appdata.settings.getReaderSetting(cid, type.sourceKey, 'readerScreenPicNumberForLandscape') ?? 1;
|
||||
return appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'readerScreenPicNumberForLandscape',
|
||||
) ??
|
||||
1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,15 +433,22 @@ abstract mixin class _ImagePerPageHandler {
|
||||
int currentImagesPerPage = imagesPerPage;
|
||||
bool currentOrientation = isPortrait;
|
||||
|
||||
if (_lastImagesPerPage != currentImagesPerPage || _lastOrientation != currentOrientation) {
|
||||
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage);
|
||||
if (_lastImagesPerPage != currentImagesPerPage ||
|
||||
_lastOrientation != currentOrientation) {
|
||||
_adjustPageForImagesPerPageChange(
|
||||
_lastImagesPerPage,
|
||||
currentImagesPerPage,
|
||||
);
|
||||
_lastImagesPerPage = currentImagesPerPage;
|
||||
_lastOrientation = currentOrientation;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjust the page number when the number of images per page changes
|
||||
void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) {
|
||||
void _adjustPageForImagesPerPageChange(
|
||||
int oldImagesPerPage,
|
||||
int newImagesPerPage,
|
||||
) {
|
||||
int previousImageIndex = 1;
|
||||
if (!showSingleImageOnFirstPage() || oldImagesPerPage == 1) {
|
||||
previousImageIndex = (page - 1) * oldImagesPerPage + 1;
|
||||
@@ -431,7 +471,7 @@ abstract mixin class _ImagePerPageHandler {
|
||||
newPage = previousImageIndex;
|
||||
}
|
||||
|
||||
page = newPage>0 ? newPage : 1;
|
||||
page = newPage > 0 ? newPage : 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,10 +506,7 @@ abstract mixin class _VolumeListener {
|
||||
if (volumeListener != null) {
|
||||
volumeListener?.cancel();
|
||||
}
|
||||
volumeListener = VolumeListener(
|
||||
onDown: onDown,
|
||||
onUp: onUp,
|
||||
)..listen();
|
||||
volumeListener = VolumeListener(onDown: onDown, onUp: onUp)..listen();
|
||||
}
|
||||
|
||||
void stopVolumeEvent() {
|
||||
@@ -504,7 +541,8 @@ abstract mixin class _ReaderLocation {
|
||||
|
||||
void update();
|
||||
|
||||
bool enablePageAnimation(String cid, ComicType type) => appdata.settings.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
|
||||
bool enablePageAnimation(String cid, ComicType type) => appdata.settings
|
||||
.getReaderSetting(cid, type.sourceKey, 'enablePageAnimation');
|
||||
|
||||
_ImageViewController? _imageViewController;
|
||||
|
||||
@@ -585,7 +623,11 @@ abstract mixin class _ReaderLocation {
|
||||
autoPageTurningTimer!.cancel();
|
||||
autoPageTurningTimer = null;
|
||||
} else {
|
||||
int interval = appdata.settings.getReaderSetting(cid, type.sourceKey, 'autoPageTurningInterval');
|
||||
int interval = appdata.settings.getReaderSetting(
|
||||
cid,
|
||||
type.sourceKey,
|
||||
'autoPageTurningInterval',
|
||||
);
|
||||
autoPageTurningTimer = Timer.periodic(Duration(seconds: interval), (_) {
|
||||
if (page == maxPage) {
|
||||
autoPageTurningTimer!.cancel();
|
||||
|
||||
@@ -183,6 +183,14 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (shouldShowChapterComments())
|
||||
Tooltip(
|
||||
message: "Chapter Comments".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.comment),
|
||||
onPressed: openChapterComments,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Settings".tl,
|
||||
child: IconButton(
|
||||
@@ -605,7 +613,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
var (imageIndex, data) = result;
|
||||
var fileType = detectFileType(data);
|
||||
var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
var filename =
|
||||
"${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
saveFile(data: data, filename: filename);
|
||||
}
|
||||
|
||||
@@ -616,7 +625,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
}
|
||||
var (imageIndex, data) = result;
|
||||
var fileType = detectFileType(data);
|
||||
var filename = "${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
var filename =
|
||||
"${context.reader.widget.name}_${imageIndex + 1}${fileType.ext}";
|
||||
Share.shareFile(data: data, filename: filename, mime: fileType.mime);
|
||||
}
|
||||
|
||||
@@ -650,6 +660,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
if (key == "quickCollectImage") {
|
||||
addDragListener();
|
||||
}
|
||||
if (key == "showChapterComments") {
|
||||
update();
|
||||
}
|
||||
context.reader.update();
|
||||
},
|
||||
),
|
||||
@@ -657,6 +670,48 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
|
||||
);
|
||||
}
|
||||
|
||||
bool shouldShowChapterComments() {
|
||||
// Check if chapters exist
|
||||
if (context.reader.widget.chapters == null) return false;
|
||||
|
||||
// Check if setting is enabled
|
||||
var showChapterComments = appdata.settings.getReaderSetting(
|
||||
context.reader.cid,
|
||||
context.reader.type.sourceKey,
|
||||
'showChapterComments',
|
||||
);
|
||||
if (showChapterComments != true) return false;
|
||||
|
||||
// Check if comic source supports chapter comments
|
||||
var source = ComicSource.find(context.reader.type.sourceKey);
|
||||
if (source == null || source.chapterCommentsLoader == null) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void openChapterComments() {
|
||||
var source = ComicSource.find(context.reader.type.sourceKey);
|
||||
if (source == null) return;
|
||||
|
||||
var chapters = context.reader.widget.chapters;
|
||||
if (chapters == null) return;
|
||||
|
||||
var chapterIndex = context.reader.chapter - 1;
|
||||
var epId = chapters.ids.elementAt(chapterIndex);
|
||||
var chapterTitle = chapters.titles.elementAt(chapterIndex);
|
||||
|
||||
showSideBar(
|
||||
context,
|
||||
ChapterCommentsPage(
|
||||
comicId: context.reader.cid,
|
||||
epId: epId,
|
||||
source: source,
|
||||
comicTitle: context.reader.widget.name,
|
||||
chapterTitle: chapterTitle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEpChangeButton() {
|
||||
if (context.reader.widget.chapters == null) return const SizedBox();
|
||||
switch (showFloatingButtonValue) {
|
||||
|
||||
@@ -49,7 +49,10 @@ class _SearchPageState extends State<SearchPage> {
|
||||
void search([String? text]) {
|
||||
if (aggregatedSearch) {
|
||||
context
|
||||
.to(() => AggregatedSearchPage(keyword: text ?? controller.text))
|
||||
.to(
|
||||
() => AggregatedSearchPage(keyword: text ?? controller.text),
|
||||
iosFullScreenGesture: false,
|
||||
)
|
||||
.then((_) => update());
|
||||
} else {
|
||||
context
|
||||
@@ -59,6 +62,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
sourceKey: searchTarget,
|
||||
options: options,
|
||||
),
|
||||
iosFullScreenGesture: false,
|
||||
)
|
||||
.then((_) => update());
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
).toSliver(),
|
||||
_CallbackSetting(
|
||||
title: "Custom Image Processing".tl,
|
||||
callback: () => context.to(() => _CustomImageProcessing()),
|
||||
callback: () => context.to(() => _CustomImageProcessing(), iosFullScreenGesture: false),
|
||||
actionTitle: "Edit".tl,
|
||||
).toSliver(),
|
||||
_SliderSetting(
|
||||
@@ -303,6 +303,15 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
_SwitchSetting(
|
||||
title: "Show Chapter Comments".tl,
|
||||
settingKey: "showChapterComments",
|
||||
onChanged: () {
|
||||
widget.onChanged?.call("showChapterComments");
|
||||
},
|
||||
comicId: isEnabledSpecificSettings ? widget.comicId : null,
|
||||
comicSource: isEnabledSpecificSettings ? widget.comicSource : null,
|
||||
).toSliver(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,9 +252,10 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
|
||||
if (!App.isIOS) {
|
||||
return;
|
||||
}
|
||||
if (event.position.dx < 20) {
|
||||
gestureRecognizer.addPointer(event);
|
||||
if (currentPage == -1) {
|
||||
return;
|
||||
}
|
||||
gestureRecognizer.addPointer(event);
|
||||
}
|
||||
|
||||
Widget buildLeft() {
|
||||
|
||||
15
pubspec.lock
15
pubspec.lock
@@ -478,10 +478,11 @@ packages:
|
||||
flutter_to_debian:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_to_debian
|
||||
sha256: d23534407334b331ce20fbaa8395b9ecc255d0c047136b8998715f36933ee696
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: "3777c91b6b1cc0b7c03357c67ca216d4313c3db5"
|
||||
url: "https://github.com/venera-app/flutter_to_debian.git"
|
||||
source: git
|
||||
version: "2.0.2"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
@@ -1126,10 +1127,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: zip_flutter
|
||||
sha256: c4d5a34c5803def866bc550926bb16fe89717c9b7304695d5b2ede30964eb8a8
|
||||
sha256: baecf8deb6bf53a50e5ab513707ab56cc0c25f5b43333aa56ef562e8e7057357
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.12"
|
||||
version: "0.0.13"
|
||||
sdks:
|
||||
dart: ">=3.8.0 <4.0.0"
|
||||
flutter: ">=3.35.5"
|
||||
flutter: ">=3.35.7"
|
||||
|
||||
10
pubspec.yaml
10
pubspec.yaml
@@ -2,11 +2,11 @@ name: venera
|
||||
description: "A comic app."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.5.3+153
|
||||
version: 1.6.0+160
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
flutter: 3.35.5
|
||||
flutter: 3.35.7
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -53,7 +53,7 @@ dependencies:
|
||||
sliver_tools: ^0.2.12
|
||||
flutter_file_dialog: ^3.0.2
|
||||
file_selector: ^1.0.3
|
||||
zip_flutter: ^0.0.12
|
||||
zip_flutter: ^0.0.13
|
||||
lodepng_flutter:
|
||||
git:
|
||||
url: https://github.com/venera-app/lodepng_flutter
|
||||
@@ -93,7 +93,9 @@ dev_dependencies:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_to_arch: ^1.0.1
|
||||
flutter_to_debian: ^2.0.2
|
||||
flutter_to_debian:
|
||||
git:
|
||||
url: https://github.com/venera-app/flutter_to_debian.git
|
||||
archive: any
|
||||
|
||||
flutter:
|
||||
|
||||
Reference in New Issue
Block a user