23 Commits

Author SHA1 Message Date
b8bdda16c6 fix cbz import 2024-12-02 21:00:06 +08:00
1a50b8bc27 fix TabBar 2024-12-02 21:00:06 +08:00
546f619063 comment button 2024-12-02 21:00:06 +08:00
Naomi
0e831468ee feat: 漫画列表页本地收藏自动选择默认收藏夹 (#84)
Signed-off-by: Naomi <33375791+Henvy-Mango@users.noreply.github.com>
2024-12-02 21:00:06 +08:00
a4cc0a3af2 Update saf 2024-12-02 21:00:06 +08:00
80811bf12d rollback android storage setting 2024-12-02 21:00:06 +08:00
21bf9d72c0 Add HistoryImageProvider 2024-12-02 21:00:06 +08:00
035a84380c fix copyDirectoryIsolate 2024-12-02 21:00:06 +08:00
5ddb6f47ca add telegram link 2024-12-02 21:00:06 +08:00
c1672d01f8 update reader 2024-12-02 21:00:06 +08:00
buste
66ebdb03b1 Feat 为画廊模式添加每页显示图片数量的配置 (#82)
* Feat: Add dynamic image-per-page configuration for gallery mode

- Implemented a slider to configure the number of images displayed per page (1-5) in gallery mode.
- Updated the reader to dynamically reflect changes in the `imagesPerPage` setting without requiring a mode switch or reopening.
- Ensured compatibility with existing continuous reading mode.

* fix currentImagesPerPage

* fix Continuous mode

* improve readerScreenPicNumber setting disable view

* improve PhotoViewController
2024-12-02 21:00:06 +08:00
df2ba6efd1 update version code 2024-12-02 21:00:06 +08:00
705c448cfe export comic as pdf 2024-12-02 21:00:06 +08:00
a711335012 import pica data 2024-12-02 21:00:06 +08:00
305ef9263d fix selecting file on Android 2024-12-02 21:00:06 +08:00
f8b8811aaa fix #52 2024-12-02 21:00:06 +08:00
a868fe3fff prevent too many image loading at save time 2024-12-02 21:00:06 +08:00
873cbd779e fix #73 2024-12-02 21:00:06 +08:00
d56e3fd59f fix #76 2024-12-02 21:00:06 +08:00
d96b36414d fix subtitle 2024-12-02 21:00:06 +08:00
b30bd11d1a fix #77 2024-12-02 21:00:06 +08:00
nyne
72507d907a Feat/saf (#81)
* [Android] Use SAF to change local path

* Use IOOverrides to replace openDirectoryPlatform and openFilePlatform

* fix io
2024-12-02 21:00:06 +08:00
onlytheworld
06094fc5fc 自动发布 (#80)
* change workflow

* Update main.yml
2024-11-29 17:08:01 +08:00
43 changed files with 1113 additions and 383 deletions

View File

@@ -2,6 +2,9 @@ name: Build ALL
run-name: Build ALL run-name: Build ALL
on: on:
workflow_dispatch: {} workflow_dispatch: {}
release:
types: [published]
jobs: jobs:
Build_MacOS: Build_MacOS:
runs-on: macos-15 runs-on: macos-15
@@ -139,3 +142,45 @@ jobs:
name: arch_build name: arch_build
path: build/linux/arch/ path: build/linux/arch/
Release:
runs-on: ubuntu-latest
needs: [Build_MacOS, Build_IOS, Build_Android, Build_Windows, Build_Linux]
if: github.event_name == 'release' # 仅在 push 事件时执行
steps:
- uses: actions/download-artifact@v4
with:
name: venera.dmg
path: outputs
- uses: actions/download-artifact@v4
with:
name: app-ios.ipa
path: outputs
- uses: actions/download-artifact@v4
with:
name: apks
path: outputs
- uses: actions/download-artifact@v4
with:
name: windows_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: deb_build
path: outputs
- uses: actions/download-artifact@v4
with:
name: arch_build
path: outputs
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
outputs/*.ipa
outputs/*.dmg
outputs/*.apk
outputs/*.zip
outputs/*.exe
outputs/*.deb
outputs/*.zst
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.Settings import android.provider.Settings
import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
@@ -324,8 +325,25 @@ class MainActivity : FlutterFragmentActivity() {
} }
} }
// use copy method // use copy method
val filePath = FileUtils.getPathFromCopyOfFileFromUri(this, uri) val tmp = File(cacheDir, fileName)
result.success(filePath) if(tmp.exists()) {
tmp.delete()
}
Log.i("Venera", "copy file (${fileName}) to ${tmp.absolutePath}")
Thread {
try {
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tmp).use { output ->
input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE)
output.flush()
}
}
result.success(tmp.absolutePath)
}
catch (e: Exception) {
result.error("copy error", e.message, null)
}
}.start()
} }
} }
} }

View File

@@ -877,6 +877,8 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
/** /**
* Create a comic details object * Create a comic details object
* @param title {string} * @param title {string}
* @param subtitle {string}
* @param subTitle {string} - equal to subtitle
* @param cover {string} * @param cover {string}
* @param description {string?} * @param description {string?}
* @param tags {Map<string, string[]> | {} | null | undefined} * @param tags {Map<string, string[]> | {} | null | undefined}
@@ -897,8 +899,9 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage
* @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page. * @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page.
* @constructor * @constructor
*/ */
function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) { function ComicDetails({title, subtitle, subTitle, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) {
this.title = title; this.title = title;
this.subtitle = subtitle ?? subTitle;
this.cover = cover; this.cover = cover;
this.description = description; this.description = description;
this.tags = tags; this.tags = tags;

View File

@@ -105,6 +105,7 @@
"Continuous (Right to Left)": "连续(从右到左)", "Continuous (Right to Left)": "连续(从右到左)",
"Continuous (Top to Bottom)": "连续(从上到下)", "Continuous (Top to Bottom)": "连续(从上到下)",
"Auto page turning interval": "自动翻页间隔", "Auto page turning interval": "自动翻页间隔",
"The number of pic in screen (Only Gallery Mode)": "同屏幕图片数量(仅画廊模式)",
"Theme Mode": "主题模式", "Theme Mode": "主题模式",
"System": "系统", "System": "系统",
"Light": "浅色", "Light": "浅色",
@@ -152,7 +153,7 @@
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。", "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目录包含一个名为'cover.*'的文件,它将被用作封面图片。否则将使用第一张图片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n", "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目录名称将被用作漫画标题。章节目录的名称将被用作章节标题。\n",
"Export as cbz": "导出为cbz", "Export as cbz": "导出为cbz",
"Select a cbz file." : "选择一个cbz文件", "Select a cbz/zip file." : "选择一个cbz/zip文件",
"A cbz file" : "一个cbz文件", "A cbz file" : "一个cbz文件",
"Fullscreen": "全屏", "Fullscreen": "全屏",
"Exit": "退出", "Exit": "退出",
@@ -244,7 +245,8 @@
"Deleted @a favorite items.": "已删除 @a 条无效收藏", "Deleted @a favorite items.": "已删除 @a 条无效收藏",
"New version available": "有新版本可用", "New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?",
"No new version available": "没有新版本可用" "No new version available": "没有新版本可用",
"Export as pdf": "导出为pdf"
}, },
"zh_TW": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -352,6 +354,7 @@
"Continuous (Right to Left)": "連續(從右到左)", "Continuous (Right to Left)": "連續(從右到左)",
"Continuous (Top to Bottom)": "連續(從上到下)", "Continuous (Top to Bottom)": "連續(從上到下)",
"Auto page turning interval": "自動翻頁間隔", "Auto page turning interval": "自動翻頁間隔",
"The number of pic in screen (Only Gallery Mode)": "同螢幕圖片數量(僅畫廊模式)",
"Theme Mode": "主題模式", "Theme Mode": "主題模式",
"System": "系統", "System": "系統",
"Light": "浅色", "Light": "浅色",
@@ -399,7 +402,7 @@
"If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。", "If the directory contains a file named 'cover.*', it will be used as the cover image. Otherwise the first image will be used." : "如果目錄包含一個名為'cover.*'的文件,它將被用作封面圖片。否則將使用第一張圖片。",
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n", "The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n" : "目錄名稱將被用作漫畫標題。章節目錄的名稱將被用作章節標題。\n",
"Export as cbz": "匯出為cbz", "Export as cbz": "匯出為cbz",
"Select a cbz file." : "選擇一個cbz文件", "Select a cbz/zip file." : "選擇一個cbz/zip文件",
"A cbz file" : "一個cbz文件", "A cbz file" : "一個cbz文件",
"Fullscreen": "全螢幕", "Fullscreen": "全螢幕",
"Exit": "退出", "Exit": "退出",
@@ -491,6 +494,7 @@
"Deleted @a favorite items.": "已刪除 @a 條無效收藏", "Deleted @a favorite items.": "已刪除 @a 條無效收藏",
"New version available": "有新版本可用", "New version available": "有新版本可用",
"A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?",
"No new version available": "沒有新版本可用" "No new version available": "沒有新版本可用",
"Export as pdf": "匯出為pdf"
} }
} }

View File

@@ -332,7 +332,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
animation: _controller, animation: _controller.animation ?? _controller,
builder: buildTabBar, builder: buildTabBar,
); );
} }
@@ -427,7 +427,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: DefaultTextStyle( child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith( style: DefaultTextStyle.of(context).style.copyWith(
color: i == _controller.index color: i == _controller.animation?.value.round()
? context.colorScheme.primary ? context.colorScheme.primary
: context.colorScheme.onSurface, : context.colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -163,7 +163,9 @@ class ComicTile extends StatelessWidget {
Widget buildImage(BuildContext context) { Widget buildImage(BuildContext context) {
ImageProvider image; ImageProvider image;
if (comic is LocalComic) { if (comic is LocalComic) {
image = FileImage((comic as LocalComic).coverFile); image = LocalComicImageProvider(comic as LocalComic);
} else if (comic is History) {
image = HistoryImageProvider(comic as History);
} else if (comic.sourceKey == 'local') { } else if (comic.sourceKey == 'local') {
var localComic = LocalManager().find(comic.id, ComicType.local); var localComic = LocalManager().find(comic.id, ComicType.local);
if (localComic == null) { if (localComic == null) {
@@ -829,6 +831,7 @@ class ComicList extends StatefulWidget {
this.trailingSliver, this.trailingSliver,
this.errorLeading, this.errorLeading,
this.menuBuilder, this.menuBuilder,
this.controller,
}); });
final Future<Res<List<Comic>>> Function(int page)? loadPage; final Future<Res<List<Comic>>> Function(int page)? loadPage;
@@ -843,6 +846,8 @@ class ComicList extends StatefulWidget {
final List<MenuEntry> Function(Comic)? menuBuilder; final List<MenuEntry> Function(Comic)? menuBuilder;
final ScrollController? controller;
@override @override
State<ComicList> createState() => ComicListState(); State<ComicList> createState() => ComicListState();
} }
@@ -1064,6 +1069,7 @@ class ComicListState extends State<ComicList> {
); );
} }
return SmoothCustomScrollView( return SmoothCustomScrollView(
controller: widget.controller,
slivers: [ slivers: [
if (widget.leadingSliver != null) widget.leadingSliver!, if (widget.leadingSliver != null) widget.leadingSliver!,
if (_maxPage != 1) _buildSliverPageSelector(), if (_maxPage != 1) _buildSliverPageSelector(),

View File

@@ -19,13 +19,14 @@ import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/cloudflare.dart'; import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';

View File

@@ -47,10 +47,16 @@ class NaviPane extends StatefulWidget {
final GlobalKey<NavigatorState> navigatorKey; final GlobalKey<NavigatorState> navigatorKey;
@override @override
State<NaviPane> createState() => _NaviPaneState(); State<NaviPane> createState() => NaviPaneState();
static NaviPaneState of(BuildContext context) {
return context.findAncestorStateOfType<NaviPaneState>()!;
}
} }
class _NaviPaneState extends State<NaviPane> typedef NaviItemTapListener = void Function(int);
class NaviPaneState extends State<NaviPane>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late int _currentPage = widget.initialPage; late int _currentPage = widget.initialPage;
@@ -66,6 +72,16 @@ class _NaviPaneState extends State<NaviPane>
late AnimationController controller; late AnimationController controller;
final _naviItemTapListeners = <NaviItemTapListener>[];
void addNaviItemTapListener(NaviItemTapListener listener) {
_naviItemTapListeners.add(listener);
}
void removeNaviItemTapListener(NaviItemTapListener listener) {
_naviItemTapListeners.remove(listener);
}
static const _kBottomBarHeight = 58.0; static const _kBottomBarHeight = 58.0;
static const _kFoldedSideBarWidth = 80.0; static const _kFoldedSideBarWidth = 80.0;
@@ -85,9 +101,15 @@ class _NaviPaneState extends State<NaviPane>
} }
void updatePage(int index) { void updatePage(int index) {
for (var listener in _naviItemTapListeners) {
listener(index);
}
if (widget.observer.routes.length > 1) { if (widget.observer.routes.length > 1) {
widget.navigatorKey.currentState!.popUntil((route) => route.isFirst); widget.navigatorKey.currentState!.popUntil((route) => route.isFirst);
} }
if (currentPage == index) {
return;
}
setState(() { setState(() {
currentPage = index; currentPage = index;
}); });
@@ -670,14 +692,14 @@ class _NaviPopScope extends StatelessWidget {
class _NaviMainView extends StatefulWidget { class _NaviMainView extends StatefulWidget {
const _NaviMainView({required this.state}); const _NaviMainView({required this.state});
final _NaviPaneState state; final NaviPaneState state;
@override @override
State<_NaviMainView> createState() => _NaviMainViewState(); State<_NaviMainView> createState() => _NaviMainViewState();
} }
class _NaviMainViewState extends State<_NaviMainView> { class _NaviMainViewState extends State<_NaviMainView> {
_NaviPaneState get state => widget.state; NaviPaneState get state => widget.state;
@override @override
void initState() { void initState() {

View File

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

View File

@@ -106,6 +106,7 @@ class _Settings with ChangeNotifier {
'defaultSearchTarget': null, 'defaultSearchTarget': null,
'autoPageTurningInterval': 5, // in seconds 'autoPageTurningInterval': 5, // in seconds
'readerMode': 'galleryLeftToRight', // values of [ReaderMode] 'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'readerScreenPicNumber': 1, // 1 - 5
'enableTapToTurnPages': true, 'enableTapToTurnPages': true,
'enablePageAnimation': true, 'enablePageAnimation': true,
'language': 'system', // system, zh-CN, zh-TW, en-US 'language': 'system', // system, zh-CN, zh-TW, en-US

View File

@@ -172,7 +172,7 @@ class ComicDetails with HistoryMixin {
ComicDetails.fromJson(Map<String, dynamic> json) ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"], : title = json["title"],
subTitle = json["subTitle"], subTitle = json["subtitle"],
cover = json["cover"], cover = json["cover"],
description = json["description"], description = json["description"],
tags = _generateMap(json["tags"]), tags = _generateMap(json["tags"]),
@@ -198,7 +198,9 @@ class ComicDetails with HistoryMixin {
maxPage = json["maxPage"], maxPage = json["maxPage"],
comments = (json["comments"] as List?) comments = (json["comments"] as List?)
?.map((e) => Comment.fromJson(e)) ?.map((e) => Comment.fromJson(e))
.toList(); .toList(){
print(json);
}
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {

View File

@@ -2,7 +2,9 @@ import 'dart:async';
import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/sqlite3.dart'; import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/utils/translations.dart';
import 'app.dart'; import 'app.dart';
@@ -22,15 +24,18 @@ abstract mixin class HistoryMixin {
HistoryType get historyType; HistoryType get historyType;
} }
class History { class History implements Comic {
HistoryType type; HistoryType type;
DateTime time; DateTime time;
@override
String title; String title;
@override
String subtitle; String subtitle;
@override
String cover; String cover;
int ep; int ep;
@@ -44,6 +49,7 @@ class History {
/// The number of episodes is 1-based. /// The number of episodes is 1-based.
Set<int> readEpisode; Set<int> readEpisode;
@override
int? maxPage; int? maxPage;
History.fromModel( History.fromModel(
@@ -137,6 +143,47 @@ class History {
@override @override
int get hashCode => Object.hash(id, type); int get hashCode => Object.hash(id, type);
@override
String get description {
var res = "";
if (ep >= 1) {
res += "Chapter @ep".tlParams({
"ep": ep,
});
}
if (page >= 1) {
if (ep >= 1) {
res += " - ";
}
res += "Page @page".tlParams({
"page": page,
});
}
return res;
}
@override
String? get favoriteId => null;
@override
String? get language => null;
@override
String get sourceKey => type == ComicType.local
? 'local'
: type.comicSource?.key ?? "Unknown:${type.value}";
@override
double? get stars => null;
@override
List<String>? get tags => null;
@override
Map<String, dynamic> toJson() {
throw UnimplementedError();
}
} }
class HistoryManager with ChangeNotifier { class HistoryManager with ChangeNotifier {

View File

@@ -1,5 +1,4 @@
import 'dart:async' show Future, StreamController, scheduleMicrotask; import 'dart:async' show Future, StreamController, scheduleMicrotask;
import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'dart:ui'; import 'dart:ui';
@@ -108,7 +107,7 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
} }
} }
static final _cache = LinkedHashMap<String, Uint8List>(); static final _cache = <String, Uint8List>{};
static var _cacheSize = 0; static var _cacheSize = 0;

View File

@@ -1,5 +1,4 @@
import 'dart:async' show Future, StreamController; import 'dart:async' show Future, StreamController;
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/network/images.dart'; import 'package:venera/network/images.dart';
@@ -22,10 +21,19 @@ class CachedImageProvider
final String? cid; final String? cid;
static int loadingCount = 0;
static const _kMaxLoadingCount = 8;
@override @override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async { Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
while(loadingCount > _kMaxLoadingCount) {
await Future.delayed(const Duration(milliseconds: 100));
}
loadingCount++;
try {
if(url.startsWith("file://")) { if(url.startsWith("file://")) {
var file = openFilePlatform(url.substring(7)); var file = File(url.substring(7));
return file.readAsBytes(); return file.readAsBytes();
} }
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) {
@@ -39,6 +47,10 @@ class CachedImageProvider
} }
throw "Error: Empty response body."; throw "Error: Empty response body.";
} }
finally {
loadingCount--;
}
}
@override @override
Future<CachedImageProvider> obtainKey(ImageConfiguration configuration) { Future<CachedImageProvider> obtainKey(ImageConfiguration configuration) {

View File

@@ -0,0 +1,57 @@
import 'dart:async' show Future, StreamController;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/network/images.dart';
import '../history.dart';
import 'base_image_provider.dart';
import 'history_image_provider.dart' as image_provider;
class HistoryImageProvider
extends BaseImageProvider<image_provider.HistoryImageProvider> {
/// Image provider for normal image.
///
/// [url] is the url of the image. Local file path is also supported.
const HistoryImageProvider(this.history);
final History history;
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
var url = history.cover;
if (!url.contains('/')) {
var localComic = LocalManager().find(history.id, history.type);
if (localComic != null) {
return localComic.coverFile.readAsBytes();
}
var comicSource =
history.type.comicSource ?? (throw "Comic source not found.");
var comic = await comicSource.loadComicInfo!(history.id);
url = comic.data.cover;
history.cover = url;
HistoryManager().addHistory(history);
}
await for (var progress in ImageDownloader.loadThumbnail(
url,
history.type.sourceKey,
history.id,
)) {
chunkEvents.add(ImageChunkEvent(
cumulativeBytesLoaded: progress.currentBytes,
expectedTotalBytes: progress.totalBytes,
));
if (progress.imageBytes != null) {
return progress.imageBytes!;
}
}
throw "Error: Empty response body.";
}
@override
Future<HistoryImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
String get key => "history${history.id}${history.type.value}";
}

View File

@@ -0,0 +1,66 @@
import 'dart:async' show Future, StreamController;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/io.dart';
import 'base_image_provider.dart';
import 'local_comic_image.dart' as image_provider;
class LocalComicImageProvider
extends BaseImageProvider<image_provider.LocalComicImageProvider> {
/// Image provider for normal image.
///
/// [url] is the url of the image. Local file path is also supported.
const LocalComicImageProvider(this.comic);
final LocalComic comic;
@override
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
File? file = comic.coverFile;
if(! await file.exists()) {
file = null;
var dir = Directory(comic.directory);
if (! await dir.exists()) {
throw "Error: Comic not found.";
}
Directory? firstDir;
await for (var entity in dir.list()) {
if(entity is File) {
if(["jpg", "jpeg", "png", "webp", "gif", "jpe", "jpeg"].contains(entity.extension)) {
file = entity;
break;
}
} else if(entity is Directory) {
firstDir ??= entity;
}
}
if(file == null && firstDir != null) {
await for (var entity in firstDir.list()) {
if(entity is File) {
if(["jpg", "jpeg", "png", "webp", "gif", "jpe", "jpeg"].contains(entity.extension)) {
file = entity;
break;
}
}
}
}
}
if(file == null) {
throw "Error: Cover not found.";
}
var data = await file.readAsBytes();
if(data.isEmpty) {
throw "Exception: Empty file(${file.path}).";
}
return data;
}
@override
Future<LocalComicImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
String get key => "local${comic.id}${comic.comicType.value}";
}

View File

@@ -71,12 +71,12 @@ class LocalComic with HistoryMixin implements Comic {
downloadedChapters = List.from(jsonDecode(row[8] as String)), downloadedChapters = List.from(jsonDecode(row[8] as String)),
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int); createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
File get coverFile => openFilePlatform(FilePath.join( File get coverFile => File(FilePath.join(
baseDir, baseDir,
cover, cover,
)); ));
String get baseDir => directory.contains("/") ? directory : FilePath.join(LocalManager().path, directory); String get baseDir => (directory.contains('/') || directory.contains('\\')) ? directory : FilePath.join(LocalManager().path, directory);
@override @override
String get description => ""; String get description => "";
@@ -151,6 +151,8 @@ class LocalManager with ChangeNotifier {
/// path to the directory where all the comics are stored /// path to the directory where all the comics are stored
late String path; late String path;
Directory get directory => Directory(path);
// return error message if failed // return error message if failed
Future<String?> setNewPath(String newPath) async { Future<String?> setNewPath(String newPath) async {
var newDir = Directory(newPath); var newDir = Directory(newPath);
@@ -162,7 +164,7 @@ class LocalManager with ChangeNotifier {
} }
try { try {
await copyDirectoryIsolate( await copyDirectoryIsolate(
Directory(path), directory,
newDir, newDir,
); );
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(newPath); await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(newPath);
@@ -170,7 +172,7 @@ class LocalManager with ChangeNotifier {
Log.error("IO", e, s); Log.error("IO", e, s);
return e.toString(); return e.toString();
} }
await Directory(path).deleteIgnoreError(recursive:true); await directory.deleteContents(recursive: true);
path = newPath; path = newPath;
return null; return null;
} }
@@ -217,15 +219,15 @@ class LocalManager with ChangeNotifier {
'''); ''');
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) { if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync(); path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
if (!Directory(path).existsSync()) { if (!directory.existsSync()) {
path = await findDefaultPath(); path = await findDefaultPath();
} }
} else { } else {
path = await findDefaultPath(); path = await findDefaultPath();
} }
try { try {
if (!Directory(path).existsSync()) { if (!directory.existsSync()) {
await Directory(path).create(); await directory.create();
} }
} }
catch(e, s) { catch(e, s) {
@@ -354,12 +356,12 @@ class LocalManager with ChangeNotifier {
throw "Invalid ep"; throw "Invalid ep";
} }
var comic = find(id, type) ?? (throw "Comic Not Found"); var comic = find(id, type) ?? (throw "Comic Not Found");
var directory = openDirectoryPlatform(comic.baseDir); var directory = Directory(comic.baseDir);
if (comic.chapters != null) { if (comic.chapters != null) {
var cid = ep is int var cid = ep is int
? comic.chapters!.keys.elementAt(ep - 1) ? comic.chapters!.keys.elementAt(ep - 1)
: (ep as String); : (ep as String);
directory = openDirectoryPlatform(FilePath.join(directory.path, cid)); directory = Directory(FilePath.join(directory.path, cid));
} }
var files = <File>[]; var files = <File>[];
await for (var entity in directory.list()) { await for (var entity in directory.list()) {
@@ -406,10 +408,10 @@ class LocalManager with ChangeNotifier {
String id, ComicType type, String name) async { String id, ComicType type, String name) async {
var comic = find(id, type); var comic = find(id, type);
if (comic != null) { if (comic != null) {
return openDirectoryPlatform(FilePath.join(path, comic.directory)); return Directory(FilePath.join(path, comic.directory));
} }
var dir = findValidDirectoryName(path, name); var dir = findValidDirectoryName(path, name);
return openDirectoryPlatform(FilePath.join(path, dir)).create().then((value) => value); return Directory(FilePath.join(path, dir)).create().then((value) => value);
} }
void completeTask(DownloadTask task) { void completeTask(DownloadTask task) {
@@ -468,7 +470,7 @@ class LocalManager with ChangeNotifier {
void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) { void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
if(removeFileOnDisk) { if(removeFileOnDisk) {
var dir = openDirectoryPlatform(FilePath.join(path, c.directory)); var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true); dir.deleteIgnoreError(recursive: true);
} }
//Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted. //Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.

View File

@@ -1,14 +1,18 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class SimpleController extends StateController { class SimpleController extends StateController {
final void Function()? refresh_; final void Function()? refreshFunction;
SimpleController({this.refresh_}); final Map<String, dynamic> Function()? control;
SimpleController({this.refreshFunction, this.control});
@override @override
void refresh() { void refresh() {
(refresh_ ?? super.refresh)(); (refreshFunction ?? super.refresh)();
} }
Map<String, dynamic> get controlMap => control?.call() ?? {};
} }
abstract class StateController { abstract class StateController {
@@ -71,8 +75,8 @@ abstract class StateController {
static SimpleController putSimpleController( static SimpleController putSimpleController(
void Function() onUpdate, Object? tag, void Function() onUpdate, Object? tag,
{void Function()? refresh}) { {void Function()? refresh, Map<String, dynamic> Function()? control}) {
var controller = SimpleController(refresh_: refresh); var controller = SimpleController(refreshFunction: refresh, control: control);
controller.stateUpdaters.add(Pair(null, onUpdate)); controller.stateUpdaters.add(Pair(null, onUpdate));
_controllers.add(StateControllerWrapped(controller, false, tag)); _controllers.add(StateControllerWrapped(controller, false, tag));
return controller; return controller;
@@ -202,6 +206,7 @@ abstract class StateWithController<T extends StatefulWidget> extends State<T> {
}, },
tag, tag,
refresh: refresh, refresh: refresh,
control: () => control,
); );
super.initState(); super.initState();
} }
@@ -218,6 +223,8 @@ abstract class StateWithController<T extends StatefulWidget> extends State<T> {
} }
Object? get tag; Object? get tag;
Map<String, dynamic> get control => {};
} }
class Pair<M, V>{ class Pair<M, V>{

View File

@@ -20,6 +20,7 @@ void main(List<String> args) {
if (runWebViewTitleBarWidget(args)) { if (runWebViewTitleBarWidget(args)) {
return; return;
} }
overrideIO(() {
runZonedGuarded(() async { runZonedGuarded(() async {
await Rhttp.init(); await Rhttp.init();
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -55,6 +56,7 @@ void main(List<String> args) {
}, (error, stack) { }, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack"); Log.error("Unhandled Exception", "$error\n$stack");
}); });
});
} }
class MyApp extends StatefulWidget { class MyApp extends StatefulWidget {

View File

@@ -235,21 +235,22 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
} }
if (path == null) { if (path == null) {
try {
var dir = await LocalManager().findValidDirectory( var dir = await LocalManager().findValidDirectory(
comicId, comicId,
comicType, comicType,
comic!.title, comic!.title,
); );
if (!(await dir.exists())) { if (!(await dir.exists())) {
try {
await dir.create(); await dir.create();
} catch (e) { }
path = dir.path;
} catch (e, s) {
Log.error("Download", e.toString(), s);
_setError("Error: $e"); _setError("Error: $e");
return; return;
} }
} }
path = dir.path;
}
await LocalManager().saveCurrentDownloadingTasks(); await LocalManager().saveCurrentDownloadingTasks();
@@ -266,11 +267,13 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
throw "Failed to download cover"; throw "Failed to download cover";
} }
var fileType = detectFileType(data); var fileType = detectFileType(data);
var file = File(FilePath.join(path!, "cover${fileType.ext}")); var file =
File(FilePath.join(path!, "cover${fileType.ext}"));
file.writeAsBytesSync(data); file.writeAsBytesSync(data);
return "file://${file.path}"; return "file://${file.path}";
}); });
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -294,6 +297,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -323,6 +327,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (res.error) { if (res.error) {
Log.error("Download", res.errorMessage!);
_setError("Error: ${res.errorMessage}"); _setError("Error: ${res.errorMessage}");
return; return;
} else { } else {
@@ -347,6 +352,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
return; return;
} }
if (task.error != null) { if (task.error != null) {
Log.error("Download", task.error.toString());
_setError("Error: ${task.error}"); _setError("Error: ${task.error}");
return; return;
} }
@@ -375,7 +381,6 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
_message = message; _message = message;
notifyListeners(); notifyListeners();
stopRecorder(); stopRecorder();
Log.error("Download", message);
} }
@override @override
@@ -448,7 +453,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
}).toList(), }).toList(),
directory: Directory(path!).name, directory: Directory(path!).name,
chapters: comic!.chapters, chapters: comic!.chapters,
cover: File(_cover!.split("file://").last).uri.pathSegments.last, cover:
File(_cover!.split("file://").last).name,
comicType: ComicType(source.key.hashCode), comicType: ComicType(source.key.hashCode),
downloadedChapters: chapters ?? [], downloadedChapters: chapters ?? [],
createdAt: DateTime.now(), createdAt: DateTime.now(),
@@ -726,8 +732,7 @@ class ArchiveDownloadTask extends DownloadTask {
isDownloaded = status.isFinished; isDownloaded = status.isFinished;
notifyListeners(); notifyListeners();
} }
} } catch (e) {
catch(e) {
_setError("Error: $e"); _setError("Error: $e");
return; return;
} }

View File

@@ -223,7 +223,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
children: [ children: [
SelectableText(comic.title, style: ts.s18), SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null) if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14), SelectableText(comic.subTitle!, style: ts.s14).paddingVertical(4),
Text( Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '', (ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12, style: ts.s12,
@@ -288,8 +288,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
onLongPressed: quickFavorite, onLongPressed: quickFavorite,
iconColor: context.useTextColor(Colors.purple), iconColor: context.useTextColor(Colors.purple),
), ),
if (comicSource.commentsLoader != null && if (comicSource.commentsLoader != null)
(comic.comments == null || comic.comments!.isEmpty))
_ActionButton( _ActionButton(
icon: const Icon(Icons.comment), icon: const Icon(Icons.comment),
text: (comic.commentsCount ?? 'Comments'.tl).toString(), text: (comic.commentsCount ?? 'Comments'.tl).toString(),

View File

@@ -46,6 +46,18 @@ class _ExplorePageState extends State<ExplorePage>
} }
} }
void onNaviItemTapped(int index) {
if (index == 2) {
int page = controller.index;
String currentPageId = pages[page];
StateController.find<SimpleController>(tag: currentPageId)
.control!()['toTop']
?.call();
}
}
NaviPaneState? naviPane;
@override @override
void initState() { void initState() {
pages = List<String>.from(appdata.settings["explore_pages"]); pages = List<String>.from(appdata.settings["explore_pages"]);
@@ -59,13 +71,21 @@ class _ExplorePageState extends State<ExplorePage>
vsync: this, vsync: this,
); );
appdata.settings.addListener(onSettingsChanged); appdata.settings.addListener(onSettingsChanged);
NaviPane.of(context).addNaviItemTapListener(onNaviItemTapped);
super.initState(); super.initState();
} }
@override
void didChangeDependencies() {
naviPane = NaviPane.of(context);
super.didChangeDependencies();
}
@override @override
void dispose() { void dispose() {
controller.dispose(); controller.dispose();
appdata.settings.removeListener(onSettingsChanged); appdata.settings.removeListener(onSettingsChanged);
naviPane?.removeNaviItemTapListener(onNaviItemTapped);
super.dispose(); super.dispose();
} }
@@ -232,6 +252,8 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
bool _wantKeepAlive = true; bool _wantKeepAlive = true;
var scrollController = ScrollController();
void onSettingsChanged() { void onSettingsChanged() {
var explorePages = appdata.settings["explore_pages"]; var explorePages = appdata.settings["explore_pages"];
if (!explorePages.contains(widget.title)) { if (!explorePages.contains(widget.title)) {
@@ -274,6 +296,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
data, data,
comicSourceKey, comicSourceKey,
key: ValueKey(key), key: ValueKey(key),
controller: scrollController,
); );
} else { } else {
return const Center( return const Center(
@@ -287,6 +310,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
loadPage: data.loadPage, loadPage: data.loadPage,
loadNext: data.loadNext, loadNext: data.loadNext,
key: ValueKey(key), key: ValueKey(key),
controller: scrollController,
); );
} }
@@ -323,6 +347,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
Widget buildPage() { Widget buildPage() {
return SmoothCustomScrollView( return SmoothCustomScrollView(
controller: scrollController,
slivers: _buildPage().toList(), slivers: _buildPage().toList(),
); );
} }
@@ -352,15 +377,30 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
@override @override
bool get wantKeepAlive => _wantKeepAlive; bool get wantKeepAlive => _wantKeepAlive;
void toTop() {
if (scrollController.hasClients) {
scrollController.animateTo(
scrollController.position.minScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
}
}
@override
Map<String, dynamic> get control => {"toTop": toTop};
} }
class _MixedExplorePage extends StatefulWidget { class _MixedExplorePage extends StatefulWidget {
const _MixedExplorePage(this.data, this.sourceKey, {super.key}); const _MixedExplorePage(this.data, this.sourceKey, {super.key, this.controller});
final ExplorePageData data; final ExplorePageData data;
final String sourceKey; final String sourceKey;
final ScrollController? controller;
@override @override
State<_MixedExplorePage> createState() => _MixedExplorePageState(); State<_MixedExplorePage> createState() => _MixedExplorePageState();
} }
@@ -394,6 +434,7 @@ class _MixedExplorePageState
@override @override
Widget buildContent(BuildContext context, List<Object> data) { Widget buildContent(BuildContext context, List<Object> data) {
return SmoothCustomScrollView( return SmoothCustomScrollView(
controller: widget.controller,
slivers: [ slivers: [
...buildSlivers(context, data), ...buildSlivers(context, data),
if (haveNextPage) const ListLoadingIndicator().toSliver() if (haveNextPage) const ListLoadingIndicator().toSliver()

View File

@@ -1,3 +1,5 @@
import 'package:venera/foundation/appdata.dart';
part of 'favorites_page.dart'; part of 'favorites_page.dart';
/// Open a dialog to create a new favorite folder. /// Open a dialog to create a new favorite folder.
@@ -83,7 +85,7 @@ void addFavorite(Comic comic) {
showDialog( showDialog(
context: App.rootContext, context: App.rootContext,
builder: (context) { builder: (context) {
String? selectedFolder; String? selectedFolder = appdata.settings['quickFavorite'];
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(

View File

@@ -1,7 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
@@ -11,9 +10,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';

View File

@@ -78,33 +78,7 @@ class _HistoryPageState extends State<HistoryPage> {
], ],
), ),
SliverGridComics( SliverGridComics(
comics: comics.map( comics: comics,
(e) {
var cover = e.cover;
if (!cover.isURL) {
var localComic = LocalManager().find(
e.id,
e.type,
);
if(localComic != null) {
cover = "file://${localComic.coverFile.path}";
}
}
return Comic(
e.title,
cover,
e.id,
e.subtitle,
null,
getDescription(e),
e.type == ComicType.local
? 'local'
: e.type.comicSource?.key ?? "Unknown:${e.type.value}",
null,
null,
);
},
).toList(),
badgeBuilder: (c) { badgeBuilder: (c) {
return ComicSource.find(c.sourceKey)?.name; return ComicSource.find(c.sourceKey)?.name;
}, },

View File

@@ -7,6 +7,8 @@ import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_page.dart';
@@ -264,21 +266,6 @@ class _HistoryState extends State<_History> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: history.length, itemCount: history.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
var cover = history[index].cover;
ImageProvider imageProvider = CachedImageProvider(
cover,
sourceKey: history[index].type.comicSource?.key,
cid: history[index].id,
);
if (!cover.isURL) {
var localComic = LocalManager().find(
history[index].id,
history[index].type,
);
if (localComic != null) {
imageProvider = FileImage(localComic.coverFile);
}
}
return InkWell( return InkWell(
onTap: () { onTap: () {
context.to( context.to(
@@ -301,7 +288,7 @@ class _HistoryState extends State<_History> {
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: AnimatedImage( child: AnimatedImage(
image: imageProvider, image: HistoryImageProvider(history[index]),
width: 96, width: 96,
height: 128, height: 128,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -418,8 +405,8 @@ class _LocalState extends State<_Local> {
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: AnimatedImage( child: AnimatedImage(
image: FileImage( image: LocalComicImageProvider(
local[index].coverFile, local[index],
), ),
width: 96, width: 96,
height: 128, height: 128,
@@ -511,7 +498,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
String info = [ String info = [
"Select a directory which contains the comic files.".tl, "Select a directory which contains the comic files.".tl,
"Select a directory which contains the comic directories.".tl, "Select a directory which contains the comic directories.".tl,
"Select a cbz file.".tl, "Select a cbz/zip file.".tl,
"Select an EhViewer database and a download folder.".tl "Select an EhViewer database and a download folder.".tl
][type]; ][type];
List<String> importMethods = [ List<String> importMethods = [

View File

@@ -4,9 +4,11 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/downloading_page.dart';
import 'package:venera/utils/cbz.dart'; import 'package:venera/utils/cbz.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/pdf.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
class LocalComicsPage extends StatefulWidget { class LocalComicsPage extends StatefulWidget {
@@ -299,8 +301,7 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
return ContentDialog( return ContentDialog(
title: "Delete".tl, title: "Delete".tl,
content: CheckboxListTile( content: CheckboxListTile(
title: title: Text("Also remove files on disk".tl),
Text("Also remove files on disk".tl),
value: removeComicFile, value: removeComicFile,
onChanged: (v) { onChanged: (v) {
state(() { state(() {
@@ -361,6 +362,34 @@ class _LocalComicsPageState extends State<LocalComicsPage> {
} }
controller.close(); controller.close();
}), }),
if (!multiSelectMode)
MenuEntry(
icon: Icons.picture_as_pdf_outlined,
text: "Export as pdf".tl,
onClick: () async {
var cache = FilePath.join(App.cachePath, 'temp.pdf');
var controller = showLoadingDialog(
context,
allowCancel: false,
);
try {
await createPdfFromComicIsolate(
comic: c as LocalComic,
savePath: cache,
);
await saveFile(
file: File(cache),
filename: "${c.title}.pdf",
);
} catch (e, s) {
Log.error("PDF Export", e, s);
context.showMessage(message: e.toString());
} finally {
controller.close();
File(cache).deleteIgnoreError();
}
},
)
]; ];
}, },
), ),

View File

@@ -103,6 +103,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
} }
void onMouseWheel(bool forward) { void onMouseWheel(bool forward) {
if (HardwareKeyboard.instance.isControlPressed) {
return;
}
if (context.reader.mode.key.startsWith('gallery')) { if (context.reader.mode.key.startsWith('gallery')) {
if (forward) { if (forward) {
if (!context.reader.toNextPage()) { if (!context.reader.toNextPage()) {

View File

@@ -83,7 +83,8 @@ class _ReaderImagesState extends State<_ReaderImages> {
); );
} else { } else {
if (reader.mode.isGallery) { if (reader.mode.isGallery) {
return _GalleryMode(key: Key(reader.mode.key)); return _GalleryMode(
key: Key('${reader.mode.key}_${reader.imagesPerPage}'));
} else { } else {
return _ContinuousMode(key: Key(reader.mode.key)); return _ContinuousMode(key: Key(reader.mode.key));
} }
@@ -110,6 +111,10 @@ class _GalleryModeState extends State<_GalleryMode>
late _ReaderState reader; late _ReaderState reader;
int get totalPages => ((reader.images!.length + reader.imagesPerPage - 1) /
reader.imagesPerPage)
.ceil();
@override @override
void initState() { void initState() {
reader = context.reader; reader = context.reader;
@@ -124,8 +129,14 @@ class _GalleryModeState extends State<_GalleryMode>
void cache(int current) { void cache(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) { for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= reader.maxPage && !cached[i]) { if (i <= totalPages && !cached[i]) {
_precacheImage(i, context); int startIndex = (i - 1) * reader.imagesPerPage;
int endIndex =
math.min(startIndex + reader.imagesPerPage, reader.images!.length);
for (int i = startIndex; i < endIndex; i++) {
precacheImage(
_createImageProviderFromKey(reader.images![i], context), context);
}
cached[i] = true; cached[i] = true;
} }
} }
@@ -141,32 +152,43 @@ class _GalleryModeState extends State<_GalleryMode>
scrollDirection: reader.mode == ReaderMode.galleryTopToBottom scrollDirection: reader.mode == ReaderMode.galleryTopToBottom
? Axis.vertical ? Axis.vertical
: Axis.horizontal, : Axis.horizontal,
itemCount: reader.images!.length + 2, itemCount: totalPages + 2,
builder: (BuildContext context, int index) { builder: (BuildContext context, int index) {
ImageProvider? imageProvider; if (index == 0 || index == totalPages + 1) {
if (index != 0 && index != reader.images!.length + 1) {
imageProvider = _createImageProvider(index, context);
} else {
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
scaleStateController: PhotoViewScaleStateController(), scaleStateController: PhotoViewScaleStateController(),
child: const SizedBox(), child: const SizedBox(),
); );
} } else {
int pageIndex = index - 1;
int startIndex = pageIndex * reader.imagesPerPage;
int endIndex = math.min(startIndex + reader.imagesPerPage, reader.images!.length);
List<String> pageImages = reader.images!.sublist(startIndex, endIndex);
cached[index] = true; cached[index] = true;
cache(index); cache(index);
photoViewControllers[index] ??= PhotoViewController(); photoViewControllers[index] = PhotoViewController();
if(reader.imagesPerPage == 1) {
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
controller: photoViewControllers[index], controller: photoViewControllers[index],
imageProvider: imageProvider, imageProvider: _createImageProviderFromKey(pageImages[0], context),
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, error, s, retry) { errorBuilder: (_, error, s, retry) {
return NetworkError(message: error.toString(), retry: retry); return NetworkError(message: error.toString(), retry: retry);
}, },
); );
}
return PhotoViewGalleryPageOptions.customChild(
controller: photoViewControllers[index],
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 10.0,
child: buildPageImages(pageImages),
);
}
}, },
pageController: controller, pageController: controller,
loadingBuilder: (context, event) => Center( loadingBuilder: (context, event) => Center(
@@ -186,9 +208,9 @@ class _GalleryModeState extends State<_GalleryMode>
if (!reader.toPrevChapter()) { if (!reader.toPrevChapter()) {
reader.toPage(1); reader.toPage(1);
} }
} else if (i == reader.maxPage + 1) { } else if (i == totalPages + 1) {
if (!reader.toNextChapter()) { if (!reader.toNextChapter()) {
reader.toPage(reader.maxPage); reader.toPage(totalPages);
} }
} else { } else {
reader.setPage(i); reader.setPage(i);
@@ -198,9 +220,30 @@ class _GalleryModeState extends State<_GalleryMode>
); );
} }
Widget buildPageImages(List<String> images) {
Axis axis = (reader.mode == ReaderMode.galleryTopToBottom)
? Axis.vertical
: Axis.horizontal;
List<Widget> imageWidgets = images.map((imageKey) {
ImageProvider imageProvider =
_createImageProviderFromKey(imageKey, context);
return Expanded(
child: Image(
image: imageProvider,
fit: BoxFit.contain,
),
);
}).toList();
return axis == Axis.vertical
? Column(children: imageWidgets)
: Row(children: imageWidgets);
}
@override @override
Future<void> animateToPage(int page) { Future<void> animateToPage(int page) {
if ((page - controller.page!).abs() > 1) { if ((page - controller.page!.round()).abs() > 1) {
controller.jumpToPage(page > controller.page! ? page - 1 : page + 1); controller.jumpToPage(page > controller.page! ? page - 1 : page + 1);
} }
return controller.animateToPage( return controller.animateToPage(
@@ -600,11 +643,26 @@ class _ContinuousModeState extends State<_ContinuousMode>
} }
} }
ImageProvider _createImageProviderFromKey(
String imageKey, BuildContext context) {
if (imageKey.startsWith('file://')) {
return FileImage(File(imageKey.replaceFirst("file://", '')));
} else {
var reader = context.reader;
return ReaderImageProvider(
imageKey,
reader.type.comicSource!.key,
reader.cid,
reader.eid,
);
}
}
ImageProvider _createImageProvider(int page, BuildContext context) { ImageProvider _createImageProvider(int page, BuildContext context) {
var reader = context.reader; var reader = context.reader;
var imageKey = reader.images![page - 1]; var imageKey = reader.images![page - 1];
if (imageKey.startsWith('file://')) { if (imageKey.startsWith('file://')) {
return FileImage(openFilePlatform(imageKey.replaceFirst("file://", ''))); return FileImage(File(imageKey.replaceFirst("file://", '')));
} else { } else {
return ReaderImageProvider( return ReaderImageProvider(
imageKey, imageKey,

View File

@@ -1,6 +1,7 @@
library venera_reader; library venera_reader;
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
@@ -82,7 +83,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} }
@override @override
int get maxPage => images?.length ?? 1; int get maxPage =>
((images?.length ?? 1) + imagesPerPage - 1) ~/ imagesPerPage;
ComicType get type => widget.type; ComicType get type => widget.type;
@@ -94,6 +96,30 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
late ReaderMode mode; late ReaderMode mode;
int get imagesPerPage => appdata.settings['readerScreenPicNumber'] ?? 1;
int _lastImagesPerPage = appdata.settings['readerScreenPicNumber'] ?? 1;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_checkImagesPerPageChange();
}
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
}
}
void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
}
History? history; History? history;
@override @override
@@ -133,6 +159,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_checkImagesPerPageChange();
return KeyboardListener( return KeyboardListener(
focusNode: focusNode, focusNode: focusNode,
autofocus: true, autofocus: true,

View File

@@ -469,7 +469,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
ImageProvider image; ImageProvider image;
var imageKey = images[index]; var imageKey = images[index];
if (imageKey.startsWith('file://')) { if (imageKey.startsWith('file://')) {
image = FileImage(openFilePlatform(imageKey.replaceFirst("file://", ''))); image = FileImage(File(imageKey.replaceFirst("file://", '')));
} else { } else {
image = ReaderImageProvider( image = ReaderImageProvider(
imageKey, imageKey,
@@ -515,7 +515,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
} }
} }
if (imageKey.startsWith("file://")) { if (imageKey.startsWith("file://")) {
return await openFilePlatform(imageKey.substring(7)).readAsBytes(); return await File(imageKey.substring(7)).readAsBytes();
} else { } else {
return (await CacheManager().findCache( return (await CacheManager().findCache(
"$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))!

View File

@@ -68,6 +68,13 @@ class _AboutSettingsState extends State<AboutSettings> {
launchUrlString("https://github.com/venera-app/venera"); launchUrlString("https://github.com/venera-app/venera");
}, },
).toSliver(), ).toSliver(),
ListTile(
title: const Text("Telegram"),
trailing: const Icon(Icons.open_in_new),
onTap: () {
launchUrlString("https://t.me/venera_release");
},
).toSliver(),
], ],
); );
} }

View File

@@ -34,25 +34,8 @@ class _AppSettingsState extends State<AppSettings> {
callback: () async { callback: () async {
String? result; String? result;
if (App.isAndroid) { if (App.isAndroid) {
var channel = const MethodChannel("venera/storage"); var picker = DirectoryPicker();
var permission = await channel.invokeMethod(''); result = (await picker.pickDirectory())?.path;
if (permission != true) {
context.showMessage(message: "Permission denied".tl);
return;
}
var path = await selectDirectory();
if (path != null) {
// check if the path is writable
var testFile = File(FilePath.join(path, "test"));
try {
await testFile.writeAsBytes([1]);
await testFile.delete();
} catch (e) {
context.showMessage(message: "Permission denied".tl);
return;
}
result = path;
}
} else if (App.isIOS) { } else if (App.isIOS) {
result = await selectDirectoryIOS(); result = await selectDirectoryIOS();
} else { } else {
@@ -127,16 +110,23 @@ class _AppSettingsState extends State<AppSettings> {
title: "Import App Data".tl, title: "Import App Data".tl,
callback: () async { callback: () async {
var controller = showLoadingDialog(context); var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera']); var file = await selectFile(ext: ['venera', 'picadata']);
if (file != null) { if (file != null) {
var cacheFile = File(FilePath.join(App.cachePath, "temp.venera")); var cacheFile = File(FilePath.join(App.cachePath, "import_data_temp"));
await file.saveTo(cacheFile.path); await file.saveTo(cacheFile.path);
try { try {
if(file.name.endsWith('picadata')) {
await importPicaData(cacheFile);
} else {
await importAppData(cacheFile); await importAppData(cacheFile);
}
} catch (e, s) { } catch (e, s) {
Log.error("Import data", e.toString(), s); Log.error("Import data", e.toString(), s);
context.showMessage(message: "Failed to import data".tl); context.showMessage(message: "Failed to import data".tl);
} }
finally {
cacheFile.deleteIgnoreError();
}
} }
controller.close(); controller.close();
}, },

View File

@@ -41,6 +41,11 @@ class _ReaderSettingsState extends State<ReaderSettings> {
"continuousTopToBottom": "Continuous (Top to Bottom)".tl, "continuousTopToBottom": "Continuous (Top to Bottom)".tl,
}, },
onChanged: () { onChanged: () {
var readerMode = appdata.settings['readerMode'];
if (readerMode?.toLowerCase().startsWith('continuous') ?? false) {
appdata.settings['readerScreenPicNumber'] = 1;
widget.onChanged?.call('readerScreenPicNumber');
}
widget.onChanged?.call("readerMode"); widget.onChanged?.call("readerMode");
}, },
).toSliver(), ).toSliver(),
@@ -54,6 +59,25 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call("autoPageTurningInterval"); widget.onChanged?.call("autoPageTurningInterval");
}, },
).toSliver(), ).toSliver(),
SliverToBoxAdapter(
child: AbsorbPointer(
absorbing: (appdata.settings['readerMode']?.toLowerCase().startsWith('continuous') ?? false),
child: AnimatedOpacity(
opacity: (appdata.settings['readerMode']?.toLowerCase().startsWith('continuous') ?? false) ? 0.5 : 1.0,
duration: Duration(milliseconds: 300),
child: _SliderSetting(
title: "The number of pic in screen (Only Gallery Mode)".tl,
settingsIndex: "readerScreenPicNumber",
interval: 1,
min: 1,
max: 5,
onChanged: () {
widget.onChanged?.call("readerScreenPicNumber");
},
),
),
),
),
_SwitchSetting( _SwitchSetting(
title: 'Long press to zoom'.tl, title: 'Long press to zoom'.tl,
settingKey: 'enableLongPressToZoom', settingKey: 'enableLongPressToZoom',

View File

@@ -178,8 +178,9 @@ class _SettingsPageState extends State<SettingsPage> implements PopEntry {
Positioned.fill(child: buildLeft()), Positioned.fill(child: buildLeft()),
Positioned( Positioned(
left: offset, left: offset,
width: MediaQuery.of(context).size.width, right: 0,
height: MediaQuery.of(context).size.height, top: 0,
bottom: 0,
child: Listener( child: Listener(
onPointerDown: handlePointerDown, onPointerDown: handlePointerDown,
child: AnimatedSwitcher( child: AnimatedSwitcher(

View File

@@ -104,14 +104,14 @@ abstract class CBZ {
FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)), FilePath.join(LocalManager().path, sanitizeFileName(metaData.title)),
); );
dest.createSync(); dest.createSync();
coverFile.copy( coverFile.copyMem(
FilePath.join(dest.path, 'cover.${coverFile.path.split('.').last}')); FilePath.join(dest.path, 'cover.${coverFile.extension}'));
if (metaData.chapters == null) { if (metaData.chapters == null) {
for (var i = 0; i < files.length; i++) { for (var i = 0; i < files.length; i++) {
var src = files[i]; var src = files[i];
var dst = File( var dst = File(
FilePath.join(dest.path, '${i + 1}.${src.path.split('.').last}')); FilePath.join(dest.path, '${i + 1}.${src.path.split('.').last}'));
await src.copy(dst.path); await src.copyMem(dst.path);
} }
} else { } else {
dest.createSync(); dest.createSync();
@@ -129,7 +129,7 @@ abstract class CBZ {
var src = chapter.value[i]; var src = chapter.value[i];
var dst = File(FilePath.join( var dst = File(FilePath.join(
chapterDir.path, '${i + 1}.${src.path.split('.').last}')); chapterDir.path, '${i + 1}.${src.path.split('.').last}'));
await src.copy(dst.path); await src.copyMem(dst.path);
} }
} }
} }
@@ -142,10 +142,9 @@ abstract class CBZ {
directory: dest.name, directory: dest.name,
chapters: cpMap, chapters: cpMap,
downloadedChapters: cpMap?.keys.toList() ?? [], downloadedChapters: cpMap?.keys.toList() ?? [],
cover: 'cover.${coverFile.path.split('.').last}', cover: 'cover.${coverFile.extension}',
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
LocalManager().add(comic);
await cache.delete(recursive: true); await cache.delete(recursive: true);
return comic; return comic;
} }
@@ -164,7 +163,7 @@ abstract class CBZ {
var dstName = var dstName =
'${i.toString().padLeft(width, '0')}.${image.split('.').last}'; '${i.toString().padLeft(width, '0')}.${image.split('.').last}';
var dst = File(FilePath.join(cache.path, dstName)); var dst = File(FilePath.join(cache.path, dstName));
await src.copy(dst.path); await src.copyMem(dst.path);
i++; i++;
} }
} else { } else {
@@ -187,18 +186,18 @@ abstract class CBZ {
} }
int i = 1; int i = 1;
for (var image in allImages) { for (var image in allImages) {
var src = openFilePlatform(image); var src = File(image);
var width = allImages.length.toString().length; var width = allImages.length.toString().length;
var dstName = var dstName =
'${i.toString().padLeft(width, '0')}.${image.split('.').last}'; '${i.toString().padLeft(width, '0')}.${image.split('.').last}';
var dst = File(FilePath.join(cache.path, dstName)); var dst = File(FilePath.join(cache.path, dstName));
await src.copy(dst.path); await src.copyMem(dst.path);
i++; i++;
} }
} }
var cover = comic.coverFile; var cover = comic.coverFile;
await cover await cover
.copy(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}')); .copyMem(FilePath.join(cache.path, 'cover.${cover.path.split('.').last}'));
await File(FilePath.join(cache.path, 'metadata.json')).writeAsString( await File(FilePath.join(cache.path, 'metadata.json')).writeAsString(
jsonEncode( jsonEncode(
ComicMetaData( ComicMetaData(

View File

@@ -1,11 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate'; import 'dart:isolate';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'package:zip_flutter/zip_flutter.dart'; import 'package:zip_flutter/zip_flutter.dart';
@@ -43,6 +46,11 @@ Future<File> exportAppData() async {
Future<void> importAppData(File file, [bool checkVersion = false]) async { Future<void> importAppData(File file, [bool checkVersion = false]) async {
var cacheDirPath = FilePath.join(App.cachePath, 'temp_data'); var cacheDirPath = FilePath.join(App.cachePath, 'temp_data');
var cacheDir = Directory(cacheDirPath); var cacheDir = Directory(cacheDirPath);
if (cacheDir.existsSync()) {
cacheDir.deleteSync(recursive: true);
}
cacheDir.createSync();
try {
await Isolate.run(() { await Isolate.run(() {
ZipFile.openAndExtract(file.path, cacheDirPath); ZipFile.openAndExtract(file.path, cacheDirPath);
}); });
@@ -65,7 +73,8 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
} }
if (await localFavoriteFile.exists()) { if (await localFavoriteFile.exists()) {
LocalFavoritesManager().close(); LocalFavoritesManager().close();
File(FilePath.join(App.dataPath, "local_favorite.db")).deleteIfExistsSync(); File(FilePath.join(App.dataPath, "local_favorite.db"))
.deleteIfExistsSync();
localFavoriteFile localFavoriteFile
.renameSync(FilePath.join(App.dataPath, "local_favorite.db")); .renameSync(FilePath.join(App.dataPath, "local_favorite.db"));
LocalFavoritesManager().init(); LocalFavoritesManager().init();
@@ -93,11 +102,109 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
if (Directory(comicSourceDir).existsSync()) { if (Directory(comicSourceDir).existsSync()) {
for (var file in Directory(comicSourceDir).listSync()) { for (var file in Directory(comicSourceDir).listSync()) {
if (file is File) { if (file is File) {
var targetFile = FilePath.join(App.dataPath, "comic_source", file.name); var targetFile =
FilePath.join(App.dataPath, "comic_source", file.name);
File(targetFile).deleteIfExistsSync(); File(targetFile).deleteIfExistsSync();
await file.copy(targetFile); await file.copy(targetFile);
} }
} }
await ComicSource.reload(); await ComicSource.reload();
} }
} finally {
cacheDir.deleteIgnoreError(recursive: true);
}
}
Future<void> importPicaData(File file) async {
var cacheDirPath = FilePath.join(App.cachePath, 'temp_data');
var cacheDir = Directory(cacheDirPath);
if (cacheDir.existsSync()) {
cacheDir.deleteSync(recursive: true);
}
cacheDir.createSync();
try {
await Isolate.run(() {
ZipFile.openAndExtract(file.path, cacheDirPath);
});
var localFavoriteFile = cacheDir.joinFile("local_favorite.db");
if (localFavoriteFile.existsSync()) {
var db = sqlite3.open(localFavoriteFile.path);
try {
var folderNames = db
.select("SELECT name FROM sqlite_master WHERE type='table';")
.map((e) => e["name"] as String)
.toList();
folderNames.removeWhere((e) => e == "folder_order" || e == "folder_sync");
for (var folderName in folderNames) {
if (!LocalFavoritesManager().existsFolder(folderName)) {
LocalFavoritesManager().createFolder(folderName);
}
for (var comic in db.select("SELECT * FROM \"$folderName\";")) {
LocalFavoritesManager().addComic(
folderName,
FavoriteItem(
id: comic['target'],
name: comic['name'],
coverPath: comic['cover_path'],
author: comic['author'],
type: ComicType(switch(comic['type']) {
0 => 'picacg'.hashCode,
1 => 'ehentai'.hashCode,
2 => 'jm'.hashCode,
3 => 'hitomi'.hashCode,
4 => 'wnacg'.hashCode,
6 => 'nhentai'.hashCode,
_ => comic['type']
}),
tags: comic['tags'].split(','),
),
);
}
}
}
catch(e) {
Log.error("Import Data", "Failed to import local favorite: $e");
}
finally {
db.dispose();
}
}
var historyFile = cacheDir.joinFile("history.db");
if (historyFile.existsSync()) {
var db = sqlite3.open(historyFile.path);
try {
for (var comic in db.select("SELECT * FROM history;")) {
HistoryManager().addHistory(
History.fromMap({
"type": switch(comic['type']) {
0 => 'picacg'.hashCode,
1 => 'ehentai'.hashCode,
2 => 'jm'.hashCode,
3 => 'hitomi'.hashCode,
4 => 'wnacg'.hashCode,
6 => 'nhentai'.hashCode,
_ => comic['type']
},
"id": comic['target'],
"maxPage": comic["max_page"],
"ep": comic["ep"],
"page": comic["page"],
"time": comic["time"],
"title": comic["title"],
"subtitle": comic["subtitle"],
"cover": comic["cover"],
}),
);
}
}
catch(e) {
Log.error("Import Data", "Failed to import history: $e");
}
finally {
db.dispose();
}
}
} finally {
cacheDir.deleteIgnoreError(recursive: true);
}
} }

View File

@@ -13,7 +13,7 @@ class FileType {
var mime = lookupMimeType('no-file.$ext') ?? 'application/octet-stream'; var mime = lookupMimeType('no-file.$ext') ?? 'application/octet-stream';
// Android doesn't support some mime types // Android doesn't support some mime types
mime = switch(mime) { mime = switch(mime) {
'text/javascript' => 'application/javascript', 'text/javascript' => 'application/octet-stream',
'application/x-cbr' => 'application/octet-stream', 'application/x-cbr' => 'application/octet-stream',
_ => mime, _ => mime,
}; };

View File

@@ -20,7 +20,7 @@ class ImportComic {
const ImportComic({this.selectedFolder, this.copyToLocal = true}); const ImportComic({this.selectedFolder, this.copyToLocal = true});
Future<bool> cbz() async { Future<bool> cbz() async {
var file = await selectFile(ext: ['cbz']); var file = await selectFile(ext: ['cbz', 'zip']);
Map<String?, List<LocalComic>> imported = {}; Map<String?, List<LocalComic>> imported = {};
if (file == null) { if (file == null) {
return false; return false;
@@ -34,7 +34,7 @@ class ImportComic {
App.rootContext.showMessage(message: e.toString()); App.rootContext.showMessage(message: e.toString());
} }
controller.close(); controller.close();
return registerComics(imported, true); return registerComics(imported, false);
} }
Future<bool> ehViewer() async { Future<bool> ehViewer() async {
@@ -60,7 +60,7 @@ class ImportComic {
if (cancelled) { if (cancelled) {
return imported; return imported;
} }
var comicDir = openDirectoryPlatform( var comicDir = Directory(
FilePath.join(comicSrc.path, comic['DIRNAME'] as String)); FilePath.join(comicSrc.path, comic['DIRNAME'] as String));
String titleJP = String titleJP =
comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String; comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String;
@@ -105,8 +105,7 @@ class ImportComic {
if (cancelled) { if (cancelled) {
break; break;
} }
var folderName = var folderName = tag == '' ? '(EhViewer)Default'.tl : '(EhViewer)$tag';
tag == '' ? '(EhViewer)Default'.tl : '(EhViewer)$tag';
var comicList = db.select(""" var comicList = db.select("""
SELECT * SELECT *
FROM DOWNLOAD_DIRNAME DN FROM DOWNLOAD_DIRNAME DN
@@ -176,8 +175,7 @@ class ImportComic {
String? title, String? title,
String? subtitle, String? subtitle,
List<String>? tags, List<String>? tags,
DateTime? createTime}) DateTime? createTime}) async {
async {
if (!(await directory.exists())) return null; if (!(await directory.exists())) return null;
var name = title ?? directory.name; var name = title ?? directory.name;
if (LocalManager().findByName(name) != null) { if (LocalManager().findByName(name) != null) {
@@ -212,12 +210,13 @@ class ImportComic {
} }
fileList.sort(); fileList.sort();
coverPath = fileList.firstWhereOrNull((l) => l.startsWith('cover')) ?? fileList.first; coverPath = fileList.firstWhereOrNull((l) => l.startsWith('cover')) ??
fileList.first;
chapters.sort(); chapters.sort();
if (hasChapters && coverPath == '') { if (hasChapters && coverPath == '') {
// use the first image in the first chapter as the cover // use the first image in the first chapter as the cover
var firstChapter = openDirectoryPlatform('${directory.path}/${chapters.first}'); var firstChapter = Directory('${directory.path}/${chapters.first}');
await for (var entry in firstChapter.list()) { await for (var entry in firstChapter.list()) {
if (entry is File) { if (entry is File) {
coverPath = entry.name; coverPath = entry.name;
@@ -243,19 +242,21 @@ class ImportComic {
); );
} }
static Future<Map<String, String>> _copyDirectories(Map<String, dynamic> data) async { static Future<Map<String, String>> _copyDirectories(
Map<String, dynamic> data) async {
return overrideIO(() async {
var toBeCopied = data['toBeCopied'] as List<String>; var toBeCopied = data['toBeCopied'] as List<String>;
var destination = data['destination'] as String; var destination = data['destination'] as String;
Map<String, String> result = {}; Map<String, String> result = {};
for (var dir in toBeCopied) { for (var dir in toBeCopied) {
var source = openDirectoryPlatform(dir); var source = Directory(dir);
var dest = openDirectoryPlatform("$destination/${source.name}"); var dest = Directory("$destination/${source.name}");
if (dest.existsSync()) { if (dest.existsSync()) {
// The destination directory already exists, and it is not managed by the app. // The destination directory already exists, and it is not managed by the app.
// Rename the old directory to avoid conflicts. // Rename the old directory to avoid conflicts.
Log.info("Import Comic", Log.info("Import Comic",
"Directory already exists: ${source.name}\nRenaming the old directory."); "Directory already exists: ${source.name}\nRenaming the old directory.");
await dest.rename( dest.renameSync(
findValidDirectoryName(dest.parent.path, "${dest.path}_old")); findValidDirectoryName(dest.parent.path, "${dest.path}_old"));
} }
dest.createSync(); dest.createSync();
@@ -263,6 +264,7 @@ class ImportComic {
result[source.path] = dest.path; result[source.path] = dest.path;
} }
return result; return result;
});
} }
Future<Map<String?, List<LocalComic>>> _copyComicsToLocalDir( Future<Map<String?, List<LocalComic>>> _copyComicsToLocalDir(
@@ -284,13 +286,13 @@ class ImportComic {
// copy the comics to the local directory // copy the comics to the local directory
var pathMap = await compute<Map<String, dynamic>, Map<String, String>>( var pathMap = await compute<Map<String, dynamic>, Map<String, String>>(
_copyDirectories, { _copyDirectories, {
'toBeCopied': comics[favoriteFolder]!.map((e) => e.directory).toList(), 'toBeCopied':
comics[favoriteFolder]!.map((e) => e.directory).toList(),
'destination': destPath, 'destination': destPath,
}); });
//Construct a new object since LocalComic.directory is a final String //Construct a new object since LocalComic.directory is a final String
for (var c in comics[favoriteFolder]!) { for (var c in comics[favoriteFolder]!) {
result[favoriteFolder]!.add( result[favoriteFolder]!.add(LocalComic(
LocalComic(
id: c.id, id: c.id,
title: c.title, title: c.title,
subtitle: c.subtitle, subtitle: c.subtitle,
@@ -300,20 +302,20 @@ class ImportComic {
cover: c.cover, cover: c.cover,
comicType: c.comicType, comicType: c.comicType,
downloadedChapters: c.downloadedChapters, downloadedChapters: c.downloadedChapters,
createdAt: c.createdAt createdAt: c.createdAt,
) ));
);
} }
} catch (e) { } catch (e, s) {
App.rootContext.showMessage(message: "Failed to copy comics".tl); App.rootContext.showMessage(message: "Failed to copy comics".tl);
Log.error("Import Comic", e.toString()); Log.error("Import Comic", e.toString(), s);
return result; return result;
} }
} }
return result; return result;
} }
Future<bool> registerComics(Map<String?, List<LocalComic>> importedComics, bool copy) async { Future<bool> registerComics(
Map<String?, List<LocalComic>> importedComics, bool copy) async {
try { try {
if (copy) { if (copy) {
importedComics = await _copyComicsToLocalDir(importedComics); importedComics = await _copyComicsToLocalDir(importedComics);
@@ -334,9 +336,7 @@ class ImportComic {
author: comic.subtitle, author: comic.subtitle,
type: comic.comicType, type: comic.comicType,
tags: comic.tags, tags: comic.tags,
favoriteTime: comic.createdAt favoriteTime: comic.createdAt));
)
);
} }
} }
} }
@@ -344,9 +344,9 @@ class ImportComic {
message: "Imported @a comics".tlParams({ message: "Imported @a comics".tlParams({
'a': importedCount, 'a': importedCount,
})); }));
} catch(e) { } catch (e, s) {
App.rootContext.showMessage(message: "Failed to register comics".tl); App.rootContext.showMessage(message: "Failed to register comics".tl);
Log.error("Import Comic", e.toString()); Log.error("Import Comic", e.toString(), s);
return false; return false;
} }
return true; return true;

View File

@@ -73,6 +73,15 @@ extension FileSystemEntityExt on FileSystemEntity {
extension FileExtension on File { extension FileExtension on File {
String get extension => path.split('.').last; String get extension => path.split('.').last;
/// Copy the file to the specified path using memory.
///
/// This method prevents errors caused by files from different file systems.
Future<void> copyMem(String newPath) async {
var newFile = File(newPath);
// Stream is not usable since [AndroidFile] does not support [openRead].
await newFile.writeAsBytes(await readAsBytes());
}
} }
extension DirectoryExtension on Directory { extension DirectoryExtension on Directory {
@@ -81,7 +90,7 @@ extension DirectoryExtension on Directory {
int total = 0; int total = 0;
for (var f in listSync(recursive: true)) { for (var f in listSync(recursive: true)) {
if (FileSystemEntity.typeSync(f.path) == FileSystemEntityType.file) { if (FileSystemEntity.typeSync(f.path) == FileSystemEntityType.file) {
total += await openFilePlatform(f.path).length(); total += await File(f.path).length();
} }
} }
return total; return total;
@@ -93,7 +102,21 @@ extension DirectoryExtension on Directory {
} }
File joinFile(String name) { File joinFile(String name) {
return openFilePlatform(FilePath.join(path, name)); return File(FilePath.join(path, name));
}
void deleteContentsSync({recursive = true}) {
if (!existsSync()) return;
for (var f in listSync()) {
f.deleteIfExistsSync(recursive: recursive);
}
}
Future<void> deleteContents({recursive = true}) async {
if (!existsSync()) return;
for (var f in listSync()) {
await f.deleteIfExists(recursive: recursive);
}
} }
} }
@@ -124,14 +147,15 @@ String sanitizeFileName(String fileName) {
Future<void> copyDirectory(Directory source, Directory destination) async { Future<void> copyDirectory(Directory source, Directory destination) async {
List<FileSystemEntity> contents = source.listSync(); List<FileSystemEntity> contents = source.listSync();
for (FileSystemEntity content in contents) { for (FileSystemEntity content in contents) {
String newPath = destination.path + String newPath = FilePath.join(destination.path, content.name);
Platform.pathSeparator +
content.path.split(Platform.pathSeparator).last;
if (content is File) { if (content is File) {
content.copySync(newPath); var resultFile = File(newPath);
resultFile.createSync();
var data = content.readAsBytesSync();
resultFile.writeAsBytesSync(data);
} else if (content is Directory) { } else if (content is Directory) {
Directory newDirectory = openDirectoryPlatform(newPath); Directory newDirectory = Directory(newPath);
newDirectory.createSync(); newDirectory.createSync();
copyDirectory(content.absolute, newDirectory.absolute); copyDirectory(content.absolute, newDirectory.absolute);
} }
@@ -140,18 +164,16 @@ Future<void> copyDirectory(Directory source, Directory destination) async {
Future<void> copyDirectoryIsolate( Future<void> copyDirectoryIsolate(
Directory source, Directory destination) async { Directory source, Directory destination) async {
await Isolate.run(() { await Isolate.run(() => overrideIO(() => copyDirectory(source, destination)));
copyDirectory(source, destination);
});
} }
String findValidDirectoryName(String path, String directory) { String findValidDirectoryName(String path, String directory) {
var name = sanitizeFileName(directory); var name = sanitizeFileName(directory);
var dir = openDirectoryPlatform("$path/$name"); var dir = Directory("$path/$name");
var i = 1; var i = 1;
while (dir.existsSync() && dir.listSync().isNotEmpty) { while (dir.existsSync() && dir.listSync().isNotEmpty) {
name = sanitizeFileName("$directory($i)"); name = sanitizeFileName("$directory($i)");
dir = openDirectoryPlatform("$path/$name"); dir = Directory("$path/$name");
i++; i++;
} }
return name; return name;
@@ -184,11 +206,12 @@ class DirectoryPicker {
directory = (await AndroidDirectory.pickDirectory())?.path; directory = (await AndroidDirectory.pickDirectory())?.path;
} else { } else {
// ios, macos // ios, macos
directory = await _methodChannel.invokeMethod<String?>("getDirectoryPath"); directory =
await _methodChannel.invokeMethod<String?>("getDirectoryPath");
} }
if (directory == null) return null; if (directory == null) return null;
_finalizer.attach(this, directory); _finalizer.attach(this, directory);
return openDirectoryPlatform(directory); return Directory(directory);
} finally { } finally {
Future.delayed(const Duration(milliseconds: 100), () { Future.delayed(const Duration(milliseconds: 100), () {
IO._isSelectingFiles = false; IO._isSelectingFiles = false;
@@ -249,7 +272,9 @@ Future<FileSelectResult?> selectFile({required List<String> ext}) async {
file = FileSelectResult(xFile.path); file = FileSelectResult(xFile.path);
} }
if (!ext.contains(file.path.split(".").last)) { if (!ext.contains(file.path.split(".").last)) {
App.rootContext.showMessage(message: "Invalid file type"); App.rootContext.showMessage(
message: "Invalid file type: ${file.path.split(".").last}",
);
return null; return null;
} }
return file; return file;
@@ -311,33 +336,45 @@ Future<void> saveFile(
} }
} }
Directory openDirectoryPlatform(String path) { class _IOOverrides extends IOOverrides {
@override
Directory createDirectory(String path) {
if (App.isAndroid) { if (App.isAndroid) {
var dir = AndroidDirectory.fromPathSync(path); var dir = AndroidDirectory.fromPathSync(path);
if (dir == null) { if (dir == null) {
return Directory(path); return super.createDirectory(path);
} }
return dir; return dir;
} else { } else {
return Directory(path); return super.createDirectory(path);
} }
} }
File openFilePlatform(String path) { @override
File createFile(String path) {
if (path.startsWith("file://")) { if (path.startsWith("file://")) {
path = path.substring(7); path = path.substring(7);
} }
if (App.isAndroid) { if (App.isAndroid) {
var f = AndroidFile.fromPathSync(path); var f = AndroidFile.fromPathSync(path);
if (f == null) { if (f == null) {
return File(path); return super.createFile(path);
} }
return f; return f;
} else { } else {
return File(path); return super.createFile(path);
} }
} }
}
T overrideIO<T>(T Function() f) {
return IOOverrides.runWithIOOverrides<T>(
f,
_IOOverrides(),
);
}
class Share { class Share {
static void shareFile({ static void shareFile({
required Uint8List data, required Uint8List data,

92
lib/utils/pdf.dart Normal file
View File

@@ -0,0 +1,92 @@
import 'dart:isolate';
import 'package:pdf/widgets.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/utils/io.dart';
Future<void> _createPdfFromComic({
required LocalComic comic,
required String savePath,
required String localPath,
}) async {
final pdf = Document(
title: comic.title,
author: comic.subTitle ?? "",
producer: "Venera",
);
pdf.document.outline;
var baseDir = comic.directory.contains('/') || comic.directory.contains('\\')
? comic.directory
: FilePath.join(localPath, comic.directory);
// add cover
var imageData = File(FilePath.join(baseDir, comic.cover)).readAsBytesSync();
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
bool multiChapters = comic.chapters != null;
void reorderFiles(List<FileSystemEntity> files) {
files.removeWhere(
(element) => element is! File || element.path.startsWith('cover'));
files.sort((a, b) {
var aName = (a as File).name;
var bName = (b as File).name;
var aNumber = int.tryParse(aName);
var bNumber = int.tryParse(bName);
if (aNumber != null && bNumber != null) {
return aNumber.compareTo(bNumber);
}
return aName.compareTo(bName);
});
}
if (!multiChapters) {
var files = Directory(baseDir).listSync();
reorderFiles(files);
for (var file in files) {
var imageData = (file as File).readAsBytesSync();
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
}
} else {
for (var chapter in comic.chapters!.keys) {
var files = Directory(FilePath.join(baseDir, chapter)).listSync();
reorderFiles(files);
for (var file in files) {
var imageData = (file as File).readAsBytesSync();
pdf.addPage(Page(
build: (Context context) {
return Image(MemoryImage(imageData), fit: BoxFit.contain);
},
));
}
}
}
final file = File(savePath);
file.writeAsBytesSync(await pdf.save());
}
Future<void> createPdfFromComicIsolate({
required LocalComic comic,
required String savePath,
}) async {
var localPath = LocalManager().path;
return Isolate.run(() => overrideIO(() async {
return await _createPdfFromComic(
comic: comic,
savePath: savePath,
localPath: localPath,
);
}));
}

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +57,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003
url: "https://pub.dev"
source: hosted
version: "2.2.8"
battery_plus: battery_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -65,6 +81,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
bidi:
dependency: transitive
description:
name: bidi
sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d"
url: "https://pub.dev"
source: hosted
version: "2.0.12"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -393,8 +417,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "829a566b738a26ea98e523807f49838e21308543" ref: "3315082b9f7055655610e4f6f136b69e48228c05"
resolved-ref: "829a566b738a26ea98e523807f49838e21308543" resolved-ref: "3315082b9f7055655610e4f6f136b69e48228c05"
url: "https://github.com/pkuislm/flutter_saf.git" url: "https://github.com/pkuislm/flutter_saf.git"
source: git source: git
version: "0.0.1" version: "0.0.1"
@@ -465,6 +489,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.2" version: "4.0.2"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -626,6 +658,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -674,6 +714,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdf:
dependency: "direct main"
description:
name: pdf
sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07"
url: "https://pub.dev"
source: hosted
version: "3.11.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -715,6 +763,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.9.1" version: "3.9.1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
rhttp: rhttp:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -2,7 +2,7 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.0.7+107 version: 1.0.8+108
environment: environment:
sdk: '>=3.5.0 <4.0.0' sdk: '>=3.5.0 <4.0.0'
@@ -68,7 +68,8 @@ dependencies:
flutter_saf: flutter_saf:
git: git:
url: https://github.com/pkuislm/flutter_saf.git url: https://github.com/pkuislm/flutter_saf.git
ref: dd5242918da0ea9a0a50b0f87ade7a2def65453d ref: 3315082b9f7055655610e4f6f136b69e48228c05
pdf: ^3.11.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: