From eb3a7f9d52e8885ac849de753e7766e5999962f2 Mon Sep 17 00:00:00 2001 From: AnxuNA <41771421+axlmly@users.noreply.github.com> Date: Mon, 18 Nov 2024 21:27:47 +0800 Subject: [PATCH 01/28] add Chapter && Page translate (#54) add Chapter && Page translate --- assets/translation.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/assets/translation.json b/assets/translation.json index d2dc8bc..f56d9f9 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -229,7 +229,9 @@ "No Explore Pages": "没有探索页面", "Add a comic source in home page": "在主页添加一个漫画源", "Please check your settings": "请检查您的设置", - "No Category Pages": "没有分类页面" + "No Category Pages": "没有分类页面", + "Chapter @ep": "第 @ep 章", + "Page @page": "第 @page 页" }, "zh_TW": { "Home": "首頁", @@ -461,6 +463,8 @@ "No Explore Pages": "沒有探索頁面", "Add a comic source in home page": "在主頁添加一個漫畫源", "Please check your settings": "請檢查您的設定", - "No Category Pages": "沒有分類頁面" + "No Category Pages": "沒有分類頁面", + "Chapter @ep": "第 @ep 章", + "Page @page": "第 @page 頁" } } \ No newline at end of file From ed67bc80ea6d46d7e868df0bc56262b9c8108397 Mon Sep 17 00:00:00 2001 From: nyne Date: Mon, 18 Nov 2024 22:21:53 +0800 Subject: [PATCH 02/28] fix windows webview --- lib/pages/webview.dart | 67 +++++++++++++++++++++++++++++------------- windows/.gitignore | 1 + 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/lib/pages/webview.dart b/lib/pages/webview.dart index fdc385e..e0c6349 100644 --- a/lib/pages/webview.dart +++ b/lib/pages/webview.dart @@ -70,6 +70,8 @@ class AppWebview extends StatefulWidget { final bool singlePage; + static WebViewEnvironment? webViewEnvironment; + @override State createState() => _AppWebviewState(); } @@ -117,7 +119,50 @@ class _AppWebviewState extends State { ) ]; - Widget body = InAppWebView( + Widget body = (App.isWindows && AppWebview.webViewEnvironment == null) + ? FutureBuilder( + future: WebViewEnvironment.create( + settings: WebViewEnvironmentSettings( + userDataFolder: "${App.dataPath}\\webview", + ), + ), + builder: (context, e) { + if(e.error != null) { + return Center(child: Text("Error: ${e.error}")); + } + if(e.data == null) { + return const Center(child: CircularProgressIndicator()); + } + AppWebview.webViewEnvironment = e.data; + return createWebviewWithEnvironment(AppWebview.webViewEnvironment); + }, + ) + : createWebviewWithEnvironment(AppWebview.webViewEnvironment); + + body = Stack( + children: [ + Positioned.fill(child: body), + if (_progress < 1.0) + const Positioned.fill( + child: Center(child: CircularProgressIndicator())) + ], + ); + + return Scaffold( + appBar: Appbar( + title: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + actions: actions, + ), + body: body); + } + + Widget createWebviewWithEnvironment(WebViewEnvironment? e) { + return InAppWebView( + webViewEnvironment: e, initialSettings: InAppWebViewSettings( isInspectable: true, ), @@ -155,26 +200,6 @@ class _AppWebviewState extends State { } }, ); - - body = Stack( - children: [ - Positioned.fill(child: body), - if (_progress < 1.0) - const Positioned.fill( - child: Center(child: CircularProgressIndicator())) - ], - ); - - return Scaffold( - appBar: Appbar( - title: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - actions: actions, - ), - body: body); } } diff --git a/windows/.gitignore b/windows/.gitignore index d492d0d..8a4aa2a 100644 --- a/windows/.gitignore +++ b/windows/.gitignore @@ -15,3 +15,4 @@ x86/ *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ +/ChineseSimplified.isl From 8402c1c9f32d8db6c62105c12ea0144b94795729 Mon Sep 17 00:00:00 2001 From: Pacalini <141402887+Pacalini@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:01:35 +0800 Subject: [PATCH 03/28] authorize: auto-raise & skip on import (#56) --- lib/pages/auth_page.dart | 11 +++++++++++ lib/utils/data.dart | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/pages/auth_page.dart b/lib/pages/auth_page.dart index 67b0d29..725a6aa 100644 --- a/lib/pages/auth_page.dart +++ b/lib/pages/auth_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:local_auth/local_auth.dart'; import 'package:venera/utils/translations.dart'; @@ -14,6 +15,16 @@ class AuthPage extends StatefulWidget { class _AuthPageState extends State { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if(SchedulerBinding.instance.lifecycleState != AppLifecycleState.paused) { + auth(); + } + }); + super.initState(); + } + @override Widget build(BuildContext context) { return PopScope( diff --git a/lib/utils/data.dart b/lib/utils/data.dart index 9a13426..00f4264 100644 --- a/lib/utils/data.dart +++ b/lib/utils/data.dart @@ -71,12 +71,14 @@ Future importAppData(File file, [bool checkVersion = false]) async { LocalFavoritesManager().init(); } if (await appdataFile.exists()) { - // proxy settings should be kept + // proxy settings & authorization setting should be kept var proxySettings = appdata.settings["proxy"]; + var authSettings = appdata.settings["authorizationRequired"]; File(FilePath.join(App.dataPath, "appdata.json")).deleteIfExistsSync(); appdataFile.renameSync(FilePath.join(App.dataPath, "appdata.json")); await appdata.init(); appdata.settings["proxy"] = proxySettings; + appdata.settings["authorizationRequired"] = authSettings; appdata.saveData(); } if (await cookieFile.exists()) { From 6aeaeadb1058ec28c6c086dc01ad21a928d89095 Mon Sep 17 00:00:00 2001 From: nyne Date: Tue, 19 Nov 2024 18:44:52 +0800 Subject: [PATCH 04/28] fix & improve importing comic --- .../com/github/wgh136/venera/MainActivity.kt | 96 ++--- assets/translation.json | 6 +- lib/foundation/local.dart | 1 - lib/main.dart | 6 +- lib/pages/home_page.dart | 326 +---------------- lib/pages/local_comics_page.dart | 32 +- lib/utils/cbz.dart | 3 + lib/utils/file_type.dart | 3 + lib/utils/import_comic.dart | 338 ++++++++++++++++++ lib/utils/io.dart | 196 ++++++---- 10 files changed, 557 insertions(+), 450 deletions(-) create mode 100644 lib/utils/import_comic.dart diff --git a/android/app/src/main/kotlin/com/github/wgh136/venera/MainActivity.kt b/android/app/src/main/kotlin/com/github/wgh136/venera/MainActivity.kt index db2781e..4a3f677 100644 --- a/android/app/src/main/kotlin/com/github/wgh136/venera/MainActivity.kt +++ b/android/app/src/main/kotlin/com/github/wgh136/venera/MainActivity.kt @@ -8,7 +8,6 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Environment -import android.provider.DocumentsContract import android.provider.Settings import android.view.KeyEvent import androidx.activity.result.ActivityResultCallback @@ -96,11 +95,7 @@ class MainActivity : FlutterFragmentActivity() { if (pickedDirectoryUri == null) res.success(null) else - try { - res.success(onPickedDirectory(pickedDirectoryUri)) - } catch (e: Exception) { - res.error("Failed to Copy Files", e.toString(), null) - } + onPickedDirectory(pickedDirectoryUri, res) } } @@ -134,8 +129,9 @@ class MainActivity : FlutterFragmentActivity() { } val selectFileChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/select_file") - selectFileChannel.setMethodCallHandler { _, res -> - openFile(res) + selectFileChannel.setMethodCallHandler { req, res -> + val mimeType = req.arguments() + openFile(res, mimeType!!) } } @@ -166,26 +162,40 @@ class MainActivity : FlutterFragmentActivity() { return super.onKeyDown(keyCode, event) } - /// copy the directory to tmp directory, return copied directory - private fun onPickedDirectory(uri: Uri): String { - if (!hasStoragePermission()) { - // dart:io cannot access the directory without permission. - // so we need to copy the directory to cache directory - val contentResolver = contentResolver - var tmp = cacheDir - tmp = File(tmp, "getDirectoryPathTemp") - tmp.mkdir() - Thread { - copyDirectory(contentResolver, uri, tmp) - }.start() - - return tmp.absolutePath - } else { - val docId = DocumentsContract.getTreeDocumentId(uri) - val split: Array = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - return if ((split.size >= 2) && (split[1] != null)) split[1]!! - else File.separator + /// Ensure that the directory is accessible by dart:io + private fun onPickedDirectory(uri: Uri, result: MethodChannel.Result) { + if (hasStoragePermission()) { + var plain = uri.toString() + if(plain.contains("%3A")) { + plain = Uri.decode(plain) + } + val externalStoragePrefix = "content://com.android.externalstorage.documents/tree/primary:"; + if(plain.startsWith(externalStoragePrefix)) { + val path = plain.substring(externalStoragePrefix.length) + result.success(Environment.getExternalStorageDirectory().absolutePath + "/" + path) + } + // The uri cannot be parsed to plain path, use copy method } + // dart:io cannot access the directory without permission. + // so we need to copy the directory to cache directory + val contentResolver = contentResolver + var tmp = cacheDir + var dirName = DocumentFile.fromTreeUri(this, uri)?.name + tmp = File(tmp, dirName!!) + if(tmp.exists()) { + tmp.deleteRecursively() + } + tmp.mkdir() + Thread { + try { + copyDirectory(contentResolver, uri, tmp) + result.success(tmp.absolutePath) + } + catch (e: Exception) { + result.error("copy error", e.message, null) + } + }.start() + } private fun copyDirectory(resolver: ContentResolver, srcUri: Uri, destDir: File) { @@ -197,11 +207,12 @@ class MainActivity : FlutterFragmentActivity() { copyDirectory(resolver, file.uri, newDir) } else { val newFile = File(destDir, file.name!!) - val inputStream = resolver.openInputStream(file.uri) ?: return - val outputStream = FileOutputStream(newFile) - inputStream.copyTo(outputStream) - inputStream.close() - outputStream.close() + resolver.openInputStream(file.uri)?.use { input -> + FileOutputStream(newFile).use { output -> + input.copyTo(output, bufferSize = DEFAULT_BUFFER_SIZE) + output.flush() + } + } } } } @@ -277,10 +288,10 @@ class MainActivity : FlutterFragmentActivity() { } } - private fun openFile(result: MethodChannel.Result) { + private fun openFile(result: MethodChannel.Result, mimeType: String) { val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) - intent.type = "*/*" + intent.type = mimeType startContractForResult(ActivityResultContracts.StartActivityForResult(), intent){ activityResult -> if (activityResult.resultCode != Activity.RESULT_OK) { result.success(null) @@ -312,20 +323,9 @@ class MainActivity : FlutterFragmentActivity() { // ignore } } - // copy file to cache directory - val cacheDir = cacheDir - val newFile = File(cacheDir, fileName) - val inputStream = contentResolver.openInputStream(uri) - if (inputStream == null) { - result.success(null) - return@startContractForResult - } - val outputStream = FileOutputStream(newFile) - inputStream.copyTo(outputStream) - inputStream.close() - outputStream.close() - // send file path to flutter - result.success(newFile.absolutePath) + // use copy method + val filePath = FileUtils.getPathFromCopyOfFileFromUri(this, uri) + result.success(filePath) } } } diff --git a/assets/translation.json b/assets/translation.json index f56d9f9..0825140 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -231,7 +231,8 @@ "Please check your settings": "请检查您的设置", "No Category Pages": "没有分类页面", "Chapter @ep": "第 @ep 章", - "Page @page": "第 @page 页" + "Page @page": "第 @page 页", + "Also remove files on disk": "同时删除磁盘上的文件" }, "zh_TW": { "Home": "首頁", @@ -465,6 +466,7 @@ "Please check your settings": "請檢查您的設定", "No Category Pages": "沒有分類頁面", "Chapter @ep": "第 @ep 章", - "Page @page": "第 @page 頁" + "Page @page": "第 @page 頁", + "Also remove files on disk": "同時刪除磁盤上的文件" } } \ No newline at end of file diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 16b8247..803d89a 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -453,7 +453,6 @@ class LocalManager with ChangeNotifier { if(HistoryManager().findSync(c.id, c.comicType) != null) { HistoryManager().remove(c.id, c.comicType); } - assert(c.comicType == ComicType.local); var folders = LocalFavoritesManager().find(c.id, c.comicType); for (var f in folders) { LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType); diff --git a/lib/main.dart b/lib/main.dart index 2000267..f4fb9c8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/main_page.dart'; import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/app_links.dart'; +import 'package:venera/utils/io.dart'; import 'package:window_manager/window_manager.dart'; import 'components/components.dart'; import 'components/window_frame.dart'; @@ -81,7 +82,7 @@ class _MyAppState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - if(!App.isMobile) { + if (!App.isMobile) { return; } if (state == AppLifecycleState.inactive && hideContentOverlay == null) { @@ -104,7 +105,8 @@ class _MyAppState extends State with WidgetsBindingObserver { } if (state == AppLifecycleState.hidden && appdata.settings['authorizationRequired'] && - !isAuthPageActive) { + !isAuthPageActive && + !IO.isSelectingFiles) { isAuthPageActive = true; App.rootContext.to( () => AuthPage( diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 097b9de..75e637e 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -4,26 +4,21 @@ import 'package:sliver_tools/sliver_tools.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; -import 'package:venera/foundation/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/image_provider/cached_image.dart'; import 'package:venera/foundation/local.dart'; -import 'package:venera/foundation/log.dart'; import 'package:venera/pages/accounts_page.dart'; import 'package:venera/pages/comic_page.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/history_page.dart'; import 'package:venera/pages/search_page.dart'; -import 'package:venera/utils/cbz.dart'; import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/ext.dart'; -import 'package:venera/utils/io.dart'; +import 'package:venera/utils/import_comic.dart'; import 'package:venera/utils/translations.dart'; -import 'package:sqlite3/sqlite3.dart' as sql; -import 'dart:math'; import 'local_comics_page.dart'; @@ -624,323 +619,26 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { } void selectAndImport() async { - if (type == 2) { - var xFile = await selectFile(ext: ['cbz']); - var controller = showLoadingDialog(context, allowCancel: false); - try { - var cache = FilePath.join(App.cachePath, xFile?.name ?? 'temp.cbz'); - await xFile!.saveTo(cache); - var comic = await CBZ.import(File(cache)); - if (selectedFolder != null) { - LocalFavoritesManager().addComic( - selectedFolder!, - FavoriteItem( - id: comic.id, - name: comic.title, - coverPath: comic.cover, - author: comic.subtitle, - type: comic.comicType, - tags: comic.tags, - )); - } - await File(cache).deleteIgnoreError(); - } catch (e, s) { - Log.error("Import Comic", e.toString(), s); - context.showMessage(message: e.toString()); - } - controller.close(); - return; - } else if (type == 3) { - var dbFile = await selectFile(ext: ['db']); - final picker = DirectoryPicker(); - final comicSrc = await picker.pickDirectory(); - if (dbFile == null || comicSrc == null) { - return; - } - - bool cancelled = false; - var controller = showLoadingDialog(context, onCancel: () { cancelled = true; }); - - try { - var cache = FilePath.join(App.cachePath, dbFile.name); - await dbFile.saveTo(cache); - var db = sql.sqlite3.open(cache); - - Future addTagComics(String destFolder, List comics) async { - for(var comic in comics) { - if(cancelled) { - return; - } - var comicDir = Directory(FilePath.join(comicSrc.path, comic['DIRNAME'] as String)); - if(!(await comicDir.exists())) { - continue; - } - String titleJP = comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String; - String title = titleJP == "" ? comic['TITLE'] as String : titleJP; - if (LocalManager().findByName(title) != null) { - Log.info("Import Comic", "Comic already exists: $title"); - continue; - } - - String coverURL = await comicDir.joinFile(".thumb").exists() ? - comicDir.joinFile(".thumb").path : - (comic['THUMB'] as String).replaceAll('s.exhentai.org', 'ehgt.org'); - int downloadedTimeStamp = comic['TIME'] as int; - DateTime downloadedTime = - downloadedTimeStamp != 0 ? - DateTime.fromMillisecondsSinceEpoch(downloadedTimeStamp) : DateTime.now(); - var comicObj = LocalComic( - id: LocalManager().findValidId(ComicType.local), - title: title, - subtitle: '', - tags: [ - //1 >> x - [ - "MISC", - "DOUJINSHI", - "MANGA", - "ARTISTCG", - "GAMECG", - "IMAGE SET", - "COSPLAY", - "ASIAN PORN", - "NON-H", - "WESTERN", - ][(log(comic['CATEGORY'] as int) / ln2).floor()] - ], - directory: comicDir.path, - chapters: null, - cover: coverURL, - comicType: ComicType.local, - downloadedChapters: [], - createdAt: downloadedTime, - ); - LocalManager().add(comicObj, comicObj.id); - LocalFavoritesManager().addComic( - destFolder, - FavoriteItem( - id: comicObj.id, - name: comicObj.title, - coverPath: comicObj.cover, - author: comicObj.subtitle, - type: comicObj.comicType, - tags: comicObj.tags, - favoriteTime: downloadedTime - ), - ); - } - } - - //default folder - { - var defaultFolderName = '(EhViewer)Default'.tl; - if(!LocalFavoritesManager().existsFolder(defaultFolderName)) { - LocalFavoritesManager().createFolder(defaultFolderName); - } - var comicList = db.select(""" - SELECT * - FROM DOWNLOAD_DIRNAME DN - LEFT JOIN DOWNLOADS DL - ON DL.GID = DN.GID - WHERE DL.LABEL IS NULL AND DL.STATE = 3 - ORDER BY DL.TIME DESC - """).toList(); - await addTagComics(defaultFolderName, comicList); - } - - var folders = db.select(""" - SELECT * FROM DOWNLOAD_LABELS; - """); - - for (var folder in folders) { - if(cancelled) { - break; - } - var label = folder["LABEL"] as String; - var folderName = '(EhViewer)$label'; - if(!LocalFavoritesManager().existsFolder(folderName)) { - LocalFavoritesManager().createFolder(folderName); - } - var comicList = db.select(""" - SELECT * - FROM DOWNLOAD_DIRNAME DN - LEFT JOIN DOWNLOADS DL - ON DL.GID = DN.GID - WHERE DL.LABEL = ? AND DL.STATE = 3 - ORDER BY DL.TIME DESC - """, [label]).toList(); - await addTagComics(folderName, comicList); - } - db.dispose(); - await File(cache).deleteIgnoreError(); - } catch (e, s) { - Log.error("Import Comic", e.toString(), s); - context.showMessage(message: e.toString()); - } - controller.close(); - return; - } height = key.currentContext!.size!.height; setState(() { loading = true; }); - final picker = DirectoryPicker(); - final path = await picker.pickDirectory(); - if (!loading) { - picker.dispose(); - return; + var importer = ImportComic(selectedFolder: selectedFolder); + var result = false; + if (type == 2) { + result = await importer.cbz(); + } else if (type == 3) { + result = await importer.ehViewer(); + } else { + result = await importer.directory(type == 0); } - if (path == null) { + if(result) { + context.pop(); + } else { setState(() { loading = false; }); - return; } - Map comics = {}; - if (type == 0) { - var result = await checkSingleComic(path); - if (result != null) { - comics[path] = result; - } else { - context.showMessage(message: "Invalid Comic".tl); - setState(() { - loading = false; - }); - return; - } - } else { - await for (var entry in path.list()) { - if (entry is Directory) { - var result = await checkSingleComic(entry); - if (result != null) { - comics[entry] = result; - } - } - } - } - bool shouldCopy = true; - for (var comic in comics.keys) { - if (comic.parent.path == LocalManager().path) { - shouldCopy = false; - break; - } - } - if (shouldCopy && comics.isNotEmpty) { - try { - // copy the comics to the local directory - await compute, void>(_copyDirectories, { - 'toBeCopied': comics.keys.map((e) => e.path).toList(), - 'destination': LocalManager().path, - }); - } catch (e) { - context.showMessage(message: "Failed to import comics".tl); - Log.error("Import Comic", e.toString()); - setState(() { - loading = false; - }); - return; - } - } - for (var comic in comics.values) { - LocalManager().add(comic, LocalManager().findValidId(ComicType.local)); - if (selectedFolder != null) { - LocalFavoritesManager().addComic( - selectedFolder!, - FavoriteItem( - id: comic.id, - name: comic.title, - coverPath: comic.cover, - author: comic.subtitle, - type: comic.comicType, - tags: comic.tags, - )); - } - } - context.pop(); - context.showMessage( - message: "Imported @a comics".tlParams({ - 'a': comics.length, - })); - } - - static _copyDirectories(Map data) { - var toBeCopied = data['toBeCopied'] as List; - var destination = data['destination'] as String; - for (var dir in toBeCopied) { - var source = Directory(dir); - var dest = Directory("$destination/${source.name}"); - if (dest.existsSync()) { - // The destination directory already exists, and it is not managed by the app. - // Rename the old directory to avoid conflicts. - Log.info("Import Comic", - "Directory already exists: ${source.name}\nRenaming the old directory."); - dest.rename( - findValidDirectoryName(dest.parent.path, "${dest.path}_old")); - } - dest.createSync(); - copyDirectory(source, dest); - } - } - - Future checkSingleComic(Directory directory) async { - if (!(await directory.exists())) return null; - var name = directory.name; - if (LocalManager().findByName(name) != null) { - Log.info("Import Comic", "Comic already exists: $name"); - return null; - } - bool hasChapters = false; - var chapters = []; - var coverPath = ''; // relative path to the cover image - await for (var entry in directory.list()) { - if (entry is Directory) { - hasChapters = true; - chapters.add(entry.name); - await for (var file in entry.list()) { - if (file is Directory) { - Log.info("Import Comic", - "Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory."); - return null; - } - } - } else if (entry is File) { - if (entry.name.startsWith('cover')) { - coverPath = entry.name; - } - const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe']; - if (!coverPath.startsWith('cover') && - imageExtensions.contains(entry.extension)) { - coverPath = entry.name; - } - } - } - chapters.sort(); - if (hasChapters && coverPath == '') { - // use the first image in the first chapter as the cover - var firstChapter = Directory('${directory.path}/${chapters.first}'); - await for (var entry in firstChapter.list()) { - if (entry is File) { - coverPath = entry.name; - break; - } - } - } - if (coverPath == '') { - Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found."); - return null; - } - return LocalComic( - id: '0', - title: name, - subtitle: '', - tags: [], - directory: directory.name, - chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null, - cover: coverPath, - comicType: ComicType.local, - downloadedChapters: chapters, - createdAt: DateTime.now(), - ); } } diff --git a/lib/pages/local_comics_page.dart b/lib/pages/local_comics_page.dart index 244b8b7..5c5aa21 100644 --- a/lib/pages/local_comics_page.dart +++ b/lib/pages/local_comics_page.dart @@ -298,24 +298,16 @@ class _LocalComicsPageState extends State { return StatefulBuilder(builder: (context, state) { return ContentDialog( title: "Delete".tl, - content: Column( - children: [ - Text("Delete selected comics?".tl) - .paddingVertical(8), - Transform.scale( - scale: 0.9, - child: CheckboxListTile( - title: Text( - "Also remove files on disk".tl), - value: removeComicFile, - onChanged: (v) { - state(() { - removeComicFile = - !removeComicFile; - }); - })), - ], - ).paddingHorizontal(16).paddingVertical(8), + content: CheckboxListTile( + title: + Text("Also remove files on disk".tl), + value: removeComicFile, + onChanged: (v) { + state(() { + removeComicFile = !removeComicFile; + }); + }, + ), actions: [ FilledButton( onPressed: () { @@ -379,12 +371,12 @@ class _LocalComicsPageState extends State { return PopScope( canPop: !multiSelectMode && !searchMode, onPopInvokedWithResult: (didPop, result) { - if(multiSelectMode) { + if (multiSelectMode) { setState(() { multiSelectMode = false; selectedComics.clear(); }); - } else if(searchMode) { + } else if (searchMode) { setState(() { searchMode = false; keyword = ""; diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart index cef23ce..4458b45 100644 --- a/lib/utils/cbz.dart +++ b/lib/utils/cbz.dart @@ -86,6 +86,9 @@ abstract class CBZ { var ext = e.path.split('.').last; return !['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe'].contains(ext); }); + if(files.isEmpty) { + throw Exception('No images found in the archive'); + } files.sort((a, b) => a.path.compareTo(b.path)); var coverFile = files.firstWhereOrNull( (element) => diff --git a/lib/utils/file_type.dart b/lib/utils/file_type.dart index 72cdba4..42f8f77 100644 --- a/lib/utils/file_type.dart +++ b/lib/utils/file_type.dart @@ -10,6 +10,9 @@ class FileType { if(ext.startsWith('.')) { ext = ext.substring(1); } + if(ext == 'cbz') { + return const FileType('.cbz', 'application/octet-stream'); + } var mime = lookupMimeType('no-file.$ext'); return FileType(".$ext", mime ?? 'application/octet-stream'); } diff --git a/lib/utils/import_comic.dart b/lib/utils/import_comic.dart new file mode 100644 index 0000000..a5e40b6 --- /dev/null +++ b/lib/utils/import_comic.dart @@ -0,0 +1,338 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:venera/components/components.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/comic_type.dart'; +import 'package:venera/foundation/favorites.dart'; +import 'package:venera/foundation/local.dart'; +import 'package:venera/foundation/log.dart'; +import 'package:sqlite3/sqlite3.dart' as sql; +import 'package:venera/utils/translations.dart'; +import 'cbz.dart'; +import 'io.dart'; + +class ImportComic { + final String? selectedFolder; + + const ImportComic({this.selectedFolder}); + + Future cbz() async { + var xFile = await selectFile(ext: ['cbz']); + if(xFile == null) { + return false; + } + var controller = showLoadingDialog(App.rootContext, allowCancel: false); + var isSuccessful = false; + try { + var cache = FilePath.join(App.cachePath, xFile.name); + await xFile.saveTo(cache); + var comic = await CBZ.import(File(cache)); + if (selectedFolder != null) { + LocalFavoritesManager().addComic( + selectedFolder!, + FavoriteItem( + id: comic.id, + name: comic.title, + coverPath: comic.cover, + author: comic.subtitle, + type: comic.comicType, + tags: comic.tags, + ), + ); + } + await File(cache).deleteIgnoreError(); + isSuccessful = true; + } catch (e, s) { + Log.error("Import Comic", e.toString(), s); + App.rootContext.showMessage(message: e.toString()); + } + controller.close(); + return isSuccessful; + } + + Future ehViewer() async { + var dbFile = await selectFile(ext: ['db']); + final picker = DirectoryPicker(); + final comicSrc = await picker.pickDirectory(); + if (dbFile == null || comicSrc == null) { + return false; + } + + bool cancelled = false; + var controller = showLoadingDialog(App.rootContext, onCancel: () { + cancelled = true; + }); + bool isSuccessful = false; + + try { + var cache = FilePath.join(App.cachePath, dbFile.name); + await dbFile.saveTo(cache); + var db = sql.sqlite3.open(cache); + + Future addTagComics(String destFolder, List comics) async { + for (var comic in comics) { + if (cancelled) { + return; + } + var comicDir = Directory( + FilePath.join(comicSrc.path, comic['DIRNAME'] as String)); + if (!(await comicDir.exists())) { + continue; + } + String titleJP = + comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String; + String title = titleJP == "" ? comic['TITLE'] as String : titleJP; + if (LocalManager().findByName(title) != null) { + Log.info("Import Comic", "Comic already exists: $title"); + continue; + } + + String coverURL = await comicDir.joinFile(".thumb").exists() + ? comicDir.joinFile(".thumb").path + : (comic['THUMB'] as String) + .replaceAll('s.exhentai.org', 'ehgt.org'); + int downloadedTimeStamp = comic['TIME'] as int; + DateTime downloadedTime = downloadedTimeStamp != 0 + ? DateTime.fromMillisecondsSinceEpoch(downloadedTimeStamp) + : DateTime.now(); + var comicObj = LocalComic( + id: LocalManager().findValidId(ComicType.local), + title: title, + subtitle: '', + tags: [ + //1 >> x + [ + "MISC", + "DOUJINSHI", + "MANGA", + "ARTISTCG", + "GAMECG", + "IMAGE SET", + "COSPLAY", + "ASIAN PORN", + "NON-H", + "WESTERN", + ][(log(comic['CATEGORY'] as int) / ln2).floor()] + ], + directory: comicDir.path, + chapters: null, + cover: coverURL, + comicType: ComicType.local, + downloadedChapters: [], + createdAt: downloadedTime, + ); + LocalManager().add(comicObj, comicObj.id); + LocalFavoritesManager().addComic( + destFolder, + FavoriteItem( + id: comicObj.id, + name: comicObj.title, + coverPath: comicObj.cover, + author: comicObj.subtitle, + type: comicObj.comicType, + tags: comicObj.tags, + favoriteTime: downloadedTime, + ), + ); + } + } + + { + var defaultFolderName = '(EhViewer)Default'; + if (!LocalFavoritesManager().existsFolder(defaultFolderName)) { + LocalFavoritesManager().createFolder(defaultFolderName); + } + var comicList = db.select(""" + SELECT * + FROM DOWNLOAD_DIRNAME DN + LEFT JOIN DOWNLOADS DL + ON DL.GID = DN.GID + WHERE DL.LABEL IS NULL AND DL.STATE = 3 + ORDER BY DL.TIME DESC + """).toList(); + await addTagComics(defaultFolderName, comicList); + } + + var folders = db.select(""" + SELECT * FROM DOWNLOAD_LABELS; + """); + + for (var folder in folders) { + if (cancelled) { + break; + } + var label = folder["LABEL"] as String; + var folderName = '(EhViewer)$label'; + if (!LocalFavoritesManager().existsFolder(folderName)) { + LocalFavoritesManager().createFolder(folderName); + } + var comicList = db.select(""" + SELECT * + FROM DOWNLOAD_DIRNAME DN + LEFT JOIN DOWNLOADS DL + ON DL.GID = DN.GID + WHERE DL.LABEL = ? AND DL.STATE = 3 + ORDER BY DL.TIME DESC + """, [label]).toList(); + await addTagComics(folderName, comicList); + } + db.dispose(); + await File(cache).deleteIgnoreError(); + isSuccessful = true; + } catch (e, s) { + Log.error("Import Comic", e.toString(), s); + App.rootContext.showMessage(message: e.toString()); + } + controller.close(); + return isSuccessful; + } + + Future directory(bool single) async { + final picker = DirectoryPicker(); + final path = await picker.pickDirectory(); + if (path == null) { + return false; + } + Map comics = {}; + if (single) { + var result = await _checkSingleComic(path); + if (result != null) { + comics[path] = result; + } else { + App.rootContext.showMessage(message: "Invalid Comic".tl); + return false; + } + } else { + await for (var entry in path.list()) { + if (entry is Directory) { + var result = await _checkSingleComic(entry); + if (result != null) { + comics[entry] = result; + } + } + } + } + bool shouldCopy = true; + for (var comic in comics.keys) { + if (comic.parent.path == LocalManager().path) { + shouldCopy = false; + break; + } + } + if (shouldCopy && comics.isNotEmpty) { + try { + // copy the comics to the local directory + await compute, void>(_copyDirectories, { + 'toBeCopied': comics.keys.map((e) => e.path).toList(), + 'destination': LocalManager().path, + }); + } catch (e) { + App.rootContext.showMessage(message: "Failed to import comics".tl); + Log.error("Import Comic", e.toString()); + return false; + } + } + for (var comic in comics.values) { + LocalManager().add(comic, LocalManager().findValidId(ComicType.local)); + if (selectedFolder != null) { + LocalFavoritesManager().addComic( + selectedFolder!, + FavoriteItem( + id: comic.id, + name: comic.title, + coverPath: comic.cover, + author: comic.subtitle, + type: comic.comicType, + tags: comic.tags, + ), + ); + } + } + App.rootContext.showMessage( + message: "Imported @a comics".tlParams({ + 'a': comics.length, + })); + return true; + } + + Future _checkSingleComic(Directory directory) async { + if (!(await directory.exists())) return null; + var name = directory.name; + if (LocalManager().findByName(name) != null) { + Log.info("Import Comic", "Comic already exists: $name"); + return null; + } + bool hasChapters = false; + var chapters = []; + var coverPath = ''; // relative path to the cover image + for (var entry in directory.listSync()) { + if (entry is Directory) { + hasChapters = true; + chapters.add(entry.name); + await for (var file in entry.list()) { + if (file is Directory) { + Log.info("Import Comic", + "Invalid Chapter: ${entry.name}\nA directory is found in the chapter directory."); + return null; + } + } + } else if (entry is File) { + if (entry.name.startsWith('cover')) { + coverPath = entry.name; + } + const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe']; + if (!coverPath.startsWith('cover') && + imageExtensions.contains(entry.extension)) { + coverPath = entry.name; + } + } + } + chapters.sort(); + if (hasChapters && coverPath == '') { + // use the first image in the first chapter as the cover + var firstChapter = Directory('${directory.path}/${chapters.first}'); + await for (var entry in firstChapter.list()) { + if (entry is File) { + coverPath = entry.name; + break; + } + } + } + if (coverPath == '') { + Log.info("Import Comic", "Invalid Comic: $name\nNo cover image found."); + return null; + } + return LocalComic( + id: '0', + title: name, + subtitle: '', + tags: [], + directory: directory.name, + chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null, + cover: coverPath, + comicType: ComicType.local, + downloadedChapters: chapters, + createdAt: DateTime.now(), + ); + } + + static _copyDirectories(Map data) { + var toBeCopied = data['toBeCopied'] as List; + var destination = data['destination'] as String; + for (var dir in toBeCopied) { + var source = Directory(dir); + var dest = Directory("$destination/${source.name}"); + if (dest.existsSync()) { + // The destination directory already exists, and it is not managed by the app. + // Rename the old directory to avoid conflicts. + Log.info("Import Comic", + "Directory already exists: ${source.name}\nRenaming the old directory."); + dest.rename( + findValidDirectoryName(dest.parent.path, "${dest.path}_old")); + } + dest.createSync(); + copyDirectory(source, dest); + } + } +} diff --git a/lib/utils/io.dart b/lib/utils/io.dart index 5e41265..aafeb52 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -14,6 +14,21 @@ import 'package:venera/utils/file_type.dart'; export 'dart:io'; export 'dart:typed_data'; +class IO { + /// A global flag used to indicate whether the app is selecting files. + /// + /// Select file and other similar file operations will launch external programs, + /// causing the app to lose focus. AppLifecycleState will be set to paused. + static bool get isSelectingFiles => _isSelectingFiles; + + static bool _isSelectingFiles = false; +} + +/// A finalizer that can be used to dispose resources related to file operations. +final _finalizer = Finalizer((e) { + e(); +}); + class FilePath { const FilePath._(); @@ -147,24 +162,35 @@ String findValidDirectoryName(String path, String directory) { } class DirectoryPicker { + DirectoryPicker() { + _finalizer.attach(this, dispose); + } + String? _directory; final _methodChannel = const MethodChannel("venera/method_channel"); Future pickDirectory() async { - if (App.isWindows || App.isLinux) { - var d = await file_selector.getDirectoryPath(); - _directory = d; - return d == null ? null : Directory(d); - } else if (App.isAndroid) { - var d = await _methodChannel.invokeMethod("getDirectoryPath"); - _directory = d; - return d == null ? null : Directory(d); - } else { - // ios, macos - var d = await _methodChannel.invokeMethod("getDirectoryPath"); - _directory = d; - return d == null ? null : Directory(d); + IO._isSelectingFiles = true; + try { + if (App.isWindows || App.isLinux) { + var d = await file_selector.getDirectoryPath(); + _directory = d; + return d == null ? null : Directory(d); + } else if (App.isAndroid) { + var d = await _methodChannel.invokeMethod("getDirectoryPath"); + _directory = d; + return d == null ? null : Directory(d); + } else { + // ios, macos + var d = await _methodChannel.invokeMethod("getDirectoryPath"); + _directory = d; + return d == null ? null : Directory(d); + } + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); } } @@ -172,11 +198,15 @@ class DirectoryPicker { if (_directory == null) { return; } - if (App.isAndroid && _directory != null) { - return Directory(_directory!).deleteIgnoreError(recursive: true); + if (App.isAndroid && + _directory != null && + _directory!.startsWith(App.cachePath)) { + await Directory(_directory!).deleteIgnoreError(recursive: true); + _directory = null; } if (App.isIOS || App.isMacOS) { await _methodChannel.invokeMethod("stopAccessingSecurityScopedResource"); + _directory = null; } } } @@ -186,53 +216,73 @@ class IOSDirectoryPicker { // 调用 iOS 目录选择方法 static Future selectDirectory() async { + IO._isSelectingFiles = true; try { final String? path = await _channel.invokeMethod('selectDirectory'); return path; } catch (e) { // 返回报错信息 return e.toString(); + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); } } } Future selectFile({required List ext}) async { - var extensions = App.isMacOS || App.isIOS ? null : ext; - if (App.isAndroid) { - for (var e in ext) { - var fileType = FileType.fromExtension(e); - if (fileType.mime == "application/octet-stream") { - extensions = null; - break; - } - } - } - file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup( - label: 'files', - extensions: extensions, - ); - file_selector.XFile? file; - if (extensions == null && App.isAndroid) { - const selectFileChannel = MethodChannel("venera/select_file"); - var filePath = await selectFileChannel.invokeMethod("selectFile"); - if (filePath == null) return null; - file = file_selector.XFile(filePath); - } else { - file = await file_selector.openFile( - acceptedTypeGroups: [typeGroup], + IO._isSelectingFiles = true; + try { + var extensions = App.isMacOS || App.isIOS ? null : ext; + file_selector.XTypeGroup typeGroup = file_selector.XTypeGroup( + label: 'files', + extensions: extensions, ); - if (file == null) return null; + file_selector.XFile? file; + if (App.isAndroid) { + const selectFileChannel = MethodChannel("venera/select_file"); + String mimeType = "*/*"; + if(ext.length == 1) { + mimeType = FileType.fromExtension(ext[0]).mime; + if(mimeType == "application/octet-stream") { + mimeType = "*/*"; + } + } + var filePath = await selectFileChannel.invokeMethod( + "selectFile", + mimeType, + ); + if (filePath == null) return null; + file = _AndroidFileSelectResult(filePath); + } else { + file = await file_selector.openFile( + acceptedTypeGroups: [typeGroup], + ); + if (file == null) return null; + } + if (!ext.contains(file.path.split(".").last)) { + App.rootContext.showMessage(message: "Invalid file type"); + return null; + } + return file; + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); } - if (!ext.contains(file.path.split(".").last)) { - App.rootContext.showMessage(message: "Invalid file type"); - return null; - } - return file; } Future selectDirectory() async { - var path = await file_selector.getDirectoryPath(); - return path; + IO._isSelectingFiles = true; + try { + var path = await file_selector.getDirectoryPath(); + return path; + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); + } } // selectDirectoryIOS @@ -245,25 +295,32 @@ Future saveFile( if (data == null && file == null) { throw Exception("data and file cannot be null at the same time"); } - if (data != null) { - var cache = FilePath.join(App.cachePath, filename); - if (File(cache).existsSync()) { - File(cache).deleteSync(); + IO._isSelectingFiles = true; + try { + if (data != null) { + var cache = FilePath.join(App.cachePath, filename); + if (File(cache).existsSync()) { + File(cache).deleteSync(); + } + await File(cache).writeAsBytes(data); + file = File(cache); } - await File(cache).writeAsBytes(data); - file = File(cache); - } - if (App.isMobile) { - final params = SaveFileDialogParams(sourceFilePath: file!.path); - await FlutterFileDialog.saveFile(params: params); - } else { - final result = await file_selector.getSaveLocation( - suggestedName: filename, - ); - if (result != null) { - var xFile = file_selector.XFile(file!.path); - await xFile.saveTo(result.path); + if (App.isMobile) { + final params = SaveFileDialogParams(sourceFilePath: file!.path); + await FlutterFileDialog.saveFile(params: params); + } else { + final result = await file_selector.getSaveLocation( + suggestedName: filename, + ); + if (result != null) { + var xFile = file_selector.XFile(file!.path); + await xFile.saveTo(result.path); + } } + } finally { + Future.delayed(const Duration(milliseconds: 100), () { + IO._isSelectingFiles = false; + }); } } @@ -302,3 +359,16 @@ String bytesToReadableString(int bytes) { return "${(bytes / 1024 / 1024 / 1024).toStringAsFixed(2)} GB"; } } + +class _AndroidFileSelectResult extends s.XFile { + _AndroidFileSelectResult(super.path) { + _finalizer.attach(this, dispose); + } + + void dispose() { + print("dispose $path"); + if (path.startsWith(App.cachePath)) { + File(path).deleteIgnoreError(); + } + } +} From ce175a2135b01cf54929963df46920513fd53951 Mon Sep 17 00:00:00 2001 From: nyne Date: Tue, 19 Nov 2024 20:52:13 +0800 Subject: [PATCH 05/28] improve importing comic --- lib/utils/cbz.dart | 4 +- lib/utils/import_comic.dart | 9 ++-- lib/utils/io.dart | 95 ++++++++++++++++++------------------- 3 files changed, 51 insertions(+), 57 deletions(-) diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart index 4458b45..a2458f2 100644 --- a/lib/utils/cbz.dart +++ b/lib/utils/cbz.dart @@ -111,7 +111,7 @@ abstract class CBZ { var src = files[i]; var dst = File( FilePath.join(dest.path, '${i + 1}.${src.path.split('.').last}')); - src.copy(dst.path); + await src.copy(dst.path); } } else { dest.createSync(); @@ -129,7 +129,7 @@ abstract class CBZ { var src = chapter.value[i]; var dst = File(FilePath.join( chapterDir.path, '${i + 1}.${src.path.split('.').last}')); - src.copy(dst.path); + await src.copy(dst.path); } } } diff --git a/lib/utils/import_comic.dart b/lib/utils/import_comic.dart index a5e40b6..87f02ac 100644 --- a/lib/utils/import_comic.dart +++ b/lib/utils/import_comic.dart @@ -18,16 +18,14 @@ class ImportComic { const ImportComic({this.selectedFolder}); Future cbz() async { - var xFile = await selectFile(ext: ['cbz']); - if(xFile == null) { + var file = await selectFile(ext: ['cbz']); + if(file == null) { return false; } var controller = showLoadingDialog(App.rootContext, allowCancel: false); var isSuccessful = false; try { - var cache = FilePath.join(App.cachePath, xFile.name); - await xFile.saveTo(cache); - var comic = await CBZ.import(File(cache)); + var comic = await CBZ.import(File(file.path)); if (selectedFolder != null) { LocalFavoritesManager().addComic( selectedFolder!, @@ -41,7 +39,6 @@ class ImportComic { ), ); } - await File(cache).deleteIgnoreError(); isSuccessful = true; } catch (e, s) { Log.error("Import Comic", e.toString(), s); diff --git a/lib/utils/io.dart b/lib/utils/io.dart index aafeb52..6593f75 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -24,11 +24,6 @@ class IO { static bool _isSelectingFiles = false; } -/// A finalizer that can be used to dispose resources related to file operations. -final _finalizer = Finalizer((e) { - e(); -}); - class FilePath { const FilePath._(); @@ -162,53 +157,43 @@ String findValidDirectoryName(String path, String directory) { } class DirectoryPicker { - DirectoryPicker() { - _finalizer.attach(this, dispose); - } + /// Pick a directory. + /// + /// The directory may not be usable after the instance is GCed. + DirectoryPicker(); - String? _directory; + static final _finalizer = Finalizer((path) { + if (path.startsWith(App.cachePath)) { + Directory(path).deleteIgnoreError(); + } + if (App.isIOS || App.isMacOS) { + _methodChannel.invokeMethod("stopAccessingSecurityScopedResource"); + } + }); - final _methodChannel = const MethodChannel("venera/method_channel"); + static const _methodChannel = MethodChannel("venera/method_channel"); Future pickDirectory() async { IO._isSelectingFiles = true; try { + String? directory; if (App.isWindows || App.isLinux) { - var d = await file_selector.getDirectoryPath(); - _directory = d; - return d == null ? null : Directory(d); + directory = await file_selector.getDirectoryPath(); } else if (App.isAndroid) { - var d = await _methodChannel.invokeMethod("getDirectoryPath"); - _directory = d; - return d == null ? null : Directory(d); + directory = await _methodChannel.invokeMethod("getDirectoryPath"); } else { // ios, macos - var d = await _methodChannel.invokeMethod("getDirectoryPath"); - _directory = d; - return d == null ? null : Directory(d); + directory = await _methodChannel.invokeMethod("getDirectoryPath"); } + if (directory == null) return null; + _finalizer.attach(this, directory); + return Directory(directory); } finally { Future.delayed(const Duration(milliseconds: 100), () { IO._isSelectingFiles = false; }); } } - - Future dispose() async { - if (_directory == null) { - return; - } - if (App.isAndroid && - _directory != null && - _directory!.startsWith(App.cachePath)) { - await Directory(_directory!).deleteIgnoreError(recursive: true); - _directory = null; - } - if (App.isIOS || App.isMacOS) { - await _methodChannel.invokeMethod("stopAccessingSecurityScopedResource"); - _directory = null; - } - } } class IOSDirectoryPicker { @@ -231,7 +216,7 @@ class IOSDirectoryPicker { } } -Future selectFile({required List ext}) async { +Future selectFile({required List ext}) async { IO._isSelectingFiles = true; try { var extensions = App.isMacOS || App.isIOS ? null : ext; @@ -239,13 +224,13 @@ Future selectFile({required List ext}) async { label: 'files', extensions: extensions, ); - file_selector.XFile? file; + FileSelectResult? file; if (App.isAndroid) { const selectFileChannel = MethodChannel("venera/select_file"); String mimeType = "*/*"; - if(ext.length == 1) { + if (ext.length == 1) { mimeType = FileType.fromExtension(ext[0]).mime; - if(mimeType == "application/octet-stream") { + if (mimeType == "application/octet-stream") { mimeType = "*/*"; } } @@ -254,12 +239,13 @@ Future selectFile({required List ext}) async { mimeType, ); if (filePath == null) return null; - file = _AndroidFileSelectResult(filePath); + file = FileSelectResult(filePath); } else { - file = await file_selector.openFile( + var xFile = await file_selector.openFile( acceptedTypeGroups: [typeGroup], ); - if (file == null) return null; + if (xFile == null) return null; + file = FileSelectResult(xFile.path); } if (!ext.contains(file.path.split(".").last)) { App.rootContext.showMessage(message: "Invalid file type"); @@ -360,15 +346,26 @@ String bytesToReadableString(int bytes) { } } -class _AndroidFileSelectResult extends s.XFile { - _AndroidFileSelectResult(super.path) { - _finalizer.attach(this, dispose); - } +class FileSelectResult { + final String path; - void dispose() { - print("dispose $path"); + static final _finalizer = Finalizer((path) { if (path.startsWith(App.cachePath)) { File(path).deleteIgnoreError(); } + }); + + FileSelectResult(this.path) { + _finalizer.attach(this, path); } -} + + Future saveTo(String path) async { + await File(this.path).copy(path); + } + + Future readAsBytes() { + return File(path).readAsBytes(); + } + + String get name => File(path).name; +} \ No newline at end of file From c4aab2369f3b98162a535c0e9f3c36ecf3848fc6 Mon Sep 17 00:00:00 2001 From: AnxuNA <41771421+axlmly@users.noreply.github.com> Date: Wed, 20 Nov 2024 09:33:33 +0800 Subject: [PATCH 06/28] Fix _buildBriefMode display (#58) --- lib/components/comic.dart | 55 +++++++++++-------- lib/foundation/favorites.dart | 4 +- lib/pages/favorites/local_favorites_page.dart | 5 +- 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 268f1b6..9f9ee4c 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -253,18 +253,34 @@ class ComicTile extends StatelessWidget { child: buildImage(context), ), ), - Positioned( - bottom: 0, - right: 0, - child: Padding( + Align( + alignment: Alignment.bottomRight, + child: (() { + final subtitle = + comic.subtitle?.replaceAll('\n', '').trim(); + final text = comic.description.isNotEmpty + ? comic.description.split('|').join('\n') + : (subtitle?.isNotEmpty == true + ? subtitle + : null); + final scale = + (appdata.settings['comicTileScale'] as num) + .toDouble(); + final fortSize = scale < 0.85 + ? 8.0 // 小尺寸 + : (scale < 1.0 ? 10.0 : 12.0); + + if (text == null) { + return const SizedBox + .shrink(); // 如果没有文本,则不显示任何内容 + } + + return Padding( padding: const EdgeInsets.symmetric( - horizontal: 4, vertical: 4), + horizontal: 2, vertical: 2), child: ClipRRect( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(10.0), - topRight: Radius.circular(10.0), - bottomRight: Radius.circular(10.0), - bottomLeft: Radius.circular(10.0), + borderRadius: const BorderRadius.all( + Radius.circular(10.0), ), child: Container( color: Colors.black.withOpacity(0.5), @@ -273,19 +289,13 @@ class ComicTile extends StatelessWidget { const EdgeInsets.fromLTRB(8, 6, 8, 6), child: ConstrainedBox( constraints: BoxConstraints( - maxWidth: constraints.maxWidth * 0.88, + maxWidth: constraints.maxWidth, ), child: Text( - comic.description.isEmpty - ? comic.subtitle - ?.replaceAll('\n', '') ?? - '' - : comic.description - .split('|') - .join('\n'), - style: const TextStyle( + text, + style: TextStyle( fontWeight: FontWeight.w500, - fontSize: 12, + fontSize: fortSize, color: Colors.white, ), textAlign: TextAlign.right, @@ -296,7 +306,9 @@ class ComicTile extends StatelessWidget { ), ), ), - )), + ); + })(), + ), ], ), ), @@ -307,7 +319,6 @@ class ComicTile extends StatelessWidget { comic.title.replaceAll('\n', ''), style: const TextStyle( fontWeight: FontWeight.w500, - fontSize: 14.0, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index d6f245c..1ce41e7 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -75,7 +75,9 @@ class FavoriteItem implements Comic { @override String get description { - return "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}"; + return appdata.settings['comicDisplayMode'] == 'detailed' + ? "$time | ${type == ComicType.local ? 'local' : type.comicSource?.name ?? "Unknown"}" + : "${type.comicSource?.name ?? "Unknown"} | $time"; } @override diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index 460aedd..628ed63 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -284,6 +284,7 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> { @override Widget build(BuildContext context) { + var type = appdata.settings['comicDisplayMode']; var tiles = comics.map( (e) { var comicSource = e.type.comicSource; @@ -296,7 +297,9 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> { e.id, e.author, e.tags, - "${e.time} | ${comicSource?.name ?? "Unknown"}", + type == 'detailed' + ? "${e.time} | ${comicSource?.name ?? "Unknown"}" + : "${e.type.comicSource?.name ?? "Unknown"} | ${e.time}", comicSource?.key ?? (e.type == ComicType.local ? "local" : "Unknown"), null, From 454497fd6525bf08e76fb5bda2fd5e7ea1d9cb15 Mon Sep 17 00:00:00 2001 From: nyne Date: Wed, 20 Nov 2024 13:24:50 +0800 Subject: [PATCH 07/28] fix mime --- lib/utils/file_type.dart | 13 ++++++++----- pubspec.lock | 12 ++++++------ pubspec.yaml | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lib/utils/file_type.dart b/lib/utils/file_type.dart index 42f8f77..f75ddf0 100644 --- a/lib/utils/file_type.dart +++ b/lib/utils/file_type.dart @@ -10,11 +10,14 @@ class FileType { if(ext.startsWith('.')) { ext = ext.substring(1); } - if(ext == 'cbz') { - return const FileType('.cbz', 'application/octet-stream'); - } - var mime = lookupMimeType('no-file.$ext'); - return FileType(".$ext", mime ?? 'application/octet-stream'); + var mime = lookupMimeType('no-file.$ext') ?? 'application/octet-stream'; + // Android doesn't support some mime types + mime = switch(mime) { + 'text/javascript' => 'application/javascript', + 'application/x-cbr' => 'application/octet-stream', + _ => mime, + }; + return FileType(".$ext", mime); } } diff --git a/pubspec.lock b/pubspec.lock index 1d9b852..5e8daa2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -597,10 +597,10 @@ packages: dependency: "direct main" description: name: mime - sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "2.0.0" mime_type: dependency: transitive description: @@ -767,18 +767,18 @@ packages: dependency: "direct main" description: name: share_plus - sha256: "468c43f285207c84bcabf5737f33b914ceb8eb38398b91e5e3ad1698d1b72a52" + sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8" url: "https://pub.dev" source: hosted - version: "10.0.2" + version: "10.1.2" share_plus_platform_interface: dependency: transitive description: name: share_plus_platform_interface - sha256: "6ababf341050edff57da8b6990f11f4e99eaba837865e2e6defe16d039619db5" + sha256: c57c0bbfec7142e3a0f55633be504b796af72e60e3c791b44d5a017b985f7a48 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.0.1" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 0c27fe0..1b1a23c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,7 +32,7 @@ dependencies: git: url: https://github.com/wgh136/photo_view ref: 94724a0b - mime: ^1.0.5 + mime: ^2.0.0 share_plus: ^10.0.2 scrollable_positioned_list: git: From 7fcb63c0cb65cf91fbf5cd80e34c04fc0eb7407d Mon Sep 17 00:00:00 2001 From: nyne Date: Wed, 20 Nov 2024 18:04:22 +0800 Subject: [PATCH 08/28] show comments in comic details page --- assets/init.js | 8 +- lib/foundation/comic_source/models.dart | 7 +- lib/pages/comic_page.dart | 158 +++++++++++++++++++++++- lib/pages/comments_page.dart | 12 +- 4 files changed, 175 insertions(+), 10 deletions(-) diff --git a/assets/init.js b/assets/init.js index 755df9b..8ea0e8b 100644 --- a/assets/init.js +++ b/assets/init.js @@ -880,8 +880,8 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage * @param cover {string} * @param description {string?} * @param tags {Map | {} | null | undefined} - * @param chapters {Map | {} | null | undefined}} - key: chapter id, value: chapter title - * @param isFavorite {boolean | null | undefined}} - favorite status. If the comic source supports multiple folders, this field should be null + * @param chapters {Map | {} | null | undefined} - key: chapter id, value: chapter title + * @param isFavorite {boolean | null | undefined} - favorite status. If the comic source supports multiple folders, this field should be null * @param subId {string?} - a param which is passed to comments api * @param thumbnails {string[]?} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails * @param recommend {Comic[]?} - related comics @@ -894,9 +894,10 @@ function Comic({id, title, subtitle, subTitle, cover, tags, description, maxPage * @param url {string?} * @param stars {number?} - 0-5, double * @param maxPage {number?} + * @param comments {Comment[]?}- `since 1.0.7` App will display comments in the details page. * @constructor */ -function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage}) { +function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage, comments}) { this.title = title; this.cover = cover; this.description = description; @@ -915,6 +916,7 @@ function ComicDetails({title, cover, description, tags, chapters, isFavorite, su this.url = url; this.stars = stars; this.maxPage = maxPage; + this.comments = comments; } /** diff --git a/lib/foundation/comic_source/models.dart b/lib/foundation/comic_source/models.dart index 042682b..80843d0 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -160,6 +160,8 @@ class ComicDetails with HistoryMixin { @override final int? maxPage; + final List? comments; + static Map> _generateMap(Map map) { var res = >{}; map.forEach((key, value) { @@ -193,7 +195,10 @@ class ComicDetails with HistoryMixin { updateTime = json["updateTime"], url = json["url"], stars = (json["stars"] as num?)?.toDouble(), - maxPage = json["maxPage"]; + maxPage = json["maxPage"], + comments = (json["comments"] as List?) + ?.map((e) => Comment.fromJson(e)) + .toList(); Map toJson() { return { diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 081ad0f..6e8f68a 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -115,6 +115,7 @@ class _ComicPageState extends LoadingState buildDescription(), buildInfo(), buildChapters(), + buildComments(), buildThumbnails(), buildRecommend(), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), @@ -287,7 +288,7 @@ class _ComicPageState extends LoadingState onLongPressed: quickFavorite, iconColor: context.useTextColor(Colors.purple), ), - if (comicSource.commentsLoader != null) + if (comicSource.commentsLoader != null && comic.comments == null) _ActionButton( icon: const Icon(Icons.comment), text: (comic.commentsCount ?? 'Comments'.tl).toString(), @@ -549,6 +550,16 @@ class _ComicPageState extends LoadingState SliverGridComics(comics: comic.recommend!), ]); } + + Widget buildComments() { + if (comic.comments == null || comic.comments!.isEmpty) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return _CommentsPart( + comments: comic.comments!, + showMore: showComments, + ); + } } abstract mixin class _ComicPageActions { @@ -1670,3 +1681,148 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> { ); } } + +class _CommentsPart extends StatefulWidget { + const _CommentsPart({ + required this.comments, + required this.showMore, + }); + + final List comments; + + final void Function() showMore; + + @override + State<_CommentsPart> createState() => _CommentsPartState(); +} + +class _CommentsPartState extends State<_CommentsPart> { + final scrollController = ScrollController(); + + late List comments; + + @override + void initState() { + comments = widget.comments; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return MultiSliver( + children: [ + SliverToBoxAdapter( + child: ListTile( + title: Text("Comments".tl), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () { + scrollController.animateTo( + scrollController.position.pixels - 340, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + }, + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () { + scrollController.animateTo( + scrollController.position.pixels + 340, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + }, + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 184, + child: ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + itemCount: comments.length, + itemBuilder: (context, index) { + return _CommentWidget(comment: comments[index]); + }, + ), + ), + const SizedBox(height: 8), + _ActionButton( + icon: const Icon(Icons.comment), + text: "View more".tl, + onPressed: widget.showMore, + iconColor: context.useTextColor(Colors.green), + ).fixHeight(48).paddingRight(8).toAlign(Alignment.centerRight), + const SizedBox(height: 8), + ], + ), + ), + const SliverToBoxAdapter( + child: Divider(), + ), + ], + ); + } +} + +class _CommentWidget extends StatelessWidget { + const _CommentWidget({required this.comment}); + + final Comment comment; + + @override + Widget build(BuildContext context) { + return Container( + height: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 8, 0, 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + width: 324, + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + children: [ + if (comment.avatar != null) + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: context.colorScheme.surfaceContainer, + ), + clipBehavior: Clip.antiAlias, + child: Image( + image: CachedImageProvider(comment.avatar!), + width: 36, + height: 36, + fit: BoxFit.cover, + ), + ).paddingRight(8), + Text(comment.userName, style: ts.bold), + ], + ), + const SizedBox(height: 4), + Expanded( + child: RichCommentContent(text: comment.content).fixWidth(324), + ), + const SizedBox(height: 4), + if (comment.time != null) + Text(comment.time!, style: ts.s12).toAlign(Alignment.centerLeft), + ], + ), + ); + } +} diff --git a/lib/pages/comments_page.dart b/lib/pages/comments_page.dart index aa60868..74ad4f8 100644 --- a/lib/pages/comments_page.dart +++ b/lib/pages/comments_page.dart @@ -510,7 +510,7 @@ class _CommentContent extends StatelessWidget { if (!text.contains('<') && !text.contains('http')) { return SelectableText(text); } else { - return _RichCommentContent(text: text); + return RichCommentContent(text: text); } } } @@ -610,16 +610,16 @@ class _CommentImage { const _CommentImage(this.url, this.link); } -class _RichCommentContent extends StatefulWidget { - const _RichCommentContent({required this.text}); +class RichCommentContent extends StatefulWidget { + const RichCommentContent({super.key, required this.text}); final String text; @override - State<_RichCommentContent> createState() => _RichCommentContentState(); + State createState() => _RichCommentContentState(); } -class _RichCommentContentState extends State<_RichCommentContent> { +class _RichCommentContentState extends State { var textSpan = []; var images = <_CommentImage>[]; @@ -639,6 +639,8 @@ class _RichCommentContentState extends State<_RichCommentContent> { int i = 0; var buffer = StringBuffer(); var text = widget.text; + text = text.replaceAll('\r\n', '\n'); + text = text.replaceAll('&', '&'); void writeBuffer() { if (buffer.isEmpty) return; From b1cdcc2a919fca9ea031491968f379d18faaa63b Mon Sep 17 00:00:00 2001 From: nyne Date: Wed, 20 Nov 2024 18:12:27 +0800 Subject: [PATCH 09/28] fix copyDirectories --- lib/utils/import_comic.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/utils/import_comic.dart b/lib/utils/import_comic.dart index 87f02ac..51990d9 100644 --- a/lib/utils/import_comic.dart +++ b/lib/utils/import_comic.dart @@ -314,7 +314,7 @@ class ImportComic { ); } - static _copyDirectories(Map data) { + static Future _copyDirectories(Map data) async { var toBeCopied = data['toBeCopied'] as List; var destination = data['destination'] as String; for (var dir in toBeCopied) { @@ -325,11 +325,11 @@ class ImportComic { // Rename the old directory to avoid conflicts. Log.info("Import Comic", "Directory already exists: ${source.name}\nRenaming the old directory."); - dest.rename( + await dest.rename( findValidDirectoryName(dest.parent.path, "${dest.path}_old")); } dest.createSync(); - copyDirectory(source, dest); + await copyDirectory(source, dest); } } } From ad3f2fab4593b3f363c7640d7fa17ecb4be2aa65 Mon Sep 17 00:00:00 2001 From: nyne Date: Thu, 21 Nov 2024 21:29:45 +0800 Subject: [PATCH 10/28] add archive download --- lib/components/comic.dart | 2 - lib/foundation/comic_source/comic_source.dart | 11 + lib/foundation/comic_source/models.dart | 11 + lib/foundation/comic_source/parser.dart | 34 +- .../image_provider/cached_image.dart | 7 + lib/foundation/js_engine.dart | 19 +- lib/network/app_dio.dart | 3 + lib/network/download.dart | 236 +++++++++++++- lib/network/file_downloader.dart | 298 ++++++++++++++++++ lib/pages/comic_page.dart | 116 +++++++ lib/pages/downloading_page.dart | 6 +- 11 files changed, 726 insertions(+), 17 deletions(-) create mode 100644 lib/network/file_downloader.dart diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 9f9ee4c..08a95bc 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -154,8 +154,6 @@ class ComicTile extends StatelessWidget { ImageProvider image; if (comic is LocalComic) { image = FileImage((comic as LocalComic).coverFile); - } else if (comic.cover.startsWith('file://')) { - image = FileImage(File(comic.cover.substring(7))); } else if (comic.sourceKey == 'local') { var localComic = LocalManager().find(comic.id, ComicType.local); if (localComic == null) { diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index a537918..7854b73 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -215,6 +215,8 @@ class ComicSource { final StarRatingFunc? starRatingFunc; + final ArchiveDownloader? archiveDownloader; + Future loadData() async { var file = File("${App.dataPath}/comic_source/$key.data"); if (await file.exists()) { @@ -284,6 +286,7 @@ class ComicSource { this.enableTagsSuggestions, this.enableTagsTranslate, this.starRatingFunc, + this.archiveDownloader, ); } @@ -465,3 +468,11 @@ class LinkHandler { const LinkHandler(this.domains, this.linkToId); } + +class ArchiveDownloader { + final Future>> Function(String cid) getArchives; + + final Future> Function(String cid, String aid) getDownloadUrl; + + const ArchiveDownloader(this.getArchives, this.getDownloadUrl); +} \ No newline at end of file diff --git a/lib/foundation/comic_source/models.dart b/lib/foundation/comic_source/models.dart index 80843d0..9155765 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -232,3 +232,14 @@ class ComicDetails with HistoryMixin { ComicType get comicType => ComicType(sourceKey.hashCode); } + +class ArchiveInfo { + final String title; + final String description; + final String id; + + ArchiveInfo.fromJson(Map json) + : title = json["title"], + description = json["description"], + id = json["id"]; +} \ No newline at end of file diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index 23b8d37..65019f4 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -153,11 +153,12 @@ class ComicSourceParser { _getValue("search.enableTagsSuggestions") ?? false, _getValue("comic.enableTagsTranslate") ?? false, _parseStarRatingFunc(), + _parseArchiveDownloader(), ); await source.loadData(); - if(_checkExists("init")) { + if (_checkExists("init")) { Future.delayed(const Duration(milliseconds: 50), () { JsEngine().runCode("ComicSource.sources.$_key.init()"); }); @@ -988,4 +989,35 @@ class ComicSourceParser { } }; } + + ArchiveDownloader? _parseArchiveDownloader() { + if (!_checkExists("comic.archive")) { + return null; + } + return ArchiveDownloader( + (cid) async { + try { + var res = await JsEngine().runCode(""" + ComicSource.sources.$_key.comic.archive.getArchives(${jsonEncode(cid)}) + """); + return Res( + (res as List).map((e) => ArchiveInfo.fromJson(e)).toList()); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }, + (cid, aid) async { + try { + var res = await JsEngine().runCode(""" + ComicSource.sources.$_key.comic.archive.getDownloadUrl(${jsonEncode(cid)}, ${jsonEncode(aid)}) + """); + return Res(res as String); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }, + ); + } } diff --git a/lib/foundation/image_provider/cached_image.dart b/lib/foundation/image_provider/cached_image.dart index 90dbbf0..daf1338 100644 --- a/lib/foundation/image_provider/cached_image.dart +++ b/lib/foundation/image_provider/cached_image.dart @@ -1,4 +1,5 @@ import 'dart:async' show Future, StreamController; +import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/network/images.dart'; @@ -8,6 +9,8 @@ import 'cached_image.dart' as image_provider; class CachedImageProvider extends BaseImageProvider { /// Image provider for normal image. + /// + /// [url] is the url of the image. Local file path is also supported. const CachedImageProvider(this.url, {this.headers, this.sourceKey, this.cid}); final String url; @@ -20,6 +23,10 @@ class CachedImageProvider @override Future load(StreamController chunkEvents) async { + if(url.startsWith("file://")) { + var file = File(url.substring(7)); + return file.readAsBytes(); + } await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { chunkEvents.add(ImageChunkEvent( cumulativeBytesLoaded: progress.currentBytes, diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index 88eae18..206ffa2 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'package:crypto/crypto.dart'; +import 'package:dio/io.dart'; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html; import 'package:html/dom.dart' as dom; @@ -184,7 +185,23 @@ class JsEngine with _JSEngineApi { if (headers["user-agent"] == null && headers["User-Agent"] == null) { headers["User-Agent"] = webUA; } - response = await _dio!.request(req["url"], + var dio = _dio; + if (headers['http_client'] == "dart:io") { + dio = Dio(BaseOptions( + responseType: ResponseType.plain, + validateStatus: (status) => true, + )); + var proxy = await AppDio.getProxy(); + dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () { + return HttpClient() + ..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy"; + }, + ); + dio.interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); + dio.interceptors.add(LogInterceptor()); + } + response = await dio!.request(req["url"], data: req["data"], options: Options( method: req['http_method'], diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index 233b932..45eca7d 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -97,6 +97,9 @@ class MyLogInterceptor implements Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + Log.info("Network", "${options.method} ${options.uri}\n" + "headers:\n${options.headers}\n" + "data:\n${options.data}"); options.connectTimeout = const Duration(seconds: 15); options.receiveTimeout = const Duration(seconds: 15); options.sendTimeout = const Duration(seconds: 15); diff --git a/lib/network/download.dart b/lib/network/download.dart index e68fa26..1d0683e 100644 --- a/lib/network/download.dart +++ b/lib/network/download.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:isolate'; import 'package:flutter/widgets.dart' show ChangeNotifier; import 'package:venera/foundation/appdata.dart'; @@ -11,13 +12,14 @@ import 'package:venera/network/images.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/file_type.dart'; import 'package:venera/utils/io.dart'; +import 'package:zip_flutter/zip_flutter.dart'; + +import 'file_downloader.dart'; abstract class DownloadTask with ChangeNotifier { /// 0-1 double get progress; - bool get isComplete; - bool get isError; bool get isPaused; @@ -106,10 +108,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { } @override - String? get cover => _cover; - - @override - bool get isComplete => _totalCount == _downloadedCount; + String? get cover => _cover ?? comic?.cover; @override String get message => _message; @@ -159,7 +158,8 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { var tasks = {}; - int get _maxConcurrentTasks => (appdata.settings["downloadThreads"] as num).toInt(); + int get _maxConcurrentTasks => + (appdata.settings["downloadThreads"] as num).toInt(); void _scheduleTasks() { var images = _images![_images!.keys.elementAt(_chapter)]!; @@ -268,7 +268,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { var fileType = detectFileType(data); var file = File(FilePath.join(path!, "cover${fileType.ext}")); file.writeAsBytesSync(data); - return file.path; + return "file://${file.path}"; }); if (res.error) { _setError("Error: ${res.errorMessage}"); @@ -382,7 +382,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { int get speed => currentSpeed; @override - String get title => comic?.title ?? comicTitle ?? "Loading..."; + String get title => comic?.title ?? comicTitle ?? "Loading..."; @override Map toJson() { @@ -448,7 +448,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { }).toList(), directory: Directory(path!).name, chapters: comic!.chapters, - cover: File(_cover!).uri.pathSegments.last, + cover: File(_cover!.split("file://").last).uri.pathSegments.last, comicType: ComicType(source.key.hashCode), downloadedChapters: chapters ?? [], createdAt: DateTime.now(), @@ -577,7 +577,7 @@ abstract mixin class _TransferSpeedMixin { void onData(int length) { if (timer == null) return; - if(length < 0) { + if (length < 0) { return; } _bytesSinceLastSecond += length; @@ -603,3 +603,217 @@ abstract mixin class _TransferSpeedMixin { _bytesSinceLastSecond = 0; } } + +class ArchiveDownloadTask extends DownloadTask { + final String archiveUrl; + + final ComicDetails comic; + + late ComicSource source; + + /// Download comic by archive url + /// + /// Currently only support zip file and comics without chapters + ArchiveDownloadTask(this.archiveUrl, this.comic) { + source = ComicSource.find(comic.sourceKey)!; + } + + FileDownloader? _downloader; + + String _message = "Fetching comic info..."; + + bool _isRunning = false; + + bool _isError = false; + + void _setError(String message) { + _isRunning = false; + _isError = true; + _message = message; + notifyListeners(); + Log.error("Download", message); + } + + @override + void cancel() async { + _isRunning = false; + await _downloader?.stop(); + if (path != null) { + Directory(path!).deleteIgnoreError(recursive: true); + } + path = null; + LocalManager().removeTask(this); + } + + @override + ComicType get comicType => ComicType(source.key.hashCode); + + @override + String? get cover => comic.cover; + + @override + String get id => comic.id; + + @override + bool get isError => _isError; + + @override + bool get isPaused => !_isRunning; + + @override + String get message => _message; + + int _currentBytes = 0; + + int _expectedBytes = 0; + + int _speed = 0; + + @override + void pause() { + _isRunning = false; + _message = "Paused"; + _downloader?.stop(); + notifyListeners(); + } + + @override + double get progress => + _expectedBytes == 0 ? 0 : _currentBytes / _expectedBytes; + + @override + void resume() async { + if (_isRunning) { + return; + } + _isError = false; + _isRunning = true; + notifyListeners(); + _message = "Downloading..."; + + if (path == null) { + var dir = await LocalManager().findValidDirectory( + comic.id, + comicType, + comic.title, + ); + if (!(await dir.exists())) { + try { + await dir.create(); + } catch (e) { + _setError("Error: $e"); + return; + } + } + path = dir.path; + } + + var resultFile = File(FilePath.join(path!, "archive.zip")); + + Log.info("Download", "Downloading $archiveUrl"); + + _downloader = FileDownloader(archiveUrl, resultFile.path); + + bool isDownloaded = false; + + try { + await for (var status in _downloader!.start()) { + _currentBytes = status.downloadedBytes; + _expectedBytes = status.totalBytes; + _message = + "${bytesToReadableString(_currentBytes)}/${bytesToReadableString(_expectedBytes)}"; + _speed = status.bytesPerSecond; + isDownloaded = status.isFinished; + notifyListeners(); + } + } + catch(e) { + _setError("Error: $e"); + return; + } + + if (!_isRunning) { + return; + } + + if (!isDownloaded) { + _setError("Error: Download failed"); + return; + } + + try { + await extractArchive(path!); + } catch (e) { + _setError("Failed to extract archive: $e"); + return; + } + + await resultFile.deleteIgnoreError(); + + LocalManager().completeTask(this); + } + + static Future extractArchive(String path) async { + var resultFile = FilePath.join(path, "archive.zip"); + await Isolate.run(() { + ZipFile.openAndExtract(resultFile, path); + }); + } + + @override + int get speed => _speed; + + @override + String get title => comic.title; + + @override + Map toJson() { + return { + "type": "ArchiveDownloadTask", + "archiveUrl": archiveUrl, + "comic": comic.toJson(), + "path": path, + }; + } + + static ArchiveDownloadTask? fromJson(Map json) { + if (json["type"] != "ArchiveDownloadTask") { + return null; + } + return ArchiveDownloadTask( + json["archiveUrl"], + ComicDetails.fromJson(json["comic"]), + )..path = json["path"]; + } + + String _findCover() { + var files = Directory(path!).listSync(); + for (var f in files) { + if (f.name.startsWith('cover')) { + return f.name; + } + } + files.sort((a, b) { + return a.name.compareTo(b.name); + }); + return files.first.name; + } + + @override + LocalComic toLocalComic() { + return LocalComic( + id: comic.id, + title: title, + subtitle: comic.subTitle ?? '', + tags: comic.tags.entries.expand((e) { + return e.value.map((v) => "${e.key}:$v"); + }).toList(), + directory: Directory(path!).name, + chapters: null, + cover: _findCover(), + comicType: ComicType(source.key.hashCode), + downloadedChapters: [], + createdAt: DateTime.now(), + ); + } +} diff --git a/lib/network/file_downloader.dart b/lib/network/file_downloader.dart new file mode 100644 index 0000000..3d91643 --- /dev/null +++ b/lib/network/file_downloader.dart @@ -0,0 +1,298 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/io.dart'; +import 'package:venera/network/app_dio.dart'; +import 'package:venera/utils/ext.dart'; + +class FileDownloader { + final String url; + final String savePath; + final int maxConcurrent; + + FileDownloader(this.url, this.savePath, {this.maxConcurrent = 4}); + + int _currentBytes = 0; + + int _lastBytes = 0; + + late int _fileSize; + + final _dio = Dio(); + + RandomAccessFile? _file; + + bool _isWriting = false; + + int _kChunkSize = 16 * 1024 * 1024; + + bool _canceled = false; + + late List<_DownloadBlock> _blocks; + + Future _writeStatus() async { + var file = File("$savePath.download"); + await file.writeAsString(_blocks.map((e) => e.toString()).join("\n")); + } + + Future _readStatus() async { + var file = File("$savePath.download"); + if (!await file.exists()) { + return; + } + + var lines = await file.readAsLines(); + _blocks = lines.map((e) => _DownloadBlock.fromString(e)).toList(); + } + + /// create file and write empty bytes + Future _prepareFile() async { + var file = File(savePath); + if (await file.exists()) { + if (file.lengthSync() == _fileSize && + File("$savePath.download").existsSync()) { + _file = await file.open(mode: FileMode.append); + return; + } else { + await file.delete(); + } + } + + await file.create(recursive: true); + _file = await file.open(mode: FileMode.append); + await _file!.truncate(_fileSize); + } + + Future _createTasks() async { + var res = await _dio.head(url); + var length = res.headers["content-length"]?.first; + _fileSize = length == null ? 0 : int.parse(length); + + await _prepareFile(); + + if (File("$savePath.download").existsSync()) { + await _readStatus(); + _currentBytes = _blocks.fold(0, + (previousValue, element) => previousValue + element.downloadedBytes); + } else { + if (_fileSize > 1024 * 1024 * 1024) { + _kChunkSize = 64 * 1024 * 1024; + } else if (_fileSize > 512 * 1024 * 1024) { + _kChunkSize = 32 * 1024 * 1024; + } + + _blocks = []; + for (var i = 0; i < _fileSize; i += _kChunkSize) { + var end = i + _kChunkSize; + if (end > _fileSize) { + _blocks.add(_DownloadBlock(i, _fileSize, 0, false)); + } else { + _blocks.add(_DownloadBlock(i, i + _kChunkSize, 0, false)); + } + } + } + } + + Stream start() { + var stream = StreamController(); + _download(stream); + return stream.stream; + } + + void _reportStatus(StreamController stream) { + stream.add(DownloadingStatus(_currentBytes, _fileSize, 0)); + } + + void _download(StreamController resultStream) async { + try { + var proxy = await AppDio.getProxy(); + _dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () { + return HttpClient() + ..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy"; + }, + ); + + // get file size + await _createTasks(); + + if (_canceled) return; + + // check if file is downloaded + if (_currentBytes >= _fileSize) { + await _file!.close(); + _file = null; + _reportStatus(resultStream); + resultStream.close(); + return; + } + + _reportStatus(resultStream); + + Timer.periodic(const Duration(seconds: 1), (timer) { + if (_canceled || _currentBytes >= _fileSize) { + timer.cancel(); + return; + } + resultStream.add(DownloadingStatus( + _currentBytes, _fileSize, _currentBytes - _lastBytes)); + _lastBytes = _currentBytes; + }); + + // start downloading + await _scheduleDownload(); + if (_canceled) { + resultStream.close(); + return; + } + await _file!.close(); + _file = null; + await File("$savePath.download").delete(); + + // check if download is finished + if (_currentBytes < _fileSize) { + resultStream + .addError(Exception("Download failed: Expected $_fileSize bytes, " + "but only $_currentBytes bytes downloaded.")); + resultStream.close(); + } + + resultStream.add(DownloadingStatus(_currentBytes, _fileSize, 0, true)); + resultStream.close(); + } catch (e, s) { + await _file?.close(); + _file = null; + resultStream.addError(e, s); + resultStream.close(); + } + } + + Future _scheduleDownload() async { + var tasks = []; + while (true) { + if (_canceled) return; + if (tasks.length >= maxConcurrent) { + await Future.any(tasks); + } + final block = _blocks.firstWhereOrNull((element) => + !element.downloading && + element.end - element.start > element.downloadedBytes); + if (block == null) { + break; + } + block.downloading = true; + var task = _fetchBlock(block); + task.then((value) => tasks.remove(task), onError: (e) { + if(_canceled) return; + throw e; + }); + tasks.add(task); + } + await Future.wait(tasks); + } + + Future _fetchBlock(_DownloadBlock block) async { + final start = block.start; + final end = block.end; + + if (start > _fileSize) { + return; + } + + var options = Options( + responseType: ResponseType.stream, + headers: { + "Range": "bytes=${start + block.downloadedBytes}-${end - 1}", + "Accept": "*/*", + "Accept-Encoding": "deflate, gzip", + }, + preserveHeaderCase: true, + ); + var res = await _dio.get(url, options: options); + if (_canceled) return; + if (res.data == null) { + throw Exception("Failed to block $start-$end"); + } + + var buffer = []; + await for (var data in res.data!.stream) { + if (_canceled) return; + buffer.addAll(data); + if (buffer.length > 16 * 1024) { + if (_isWriting) continue; + _currentBytes += buffer.length; + _isWriting = true; + await _file!.setPosition(start + block.downloadedBytes); + await _file!.writeFrom(buffer); + block.downloadedBytes += buffer.length; + buffer.clear(); + await _writeStatus(); + _isWriting = false; + } + } + + if (buffer.isNotEmpty) { + while (_isWriting) { + await Future.delayed(const Duration(milliseconds: 10)); + } + _isWriting = true; + _currentBytes += buffer.length; + await _file!.setPosition(start + block.downloadedBytes); + await _file!.writeFrom(buffer); + block.downloadedBytes += buffer.length; + await _writeStatus(); + _isWriting = false; + } + + block.downloading = false; + } + + Future stop() async { + _canceled = true; + await _file?.close(); + _file = null; + } +} + +class DownloadingStatus { + /// The current downloaded bytes + final int downloadedBytes; + + /// The total bytes of the file + final int totalBytes; + + /// Whether the download is finished + final bool isFinished; + + /// The download speed in bytes per second + final int bytesPerSecond; + + const DownloadingStatus( + this.downloadedBytes, this.totalBytes, this.bytesPerSecond, + [this.isFinished = false]); + + @override + String toString() { + return "Downloaded: $downloadedBytes/$totalBytes ${isFinished ? "Finished" : ""}"; + } +} + +class _DownloadBlock { + final int start; + final int end; + int downloadedBytes; + bool downloading; + + _DownloadBlock(this.start, this.end, this.downloadedBytes, this.downloading); + + @override + String toString() { + return "$start-$end-$downloadedBytes"; + } + + _DownloadBlock.fromString(String str) + : start = int.parse(str.split("-")[0]), + end = int.parse(str.split("-")[1]), + downloadedBytes = int.parse(str.split("-")[2]), + downloading = false; +} diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 6e8f68a..b17906e 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -682,6 +682,122 @@ abstract mixin class _ComicPageActions { App.rootContext.showMessage(message: "The comic is downloaded".tl); return; } + + if (comicSource.archiveDownloader != null) { + bool useNormalDownload = false; + List? archives; + int selected = -1; + bool isLoading = false; + bool isGettingLink = false; + await showDialog( + context: App.rootContext, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return ContentDialog( + title: "Download".tl, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + value: -1, + groupValue: selected, + title: Text("Normal".tl), + onChanged: (v) { + setState(() { + selected = v!; + }); + }, + ), + ExpansionTile( + title: Text("Archive".tl), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + collapsedShape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + onExpansionChanged: (b) { + if (!isLoading && b && archives == null) { + isLoading = true; + comicSource.archiveDownloader! + .getArchives(comic.id) + .then((value) { + if (value.success) { + archives = value.data; + } else { + App.rootContext + .showMessage(message: value.errorMessage!); + } + setState(() { + isLoading = false; + }); + }); + } + }, + children: [ + if (archives == null) + const ListLoadingIndicator().toCenter() + else + for (int i = 0; i < archives!.length; i++) + RadioListTile( + value: i, + groupValue: selected, + onChanged: (v) { + setState(() { + selected = v!; + }); + }, + title: Text(archives![i].title), + subtitle: Text(archives![i].description), + ) + ], + ) + ], + ), + actions: [ + Button.filled( + isLoading: isGettingLink, + onPressed: () async { + if (selected == -1) { + useNormalDownload = true; + context.pop(); + return; + } + setState(() { + isGettingLink = true; + }); + var res = + await comicSource.archiveDownloader!.getDownloadUrl( + comic.id, + archives![selected].id, + ); + if (res.error) { + App.rootContext.showMessage(message: res.errorMessage!); + setState(() { + isGettingLink = false; + }); + } else if (context.mounted) { + LocalManager() + .addTask(ArchiveDownloadTask(res.data, comic)); + App.rootContext + .showMessage(message: "Download started".tl); + context.pop(); + } + }, + child: Text("Confirm".tl), + ), + ], + ); + }, + ); + }, + ); + if (!useNormalDownload) { + return; + } + } + if (comic.chapters == null) { LocalManager().addTask(ImagesDownloadTask( source: comicSource, diff --git a/lib/pages/downloading_page.dart b/lib/pages/downloading_page.dart index f066184..790e482 100644 --- a/lib/pages/downloading_page.dart +++ b/lib/pages/downloading_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/image_provider/cached_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/network/download.dart'; import 'package:venera/utils/io.dart'; @@ -161,8 +162,8 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> { clipBehavior: Clip.antiAlias, child: widget.task.cover == null ? null - : Image.file( - File(widget.task.cover!), + : Image( + image: CachedImageProvider(widget.task.cover!), filterQuality: FilterQuality.medium, fit: BoxFit.cover, ), @@ -206,6 +207,7 @@ class _DownloadTaskTileState extends State<_DownloadTaskTile> { Text( widget.task.message, style: ts.s12, + maxLines: 3, ), const SizedBox(height: 4), LinearProgressIndicator( From 4d55e6a72f2dc66c585cd022dcd723439efa2586 Mon Sep 17 00:00:00 2001 From: nyne Date: Thu, 21 Nov 2024 21:36:08 +0800 Subject: [PATCH 11/28] move checkUpdates to main_page --- assets/translation.json | 10 ++++++++-- lib/main.dart | 19 ------------------- lib/pages/main_page.dart | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/assets/translation.json b/assets/translation.json index 0825140..b7e6389 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -232,7 +232,10 @@ "No Category Pages": "没有分类页面", "Chapter @ep": "第 @ep 章", "Page @page": "第 @page 页", - "Also remove files on disk": "同时删除磁盘上的文件" + "Also remove files on disk": "同时删除磁盘上的文件", + "New version available": "有新版本可用", + "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", + "No new version available": "没有新版本可用" }, "zh_TW": { "Home": "首頁", @@ -467,6 +470,9 @@ "No Category Pages": "沒有分類頁面", "Chapter @ep": "第 @ep 章", "Page @page": "第 @page 頁", - "Also remove files on disk": "同時刪除磁盤上的文件" + "Also remove files on disk": "同時刪除磁盤上的文件", + "New version available": "有新版本可用", + "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", + "No new version available": "沒有新版本可用" } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index f4fb9c8..d2e827d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,9 +6,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:rhttp/rhttp.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/pages/auth_page.dart'; -import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/main_page.dart'; -import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/io.dart'; import 'package:window_manager/window_manager.dart'; @@ -69,7 +67,6 @@ class MyApp extends StatefulWidget { class _MyAppState extends State with WidgetsBindingObserver { @override void initState() { - checkUpdates(); App.registerForceRebuild(forceRebuild); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); WidgetsBinding.instance.addObserver(this); @@ -227,22 +224,6 @@ class _MyAppState extends State with WidgetsBindingObserver { }, ); } - - void checkUpdates() async { - if (!appdata.settings['checkUpdateOnStart']) { - return; - } - var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; - var now = DateTime.now().millisecondsSinceEpoch; - if (now - lastCheck < 24 * 60 * 60 * 1000) { - return; - } - appdata.implicitData['lastCheckUpdate'] = now; - appdata.writeImplicitData(); - await Future.delayed(const Duration(milliseconds: 300)); - await checkUpdateUi(false); - await ComicSourcePage.checkComicSourceUpdate(true); - } } class _SystemUiProvider extends StatelessWidget { diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index 4610da6..3cba828 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:venera/foundation/appdata.dart'; import 'package:venera/pages/categories_page.dart'; import 'package:venera/pages/search_page.dart'; import 'package:venera/pages/settings/settings_page.dart'; @@ -6,6 +7,7 @@ import 'package:venera/utils/translations.dart'; import '../components/components.dart'; import '../foundation/app.dart'; +import 'comic_source_page.dart'; import 'explore_page.dart'; import 'favorites/favorites_page.dart'; import 'home_page.dart'; @@ -34,8 +36,25 @@ class _MainPageState extends State { _navigatorKey!.currentContext!.pop(); } + void checkUpdates() async { + if (!appdata.settings['checkUpdateOnStart']) { + return; + } + var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; + var now = DateTime.now().millisecondsSinceEpoch; + if (now - lastCheck < 24 * 60 * 60 * 1000) { + return; + } + appdata.implicitData['lastCheckUpdate'] = now; + appdata.writeImplicitData(); + await Future.delayed(const Duration(milliseconds: 300)); + await checkUpdateUi(false); + await ComicSourcePage.checkComicSourceUpdate(true); + } + @override void initState() { + checkUpdates(); _observer = NaviObserver(); _navigatorKey = GlobalKey(); App.mainNavigatorKey = _navigatorKey; From f4b9cb5abeeff2b2285266f78de4df5df066d432 Mon Sep 17 00:00:00 2001 From: nyne Date: Thu, 21 Nov 2024 21:38:41 +0800 Subject: [PATCH 12/28] `limitImageWidth` should only be enabled with `ReaderMode.continuousTopToBottom` --- lib/pages/reader/images.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index 11be666..d273eca 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -223,7 +223,7 @@ class _GalleryModeState extends State<_GalleryMode> @override void handleLongPressDown(Offset location) { - if(!appdata.settings['enableLongPressToZoom']) { + if (!appdata.settings['enableLongPressToZoom']) { return; } var photoViewController = photoViewControllers[reader.page]!; @@ -237,7 +237,7 @@ class _GalleryModeState extends State<_GalleryMode> @override void handleLongPressUp(Offset location) { - if(!appdata.settings['enableLongPressToZoom']) { + if (!appdata.settings['enableLongPressToZoom']) { return; } var photoViewController = photoViewControllers[reader.page]!; @@ -473,7 +473,9 @@ class _ContinuousModeState extends State<_ContinuousMode> ); var width = MediaQuery.of(context).size.width; var height = MediaQuery.of(context).size.height; - if(appdata.settings['limitImageWidth'] && width / height > 0.7) { + if (appdata.settings['limitImageWidth'] && + width / height > 0.7 && + reader.mode == ReaderMode.continuousTopToBottom) { width = height * 0.7; } @@ -521,7 +523,7 @@ class _ContinuousModeState extends State<_ContinuousMode> @override void handleLongPressDown(Offset location) { - if(!appdata.settings['enableLongPressToZoom']) { + if (!appdata.settings['enableLongPressToZoom']) { return; } double target = photoViewController.getInitialScale!.call()! * 1.75; @@ -534,7 +536,7 @@ class _ContinuousModeState extends State<_ContinuousMode> @override void handleLongPressUp(Offset location) { - if(!appdata.settings['enableLongPressToZoom']) { + if (!appdata.settings['enableLongPressToZoom']) { return; } double target = photoViewController.getInitialScale!.call()!; From f3aa0e9f270996f44f03b139384af89cd1c07abe Mon Sep 17 00:00:00 2001 From: nyne Date: Fri, 22 Nov 2024 10:24:31 +0800 Subject: [PATCH 13/28] fix comment --- lib/pages/comic_page.dart | 18 +++++++++++------- lib/pages/comments_page.dart | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index b17906e..5833610 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -1863,13 +1863,17 @@ class _CommentsPartState extends State<_CommentsPart> { children: [ SizedBox( height: 184, - child: ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - itemCount: comments.length, - itemBuilder: (context, index) { - return _CommentWidget(comment: comments[index]); - }, + child: MediaQuery.removePadding( + removeTop: true, + context: context, + child: ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + itemCount: comments.length, + itemBuilder: (context, index) { + return _CommentWidget(comment: comments[index]); + }, + ), ), ), const SizedBox(height: 8), diff --git a/lib/pages/comments_page.dart b/lib/pages/comments_page.dart index 74ad4f8..4196a4d 100644 --- a/lib/pages/comments_page.dart +++ b/lib/pages/comments_page.dart @@ -595,7 +595,7 @@ class _Tag { static void handleLink(String link) async { if (link.isURL) { if (await handleAppLink(Uri.parse(link))) { - App.rootContext.pop(); + Navigator.of(App.rootContext).maybePop(); } else { launchUrlString(link); } From 8b1f13cd339ffd1f13386f4881580494aa38312f Mon Sep 17 00:00:00 2001 From: AnxuNA <41771421+axlmly@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:21:22 +0800 Subject: [PATCH 14/28] Add Favorite multiple selections (#66) --- assets/translation.json | 8 +- lib/components/comic.dart | 47 +- lib/foundation/favorites.dart | 60 +- lib/pages/favorites/favorites_page.dart | 1 + lib/pages/favorites/local_favorites_page.dart | 717 +++++++++++++----- 5 files changed, 614 insertions(+), 219 deletions(-) diff --git a/assets/translation.json b/assets/translation.json index b7e6389..d8226a3 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -235,7 +235,9 @@ "Also remove files on disk": "同时删除磁盘上的文件", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", - "No new version available": "没有新版本可用" + "No new version available": "没有新版本可用", + "Move": "移动", + "Move to favorites": "移动至收藏" }, "zh_TW": { "Home": "首頁", @@ -473,6 +475,8 @@ "Also remove files on disk": "同時刪除磁盤上的文件", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", - "No new version available": "沒有新版本可用" + "No new version available": "沒有新版本可用", + "Move": "移動", + "Move to favorites": "移動至收藏" } } \ No newline at end of file diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 08a95bc..488fd70 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -1,14 +1,14 @@ part of 'components.dart'; class ComicTile extends StatelessWidget { - const ComicTile({ - super.key, - required this.comic, - this.enableLongPressed = true, - this.badge, - this.menuOptions, - this.onTap, - }); + const ComicTile( + {super.key, + required this.comic, + this.enableLongPressed = true, + this.badge, + this.menuOptions, + this.onTap, + this.onLongPressed}); final Comic comic; @@ -20,6 +20,8 @@ class ComicTile extends StatelessWidget { final VoidCallback? onTap; + final VoidCallback? onLongPressed; + void _onTap() { if (onTap != null) { onTap!(); @@ -29,6 +31,14 @@ class ComicTile extends StatelessWidget { ?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); } + void _onLongPressed(context) { + if (onLongPressed != null) { + onLongPressed!(); + return; + } + onLongPress(context); + } + void onLongPress(BuildContext context) { var renderBox = context.findRenderObject() as RenderBox; var size = renderBox.size; @@ -181,7 +191,7 @@ class ComicTile extends StatelessWidget { return InkWell( borderRadius: BorderRadius.circular(12), onTap: _onTap, - onLongPress: enableLongPressed ? () => onLongPress(context) : null, + onLongPress: enableLongPressed ? () => _onLongPressed(context) : null, onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), @@ -231,7 +241,7 @@ class ComicTile extends StatelessWidget { borderRadius: BorderRadius.circular(8), onTap: _onTap, onLongPress: - enableLongPressed ? () => onLongPress(context) : null, + enableLongPressed ? () => _onLongPressed(context) : null, onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), child: Column( children: [ @@ -644,6 +654,7 @@ class SliverGridComics extends StatefulWidget { this.badgeBuilder, this.menuBuilder, this.onTap, + this.onLongPressed, this.selections}); final List comics; @@ -658,6 +669,8 @@ class SliverGridComics extends StatefulWidget { final void Function(Comic)? onTap; + final void Function(Comic)? onLongPressed; + @override State createState() => _SliverGridComicsState(); } @@ -708,6 +721,7 @@ class _SliverGridComicsState extends State { badgeBuilder: widget.badgeBuilder, menuBuilder: widget.menuBuilder, onTap: widget.onTap, + onLongPressed: widget.onLongPressed, ); } } @@ -719,6 +733,7 @@ class _SliverGridComics extends StatelessWidget { this.badgeBuilder, this.menuBuilder, this.onTap, + this.onLongPressed, this.selection, }); @@ -734,6 +749,8 @@ class _SliverGridComics extends StatelessWidget { final void Function(Comic)? onTap; + final void Function(Comic)? onLongPressed; + @override Widget build(BuildContext context) { return SliverGrid( @@ -750,14 +767,18 @@ class _SliverGridComics extends StatelessWidget { badge: badge, menuOptions: menuBuilder?.call(comics[index]), onTap: onTap != null ? () => onTap!(comics[index]) : null, + onLongPressed: onLongPressed != null + ? () => onLongPressed!(comics[index]) + : null, ); - if(selection == null) { + if (selection == null) { return comic; } - return Container( + return AnimatedContainer( + duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: isSelected - ? Theme.of(context).colorScheme.surfaceContainer + ? Theme.of(context).colorScheme.secondaryContainer : null, borderRadius: BorderRadius.circular(12), ), diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index 1ce41e7..e1a60fc 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -12,10 +12,7 @@ import 'comic_source/comic_source.dart'; import 'comic_type.dart'; String _getTimeString(DateTime time) { - return time - .toIso8601String() - .replaceFirst("T", " ") - .substring(0, 19); + return time.toIso8601String().replaceFirst("T", " ").substring(0, 19); } class FavoriteItem implements Comic { @@ -29,15 +26,14 @@ class FavoriteItem implements Comic { String coverPath; late String time; - FavoriteItem({ - required this.id, - required this.name, - required this.coverPath, - required this.author, - required this.type, - required this.tags, - DateTime? favoriteTime - }) { + FavoriteItem( + {required this.id, + required this.name, + required this.coverPath, + required this.author, + required this.type, + required this.tags, + DateTime? favoriteTime}) { var t = favoriteTime ?? DateTime.now(); time = _getTimeString(t); } @@ -355,7 +351,8 @@ class LocalFavoritesManager with ChangeNotifier { """, [folder, source, networkFolder]); } - bool isLinkedToNetworkFolder(String folder, String source, String networkFolder) { + bool isLinkedToNetworkFolder( + String folder, String source, String networkFolder) { var res = _db.select(""" select * from folder_sync where folder_name == ? and source_key == ? and source_folder == ?; @@ -436,6 +433,41 @@ class LocalFavoritesManager with ChangeNotifier { return true; } + void moveFavorite( + String sourceFolder, String targetFolder, String id, ComicType type) { + _modifiedAfterLastCache = true; + + if (!existsFolder(sourceFolder)) { + throw Exception("Source folder does not exist"); + } + if (!existsFolder(targetFolder)) { + throw Exception("Target folder does not exist"); + } + + var res = _db.select(""" + select * from "$targetFolder" + where id == ? and type == ?; + """, [id, type.value]); + + if (res.isNotEmpty) { + return; + } + + _db.execute(""" + insert into "$targetFolder" (id, name, author, type, tags, cover_path, time, display_order) + select id, name, author, type, tags, cover_path, time, ? + from "$sourceFolder" + where id == ? and type == ?; + """, [minValue(targetFolder) - 1, id, type.value]); + + _db.execute(""" + delete from "$sourceFolder" + where id == ? and type == ?; + """, [id, type.value]); + + notifyListeners(); + } + /// delete a folder void deleteFolder(String name) { _modifiedAfterLastCache = true; diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index f815506..afdf3b6 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -13,6 +13,7 @@ import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/download.dart'; +import 'package:venera/pages/comic_page.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index 628ed63..1ca103b 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -17,10 +17,30 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { String? networkSource; String? networkFolder; + Map selectedComics = {}; + + var selectedLocalFolders = {}; + + late List added = []; + + String keyword = ""; + + bool searchMode = false; + + bool multiSelectMode = false; + + int? lastSelectedIndex; + void updateComics() { - setState(() { - comics = LocalFavoritesManager().getAllComics(widget.folder); - }); + if (keyword.isEmpty) { + setState(() { + comics = LocalFavoritesManager().getAllComics(widget.folder); + }); + } else { + setState(() { + comics = LocalFavoritesManager().search(keyword); + }); + } } @override @@ -35,216 +55,533 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { @override Widget build(BuildContext context) { - return SmoothCustomScrollView( - slivers: [ - SliverAppbar( - leading: Tooltip( - message: "Folders".tl, - child: context.width <= _kTwoPanelChangeWidth - ? IconButton( - icon: const Icon(Icons.menu), - color: context.colorScheme.primary, - onPressed: favPage.showFolderSelector, - ) - : const SizedBox(), - ), - title: GestureDetector( - onTap: context.width < _kTwoPanelChangeWidth - ? favPage.showFolderSelector - : null, - child: Text(favPage.folder ?? "Unselected".tl), - ), - actions: [ - if (networkSource != null) + void selectAll() { + setState(() { + selectedComics = comics.asMap().map((k, v) => MapEntry(v, true)); + }); + } + + void invertSelection() { + setState(() { + comics.asMap().forEach((k, v) { + selectedComics[v] = !selectedComics.putIfAbsent(v, () => false); + }); + selectedComics.removeWhere((k, v) => !v); + }); + } + + var body = Scaffold( + body: SmoothCustomScrollView(slivers: [ + if (!searchMode && !multiSelectMode) + SliverAppbar( + leading: Tooltip( + message: "Folders".tl, + child: context.width <= _kTwoPanelChangeWidth + ? IconButton( + icon: const Icon(Icons.menu), + color: context.colorScheme.primary, + onPressed: favPage.showFolderSelector, + ) + : const SizedBox(), + ), + title: GestureDetector( + onTap: context.width < _kTwoPanelChangeWidth + ? favPage.showFolderSelector + : null, + child: Text(favPage.folder ?? "Unselected".tl), + ), + actions: [ + if (networkSource != null) + Tooltip( + message: "Sync".tl, + child: Flyout( + flyoutBuilder: (context) { + var sourceName = ComicSource.find(networkSource!)?.name ?? + networkSource!; + var text = "The folder is Linked to @source".tlParams({ + "source": sourceName, + }); + if (networkFolder != null && networkFolder!.isNotEmpty) { + text += "\n${"Source Folder".tl}: $networkFolder"; + } + return FlyoutContent( + title: "Sync".tl, + content: Text(text), + actions: [ + Button.filled( + child: Text("Update".tl), + onPressed: () { + context.pop(); + importNetworkFolder( + networkSource!, + widget.folder, + networkFolder!, + ).then( + (value) { + updateComics(); + }, + ); + }, + ), + ], + ); + }, + child: Builder(builder: (context) { + return IconButton( + icon: const Icon(Icons.sync), + onPressed: () { + Flyout.of(context).show(); + }, + ); + }), + ), + ), Tooltip( - message: "Sync".tl, - child: Flyout( - flyoutBuilder: (context) { - var sourceName = ComicSource.find(networkSource!)?.name ?? - networkSource!; - var text = "The folder is Linked to @source".tlParams({ - "source": sourceName, + message: "Search".tl, + child: IconButton( + icon: const Icon(Icons.search), + onPressed: () { + setState(() { + searchMode = true; }); - if(networkFolder != null && networkFolder!.isNotEmpty) { - text += "\n${"Source Folder".tl}: $networkFolder"; - } - return FlyoutContent( - title: "Sync".tl, - content: Text(text), - actions: [ - Button.filled( - child: Text("Update".tl), - onPressed: () { - context.pop(); - importNetworkFolder( - networkSource!, + }, + ), + ), + MenuButton( + entries: [ + MenuEntry( + icon: Icons.delete_outline, + text: "Delete Folder".tl, + onClick: () { + showConfirmDialog( + context: App.rootContext, + title: "Delete".tl, + content: + "Are you sure you want to delete this folder?".tl, + onConfirm: () { + favPage.setFolder(false, null); + LocalFavoritesManager().deleteFolder(widget.folder); + favPage.folderList?.updateFolders(); + }, + ); + }), + MenuEntry( + icon: Icons.edit_outlined, + text: "Rename".tl, + onClick: () { + showInputDialog( + context: App.rootContext, + title: "Rename".tl, + hintText: "New Name".tl, + onConfirm: (value) { + var err = validateFolderName(value.toString()); + if (err != null) { + return err; + } + LocalFavoritesManager().rename( widget.folder, - networkFolder!, - ).then( - (value) { - updateComics(); + value.toString(), + ); + favPage.folderList?.updateFolders(); + favPage.setFolder(false, value.toString()); + return null; + }, + ); + }), + MenuEntry( + icon: Icons.reorder, + text: "Reorder".tl, + onClick: () { + context.to( + () { + return _ReorderComicsPage( + widget.folder, + (comics) { + this.comics = comics; }, ); }, - ), - ], - ); - }, - child: Builder(builder: (context) { - return IconButton( - icon: const Icon(Icons.sync), - onPressed: () { - Flyout.of(context).show(); - }, - ); - }), - ), + ).then( + (value) { + if (mounted) { + setState(() {}); + } + }, + ); + }), + MenuEntry( + icon: Icons.upload_file, + text: "Export".tl, + onClick: () { + var json = LocalFavoritesManager().folderToJson( + widget.folder, + ); + saveFile( + data: utf8.encode(json), + filename: "${widget.folder}.json", + ); + }), + MenuEntry( + icon: Icons.update, + text: "Update Comics Info".tl, + onClick: () { + updateComicsInfo(widget.folder).then((newComics) { + if (mounted) { + setState(() { + comics = newComics; + }); + } + }); + }), + ], ), - MenuButton( - entries: [ + ], + ) + else if (multiSelectMode) + SliverAppbar( + leading: Tooltip( + message: "Cancel".tl, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + multiSelectMode = false; + selectedComics.clear(); + }); + }, + ), + ), + title: Text( + "Selected @c comics".tlParams({"c": selectedComics.length})), + actions: [ + MenuButton(entries: [ + MenuEntry( + icon: Icons.star, + text: "Add to favorites".tl, + onClick: () => favoriteOption('move')), + MenuEntry( + icon: Icons.drive_file_move, + text: "Move to favorites".tl, + onClick: () => favoriteOption('add')), + MenuEntry( + icon: Icons.select_all, + text: "Select All".tl, + onClick: selectAll), + MenuEntry( + icon: Icons.deselect, + text: "Deselect".tl, + onClick: _cancel), + MenuEntry( + icon: Icons.flip, + text: "Invert Selection".tl, + onClick: invertSelection), MenuEntry( icon: Icons.delete_outline, text: "Delete Folder".tl, onClick: () { showConfirmDialog( - context: App.rootContext, + context: context, title: "Delete".tl, content: - "Are you sure you want to delete this folder?".tl, + "Are you sure you want to delete this comic?".tl, onConfirm: () { - favPage.setFolder(false, null); - LocalFavoritesManager().deleteFolder(widget.folder); - favPage.folderList?.updateFolders(); + _deleteComicWithId(); }, ); }), - MenuEntry( - icon: Icons.edit_outlined, - text: "Rename".tl, - onClick: () { - showInputDialog( - context: App.rootContext, - title: "Rename".tl, - hintText: "New Name".tl, - onConfirm: (value) { - var err = validateFolderName(value.toString()); - if (err != null) { - return err; - } - LocalFavoritesManager().rename( - widget.folder, - value.toString(), - ); - favPage.folderList?.updateFolders(); - favPage.setFolder(false, value.toString()); - return null; - }, - ); - }), - MenuEntry( - icon: Icons.reorder, - text: "Reorder".tl, - onClick: () { - context.to( - () { - return _ReorderComicsPage( - widget.folder, - (comics) { - this.comics = comics; - }, - ); - }, - ).then( - (value) { - if (mounted) { - setState(() {}); - } - }, - ); - }), - MenuEntry( - icon: Icons.upload_file, - text: "Export".tl, - onClick: () { - var json = LocalFavoritesManager().folderToJson( - widget.folder, - ); - saveFile( - data: utf8.encode(json), - filename: "${widget.folder}.json", - ); - }), - MenuEntry( - icon: Icons.update, - text: "Update Comics Info".tl, - onClick: () { - updateComicsInfo(widget.folder).then((newComics) { - if (mounted) { - setState(() { - comics = newComics; - }); - } - }); - }), - MenuEntry( - icon: Icons.download, - text: "Download All".tl, - onClick: () async { - int count = 0; - for (var c in comics) { - if (await LocalManager().isDownloaded(c.id, c.type)) { - continue; - } - var comicSource = c.type.comicSource; - if (comicSource == null) { - continue; - } - LocalManager().addTask(ImagesDownloadTask( - source: comicSource, - comicId: c.id, - comic: null, - comicTitle: c.name, - )); - count++; - } - context.showMessage( - message: "Added @count comics to download queue." - .tlParams({ - "count": count.toString(), - })); - }), - ], - ), - ], - ), - SliverGridComics( - comics: comics, - menuBuilder: (c) { - return [ - MenuEntry( - icon: Icons.delete_outline, - text: "Delete".tl, - onClick: () { - showConfirmDialog( - context: context, - title: "Delete".tl, - content: "Are you sure you want to delete this comic?".tl, - onConfirm: () { - LocalFavoritesManager().deleteComicWithId( - widget.folder, - c.id, - (c as FavoriteItem).type, - ); - updateComics(); - }, - ); + ]), + ], + ) + else if (searchMode) + SliverAppbar( + leading: Tooltip( + message: "Cancel".tl, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() { + searchMode = false; + keyword = ""; + updateComics(); + }); }, ), - ]; + ), + title: TextField( + autofocus: true, + decoration: InputDecoration( + hintText: "Search".tl, + border: InputBorder.none, + ), + onChanged: (v) { + keyword = v; + updateComics(); + }, + ), + ), + SliverGridComics( + comics: comics, + selections: selectedComics, + onTap: multiSelectMode + ? (c) { + setState(() { + if (selectedComics.containsKey(c as FavoriteItem)) { + selectedComics.remove(c); + _checkExitSelectMode(); + } else { + selectedComics[c] = true; + } + lastSelectedIndex = comics.indexOf(c); + }); + } + : (c) { + App.mainNavigatorKey?.currentContext + ?.to(() => ComicPage(id: c.id, sourceKey: c.sourceKey)); + }, + onLongPressed: (c) { + setState(() { + if (!multiSelectMode) { + multiSelectMode = true; + if (!selectedComics.containsKey(c as FavoriteItem)) { + selectedComics[c] = true; + } + lastSelectedIndex = comics.indexOf(c); + } else { + if (lastSelectedIndex != null) { + int start = lastSelectedIndex!; + int end = comics.indexOf(c as FavoriteItem); + if (start > end) { + int temp = start; + start = end; + end = temp; + } + + for (int i = start; i <= end; i++) { + if (i == lastSelectedIndex) continue; + + var comic = comics[i]; + if (selectedComics.containsKey(comic)) { + selectedComics.remove(comic); + } else { + selectedComics[comic] = true; + } + } + } + lastSelectedIndex = comics.indexOf(c as FavoriteItem); + } + _checkExitSelectMode(); + }); }, ), - ], + ]), ); + return PopScope( + canPop: !multiSelectMode && !searchMode, + onPopInvokedWithResult: (didPop, result) { + if (multiSelectMode) { + setState(() { + multiSelectMode = false; + selectedComics.clear(); + }); + } else if (searchMode) { + setState(() { + searchMode = false; + keyword = ""; + updateComics(); + }); + } + }, + child: body, + ); + } + + void favoriteOption(String option) { + var targetFolders = LocalFavoritesManager() + .folderNames + .where((folder) => folder != favPage.folder) + .toList(); + + showDialog( + context: App.rootContext, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 50), + child: Container( + constraints: + const BoxConstraints(maxHeight: 700, maxWidth: 500), + child: Column( + children: [ + Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.0), + ), + ), + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + favPage.folder ?? "Unselected".tl, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: targetFolders.length + 1, + itemBuilder: (context, index) { + if (index == targetFolders.length) { + return SizedBox( + height: 36, + child: Center( + child: TextButton( + onPressed: () { + newFolder().then((v) { + setState(() { + targetFolders = + LocalFavoritesManager() + .folderNames + .where((folder) => + folder != + favPage.folder) + .toList(); + }); + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 20), + const SizedBox(width: 4), + Text("New Folder".tl), + ], + ), + ), + ), + ); + } + var folder = targetFolders[index]; + var disabled = false; + if (selectedLocalFolders.isNotEmpty) { + if (added.contains(folder) && + !added + .contains(selectedLocalFolders.first)) { + disabled = true; + } else if (!added.contains(folder) && + added + .contains(selectedLocalFolders.first)) { + disabled = true; + } + } + return CheckboxListTile( + title: Row( + children: [ + Text(folder), + const SizedBox(width: 8), + ], + ), + value: selectedLocalFolders.contains(folder), + onChanged: disabled + ? null + : (v) { + setState(() { + if (v!) { + selectedLocalFolders.add(folder); + } else { + selectedLocalFolders.remove(folder); + } + }); + }, + ); + }, + ), + ), + Center( + child: FilledButton( + onPressed: () { + if (selectedLocalFolders.isEmpty) { + return; + } + if (option == 'move') { + for (var c in selectedComics.keys) { + for (var s in selectedLocalFolders) { + LocalFavoritesManager().moveFavorite( + favPage.folder as String, + s, + c.id, + (c as FavoriteItem).type); + } + } + } else { + for (var c in selectedComics.keys) { + for (var s in selectedLocalFolders) { + LocalFavoritesManager().addComic( + s, + FavoriteItem( + id: c.id, + name: c.title, + coverPath: c.cover, + author: c.subtitle ?? '', + type: ComicType((c.sourceKey == 'local' + ? 0 + : c.sourceKey.hashCode)), + tags: c.tags ?? [], + ), + ); + } + } + } + context.pop(); + updateComics(); + _cancel(); + }, + child: + Text(option == 'move' ? "Move".tl : "Add".tl), + ).paddingVertical(16), + ), + ], + ), + ), + )); + }, + ); + }, + ); + } + + void _checkExitSelectMode() { + if (selectedComics.isEmpty) { + setState(() { + multiSelectMode = false; + }); + } + } + + void _cancel() { + setState(() { + selectedComics.clear(); + multiSelectMode = false; + }); + } + + void _deleteComicWithId() { + for (var c in selectedComics.keys) { + LocalFavoritesManager().deleteComicWithId( + widget.folder, + c.id, + (c as FavoriteItem).type, + ); + } + updateComics(); + _cancel(); } } From 2f290f0c86e513d159f078d9946bdc1d2a2d2327 Mon Sep 17 00:00:00 2001 From: Pacalini <141402887+Pacalini@users.noreply.github.com> Date: Fri, 22 Nov 2024 16:47:50 +0800 Subject: [PATCH 15/28] universal: style improvements (#67) --- assets/translation.json | 36 +++++++------ lib/components/menu.dart | 9 +++- lib/components/message.dart | 4 ++ lib/pages/comic_source_page.dart | 5 +- lib/pages/favorites/local_favorites_page.dart | 50 +++++++++++-------- .../favorites/network_favorites_page.dart | 6 +-- lib/pages/history_page.dart | 1 + 7 files changed, 69 insertions(+), 42 deletions(-) diff --git a/assets/translation.json b/assets/translation.json index d8226a3..4e1e046 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -41,9 +41,14 @@ "Select a folder": "选择一个文件夹", "Folder": "文件夹", "Confirm": "确认", - "Are you sure you want to delete this comic?": "您确定要删除这部漫画吗?", - "Are you sure you want to delete @a selected comics?": "您确定要删除 @a 部漫画吗?", + "Remove comic from favorite?": "从收藏中移除漫画?", + "Move": "移动", + "Move to folder": "移动到文件夹", + "Copy to folder": "复制到文件夹", + "Delete Comic": "删除漫画", + "Delete @c comics?": "删除 @c 本漫画?", "Add comic source": "添加漫画源", + "Delete comic source '@n' ?": "删除漫画源 '@n' ?", "Select file": "选择文件", "View list": "查看列表", "Open help": "打开帮助", @@ -132,7 +137,8 @@ "Block": "屏蔽", "Add new favorite to": "添加新收藏到", "Move favorite after reading": "阅读后移动收藏", - "Are you sure you want to delete this folder?" : "确定要删除这个收藏夹吗?", + "Delete folder?" : "刪除文件夾?", + "Delete folder '@f' ?" : "删除文件夹 '@f' ?", "Import from file": "从文件导入", "Failed to import": "导入失败", "Cache Limit": "缓存限制", @@ -215,7 +221,7 @@ "Authorization Required": "需要身份验证", "Sync": "同步", "The folder is Linked to @source": "文件夹已关联到 @source", - "Source Folder": "源收藏夹", + "Source Folder": "源文件夹", "Use a config file": "使用配置文件", "Comic Source list": "漫画源列表", "View": "查看", @@ -235,9 +241,7 @@ "Also remove files on disk": "同时删除磁盘上的文件", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", - "No new version available": "没有新版本可用", - "Move": "移动", - "Move to favorites": "移动至收藏" + "No new version available": "没有新版本可用" }, "zh_TW": { "Home": "首頁", @@ -283,9 +287,14 @@ "Select a folder": "選擇一個文件夾", "Folder": "文件夾", "Confirm": "確認", - "Are you sure you want to delete this comic?": "您確定要刪除這部漫畫嗎?", - "Are you sure you want to delete @a selected comics?": "您確定要刪除 @a 部漫畫嗎?", + "Remove comic from favorite?": "從收藏中移除漫畫?", + "Move": "移動", + "Move to folder": "移動到文件夾", + "Copy to folder": "複製到文件夾", + "Delete Comic": "刪除漫畫", + "Delete @c comics?": "刪除 @c 本漫畫?", "Add comic source": "添加漫畫源", + "Delete comic source '@n' ?": "刪除漫畫源 '@n' ?", "Select file": "選擇文件", "View list": "查看列表", "Open help": "打開幫助", @@ -372,7 +381,8 @@ "Block": "屏蔽", "Add new favorite to": "添加新收藏到", "Move favorite after reading": "閱讀後移動收藏", - "Are you sure you want to delete this folder?" : "確定要刪除這個收藏夾嗎?", + "Delete folder?" : "刪除文件夾?", + "Delete folder '@f' ?" : "刪除文件夾 '@f' ?", "Import from file": "從文件匯入", "Failed to import": "匯入失敗", "Cache Limit": "緩存限制", @@ -455,7 +465,7 @@ "Authorization Required": "需要身份驗證", "Sync": "同步", "The folder is Linked to @source": "文件夾已關聯到 @source", - "Source Folder": "源收藏夾", + "Source Folder": "源文件夾", "Use a config file": "使用配置文件", "Comic Source list": "漫畫源列表", "View": "查看", @@ -475,8 +485,6 @@ "Also remove files on disk": "同時刪除磁盤上的文件", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", - "No new version available": "沒有新版本可用", - "Move": "移動", - "Move to favorites": "移動至收藏" + "No new version available": "沒有新版本可用" } } \ No newline at end of file diff --git a/lib/components/menu.dart b/lib/components/menu.dart index 027c116..1e13448 100644 --- a/lib/components/menu.dart +++ b/lib/components/menu.dart @@ -92,9 +92,13 @@ class _MenuRoute extends PopupRoute { Icon( entry.icon, size: 18, + color: entry.color ), const SizedBox(width: 12), - Text(entry.text), + Text( + entry.text, + style: TextStyle(color: entry.color) + ), ], ), ), @@ -119,7 +123,8 @@ class _MenuRoute extends PopupRoute { class MenuEntry { final String text; final IconData? icon; + final Color? color; final void Function() onClick; - MenuEntry({required this.text, this.icon, required this.onClick}); + MenuEntry({required this.text, this.icon, this.color, required this.onClick}); } diff --git a/lib/components/message.dart b/lib/components/message.dart index d1fb8aa..0024956 100644 --- a/lib/components/message.dart +++ b/lib/components/message.dart @@ -135,6 +135,7 @@ Future showConfirmDialog({ required String content, required void Function() onConfirm, String confirmText = "Confirm", + Color? btnColor, }) { return showDialog( context: context, @@ -147,6 +148,9 @@ Future showConfirmDialog({ context.pop(); onConfirm(); }, + style: FilledButton.styleFrom( + backgroundColor: btnColor, + ), child: Text(confirmText.tl), ), ], diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index 2ae6a71..efd1b6c 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -231,7 +231,10 @@ class _BodyState extends State<_Body> { showConfirmDialog( context: App.rootContext, title: "Delete".tl, - content: "Are you sure you want to delete it?".tl, + content: "Delete comic source '@n' ?".tlParams({ + "n": source.name, + }), + btnColor: context.colorScheme.error, onConfirm: () { var file = File(source.filePath); file.delete(); diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index 1ca103b..1a9d784 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -149,22 +149,6 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { ), MenuButton( entries: [ - MenuEntry( - icon: Icons.delete_outline, - text: "Delete Folder".tl, - onClick: () { - showConfirmDialog( - context: App.rootContext, - title: "Delete".tl, - content: - "Are you sure you want to delete this folder?".tl, - onConfirm: () { - favPage.setFolder(false, null); - LocalFavoritesManager().deleteFolder(widget.folder); - favPage.folderList?.updateFolders(); - }, - ); - }), MenuEntry( icon: Icons.edit_outlined, text: "Rename".tl, @@ -233,6 +217,26 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { } }); }), + MenuEntry( + icon: Icons.delete_outline, + text: "Delete Folder".tl, + color: context.colorScheme.error, + onClick: () { + showConfirmDialog( + context: App.rootContext, + title: "Delete".tl, + content: + "Delete folder '@f' ?".tlParams({ + "f": widget.folder, + }), + btnColor: context.colorScheme.error, + onConfirm: () { + favPage.setFolder(false, null); + LocalFavoritesManager().deleteFolder(widget.folder); + favPage.folderList?.updateFolders(); + }, + ); + }), ], ), ], @@ -256,12 +260,12 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { actions: [ MenuButton(entries: [ MenuEntry( - icon: Icons.star, - text: "Add to favorites".tl, + icon: Icons.drive_file_move, + text: "Move to folder".tl, onClick: () => favoriteOption('move')), MenuEntry( - icon: Icons.drive_file_move, - text: "Move to favorites".tl, + icon: Icons.copy, + text: "Copy to folder".tl, onClick: () => favoriteOption('add')), MenuEntry( icon: Icons.select_all, @@ -277,13 +281,15 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { onClick: invertSelection), MenuEntry( icon: Icons.delete_outline, - text: "Delete Folder".tl, + text: "Delete Comic".tl, + color: context.colorScheme.error, onClick: () { showConfirmDialog( context: context, title: "Delete".tl, content: - "Are you sure you want to delete this comic?".tl, + "Delete @c comics?".tlParams({"c": selectedComics.length}), + btnColor: context.colorScheme.error, onConfirm: () { _deleteComicWithId(); }, diff --git a/lib/pages/favorites/network_favorites_page.dart b/lib/pages/favorites/network_favorites_page.dart index b842d55..b11e803 100644 --- a/lib/pages/favorites/network_favorites_page.dart +++ b/lib/pages/favorites/network_favorites_page.dart @@ -19,8 +19,8 @@ Future _deleteComic( bool loading = false; return StatefulBuilder(builder: (context, setState) { return ContentDialog( - title: "Delete".tl, - content: Text("Are you sure you want to delete this comic?".tl) + title: "Remove".tl, + content: Text("Remove comic from favorite?".tl) .paddingHorizontal(16), actions: [ Button.filled( @@ -424,7 +424,7 @@ class _FolderTile extends StatelessWidget { return StatefulBuilder(builder: (context, setState) { return ContentDialog( title: "Delete".tl, - content: Text("Are you sure you want to delete this folder?".tl) + content: Text("Delete folder?".tl) .paddingHorizontal(16), actions: [ Button.filled( diff --git a/lib/pages/history_page.dart b/lib/pages/history_page.dart index f7e07c0..05c9466 100644 --- a/lib/pages/history_page.dart +++ b/lib/pages/history_page.dart @@ -111,6 +111,7 @@ class _HistoryPageState extends State { MenuEntry( icon: Icons.remove, text: 'Remove'.tl, + color: context.colorScheme.error, onClick: () { if (c.sourceKey.startsWith("Invalid")) { HistoryManager().remove( From c3474b1dff501b7c73c26e83ed98a283623a4c1c Mon Sep 17 00:00:00 2001 From: boa <42885162+boa-z@users.noreply.github.com> Date: Sat, 23 Nov 2024 00:25:38 +0800 Subject: [PATCH 16/28] change iOS default local path to Documents (#68) --- lib/foundation/local.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 803d89a..1461727 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -203,6 +203,14 @@ class LocalManager with ChangeNotifier { } else { path = FilePath.join(App.dataPath, 'local'); } + } else if (App.isIOS) { + var oldPath = FilePath.join(App.dataPath, 'local'); + if (Directory(oldPath).existsSync() && Directory(oldPath).listSync().isNotEmpty) { + path = oldPath; + } else { + var directory = await getApplicationDocumentsDirectory(); + path = FilePath.join(directory.path, 'local'); + } } else { path = FilePath.join(App.dataPath, 'local'); } From a1474ca9c385236380c139796edf749f9ff2239c Mon Sep 17 00:00:00 2001 From: pkuislm <1668911954@qq.com> Date: Sat, 23 Nov 2024 11:05:00 +0800 Subject: [PATCH 17/28] =?UTF-8?q?=E6=9B=B4=E6=94=B9=E5=AE=89=E5=8D=93?= =?UTF-8?q?=E7=AB=AF=E7=9A=84=E6=96=87=E4=BB=B6=E8=AE=BF=E9=97=AE=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E4=BC=98=E5=8C=96=E5=AF=BC=E5=85=A5=E9=80=BB?= =?UTF-8?q?=E8=BE=91=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor import function & Allow import local comics without copying them to local path. * android: use file_picker instead, support directory access for android 10 * Improve import logic * Fix sql query. * Add ability to remove invalid favorite items. * Perform sort before choosing cover * Revert changes of "use file_picker instead". * Try catch on "check update" * Added module 'flutter_saf' * gitignore * remove unsupported arch in build.gradle * Use flutter_saf to handle android's directory and files, improve import logic. * revert changes of 'requestLegacyExternalStorage' * fix cbz import * openDirectoryPlatform * Remove double check on source folder * use openFilePlatform * remove unused import * improve local comic's path handling * bump version * fix pubspec format * return null when comic folder is empty --- android/.gitignore | 1 + android/app/build.gradle | 2 + assets/translation.json | 6 + lib/foundation/favorites.dart | 17 + .../image_provider/cached_image.dart | 3 +- lib/foundation/local.dart | 17 +- lib/init.dart | 2 + lib/pages/home_page.dart | 75 ++-- lib/pages/reader/images.dart | 2 +- lib/pages/reader/scaffold.dart | 4 +- lib/pages/settings/about.dart | 50 +-- lib/pages/settings/local_favorites.dart | 10 + lib/utils/import_comic.dart | 381 +++++++++--------- lib/utils/io.dart | 39 +- pubspec.lock | 9 + pubspec.yaml | 4 + 16 files changed, 369 insertions(+), 253 deletions(-) diff --git a/android/.gitignore b/android/.gitignore index 6f56801..1028187 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -11,3 +11,4 @@ GeneratedPluginRegistrant.java key.properties **/*.keystore **/*.jks +/app/.cxx/ diff --git a/android/app/build.gradle b/android/app/build.gradle index 59f3a36..a544de7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -34,6 +34,8 @@ android { splits{ abi { + reset() + include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64' enable true universalApk true } diff --git a/assets/translation.json b/assets/translation.json index 4e1e046..788b30b 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -239,6 +239,9 @@ "Chapter @ep": "第 @ep 章", "Page @page": "第 @page 页", "Also remove files on disk": "同时删除磁盘上的文件", + "Copy to app local path": "将漫画复制到本地存储目录中", + "Delete all unavailable local favorite items": "删除所有无效的本地收藏", + "Deleted @a favorite items.": "已删除 @a 条无效收藏", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要现在更新吗?", "No new version available": "没有新版本可用" @@ -483,6 +486,9 @@ "Chapter @ep": "第 @ep 章", "Page @page": "第 @page 頁", "Also remove files on disk": "同時刪除磁盤上的文件", + "Copy to app local path": "將漫畫複製到本地儲存目錄中", + "Delete all unavailable local favorite items": "刪除所有無效的本地收藏", + "Deleted @a favorite items.": "已刪除 @a 條無效收藏", "New version available": "有新版本可用", "A new version is available. Do you want to update now?" : "有新版本可用。您要現在更新嗎?", "No new version available": "沒有新版本可用" diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index e1a60fc..29eab03 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/image_provider/local_favorite_image.dart'; +import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'dart:io'; @@ -496,6 +497,22 @@ class LocalFavoritesManager with ChangeNotifier { notifyListeners(); } + Future removeInvalid() async { + int count = 0; + await Future.microtask(() { + var all = allComics(); + for(var c in all) { + var comicSource = c.type.comicSource; + if ((c.type == ComicType.local && LocalManager().find(c.id, c.type) == null) + || (c.type != ComicType.local && comicSource == null)) { + deleteComicWithId(c.folder, c.id, c.type); + count++; + } + } + }); + return count; + } + Future clearAll() async { _db.dispose(); File("${App.dataPath}/local_favorite.db").deleteSync(); diff --git a/lib/foundation/image_provider/cached_image.dart b/lib/foundation/image_provider/cached_image.dart index daf1338..a962f3d 100644 --- a/lib/foundation/image_provider/cached_image.dart +++ b/lib/foundation/image_provider/cached_image.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/network/images.dart'; +import 'package:venera/utils/io.dart'; import 'base_image_provider.dart'; import 'cached_image.dart' as image_provider; @@ -24,7 +25,7 @@ class CachedImageProvider @override Future load(StreamController chunkEvents) async { if(url.startsWith("file://")) { - var file = File(url.substring(7)); + var file = openFilePlatform(url.substring(7)); return file.readAsBytes(); } await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey, cid)) { diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 1461727..2ff2a21 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -71,12 +71,13 @@ class LocalComic with HistoryMixin implements Comic { downloadedChapters = List.from(jsonDecode(row[8] as String)), createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int); - File get coverFile => File(FilePath.join( - LocalManager().path, - directory, + File get coverFile => openFilePlatform(FilePath.join( + baseDir, cover, )); + String get baseDir => directory.contains("/") ? directory : FilePath.join(LocalManager().path, directory); + @override String get description => ""; @@ -341,12 +342,12 @@ class LocalManager with ChangeNotifier { throw "Invalid ep"; } var comic = find(id, type) ?? (throw "Comic Not Found"); - var directory = Directory(FilePath.join(path, comic.directory)); + var directory = openDirectoryPlatform(comic.baseDir); if (comic.chapters != null) { var cid = ep is int ? comic.chapters!.keys.elementAt(ep - 1) : (ep as String); - directory = Directory(FilePath.join(directory.path, cid)); + directory = openDirectoryPlatform(FilePath.join(directory.path, cid)); } var files = []; await for (var entity in directory.list()) { @@ -392,10 +393,10 @@ class LocalManager with ChangeNotifier { String id, ComicType type, String name) async { var comic = find(id, type); if (comic != null) { - return Directory(FilePath.join(path, comic.directory)); + return openDirectoryPlatform(FilePath.join(path, comic.directory)); } var dir = findValidDirectoryName(path, name); - return Directory(FilePath.join(path, dir)).create().then((value) => value); + return openDirectoryPlatform(FilePath.join(path, dir)).create().then((value) => value); } void completeTask(DownloadTask task) { @@ -454,7 +455,7 @@ class LocalManager with ChangeNotifier { void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) { if(removeFileOnDisk) { - var dir = Directory(FilePath.join(path, c.directory)); + var dir = openDirectoryPlatform(FilePath.join(path, c.directory)); dir.deleteIgnoreError(recursive: true); } //Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted. diff --git a/lib/init.dart b/lib/init.dart index a4262d1..0a9ce3c 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -1,3 +1,4 @@ +import 'package:flutter_saf/flutter_saf.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; @@ -12,6 +13,7 @@ import 'package:venera/utils/translations.dart'; import 'foundation/appdata.dart'; Future init() async { + await SAFTaskWorker().init(); await AppTranslation.init(); await appdata.init(); await App.init(); diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 75e637e..49a4d0d 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:venera/components/components.dart'; @@ -497,6 +496,10 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { String? selectedFolder; + bool copyToLocalFolder = true; + + bool cancelled = false; + @override void dispose() { loading = false; @@ -530,22 +533,23 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { ), ) : Column( - key: key, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 600), - ...List.generate(importMethods.length, (index) { - return RadioListTile( - title: Text(importMethods[index]), - value: index, - groupValue: type, - onChanged: (value) { - setState(() { - type = value as int; - }); - }, - ); - }), + key: key, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 600), + ...List.generate(importMethods.length, (index) { + return RadioListTile( + title: Text(importMethods[index]), + value: index, + groupValue: type, + onChanged: (value) { + setState(() { + type = value as int; + }); + }, + ); + }), + if(type != 3) ListTile( title: Text("Add to favorites".tl), trailing: Select( @@ -559,10 +563,19 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { }, ), ).paddingHorizontal(8), - const SizedBox(height: 8), - Text(info).paddingHorizontal(24), - ], - ), + CheckboxListTile( + enabled: true, + title: Text("Copy to app local path".tl), + value: copyToLocalFolder, + onChanged:(v) { + setState(() { + copyToLocalFolder = !copyToLocalFolder; + }); + }).paddingHorizontal(8), + const SizedBox(height: 8), + Text(info).paddingHorizontal(24), + ], + ), actions: [ Button.text( child: Row( @@ -620,18 +633,20 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { void selectAndImport() async { height = key.currentContext!.size!.height; + setState(() { loading = true; }); - var importer = ImportComic(selectedFolder: selectedFolder); - var result = false; - if (type == 2) { - result = await importer.cbz(); - } else if (type == 3) { - result = await importer.ehViewer(); - } else { - result = await importer.directory(type == 0); - } + var importer = ImportComic( + selectedFolder: selectedFolder, + copyToLocal: copyToLocalFolder); + var result = switch(type) { + 0 => await importer.directory(true), + 1 => await importer.directory(false), + 2 => await importer.cbz(), + 3 => await importer.ehViewer(), + int() => true, + }; if(result) { context.pop(); } else { diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index d273eca..a202591 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -604,7 +604,7 @@ ImageProvider _createImageProvider(int page, BuildContext context) { var reader = context.reader; var imageKey = reader.images![page - 1]; if (imageKey.startsWith('file://')) { - return FileImage(File(imageKey.replaceFirst("file://", ''))); + return FileImage(openFilePlatform(imageKey.replaceFirst("file://", ''))); } else { return ReaderImageProvider( imageKey, diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 95043c3..804f32c 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -469,7 +469,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { ImageProvider image; var imageKey = images[index]; if (imageKey.startsWith('file://')) { - image = FileImage(File(imageKey.replaceFirst("file://", ''))); + image = FileImage(openFilePlatform(imageKey.replaceFirst("file://", ''))); } else { image = ReaderImageProvider( imageKey, @@ -515,7 +515,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { } } if (imageKey.startsWith("file://")) { - return await File(imageKey.substring(7)).readAsBytes(); + return await openFilePlatform(imageKey.substring(7)).readAsBytes(); } else { return (await CacheManager().findCache( "$imageKey@${context.reader.type.sourceKey}@${context.reader.cid}@${context.reader.eid}"))! diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 147ad36..6a0c70a 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -86,29 +86,33 @@ Future checkUpdate() async { } Future checkUpdateUi([bool showMessageIfNoUpdate = true]) async { - var value = await checkUpdate(); - if (value) { - showDialog( - context: App.rootContext, - builder: (context) { - return ContentDialog( - title: "New version available".tl, - content: Text( - "A new version is available. Do you want to update now?".tl), - actions: [ - Button.text( - onPressed: () { - Navigator.pop(context); - launchUrlString( - "https://github.com/venera-app/venera/releases"); - }, - child: Text("Update".tl), - ), - ], - ); - }); - } else if (showMessageIfNoUpdate) { - App.rootContext.showMessage(message: "No new version available".tl); + try { + var value = await checkUpdate(); + if (value) { + showDialog( + context: App.rootContext, + builder: (context) { + return ContentDialog( + title: "New version available".tl, + content: Text( + "A new version is available. Do you want to update now?".tl), + actions: [ + Button.text( + onPressed: () { + Navigator.pop(context); + launchUrlString( + "https://github.com/venera-app/venera/releases"); + }, + child: Text("Update".tl), + ), + ], + ); + }); + } else if (showMessageIfNoUpdate) { + App.rootContext.showMessage(message: "No new version available".tl); + } + } catch (e, s) { + Log.error("Check Update", e.toString(), s); } } diff --git a/lib/pages/settings/local_favorites.dart b/lib/pages/settings/local_favorites.dart index 9cbce52..c35f9a3 100644 --- a/lib/pages/settings/local_favorites.dart +++ b/lib/pages/settings/local_favorites.dart @@ -38,6 +38,16 @@ class _LocalFavoritesSettingsState extends State { for (var e in LocalFavoritesManager().folderNames) e: e }, ).toSliver(), + _CallbackSetting( + title: "Delete all unavailable local favorite items".tl, + callback: () async { + var controller = showLoadingDialog(context); + var count = await LocalFavoritesManager().removeInvalid(); + controller.close(); + context.showMessage(message: "Deleted @a favorite items".tlParams({'a': count})); + }, + actionTitle: 'Delete'.tl, + ).toSliver(), ], ); } diff --git a/lib/utils/import_comic.dart b/lib/utils/import_comic.dart index 51990d9..cbcecbc 100644 --- a/lib/utils/import_comic.dart +++ b/lib/utils/import_comic.dart @@ -8,50 +8,40 @@ import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:sqlite3/sqlite3.dart' as sql; +import 'package:venera/utils/ext.dart'; import 'package:venera/utils/translations.dart'; import 'cbz.dart'; import 'io.dart'; class ImportComic { final String? selectedFolder; + final bool copyToLocal; - const ImportComic({this.selectedFolder}); + const ImportComic({this.selectedFolder, this.copyToLocal = true}); Future cbz() async { var file = await selectFile(ext: ['cbz']); + Map> imported = {}; if(file == null) { return false; } var controller = showLoadingDialog(App.rootContext, allowCancel: false); - var isSuccessful = false; try { var comic = await CBZ.import(File(file.path)); - if (selectedFolder != null) { - LocalFavoritesManager().addComic( - selectedFolder!, - FavoriteItem( - id: comic.id, - name: comic.title, - coverPath: comic.cover, - author: comic.subtitle, - type: comic.comicType, - tags: comic.tags, - ), - ); - } - isSuccessful = true; + imported[selectedFolder] = [comic]; } catch (e, s) { Log.error("Import Comic", e.toString(), s); App.rootContext.showMessage(message: e.toString()); } controller.close(); - return isSuccessful; + return registerComics(imported, true); } Future ehViewer() async { var dbFile = await selectFile(ext: ['db']); final picker = DirectoryPicker(); final comicSrc = await picker.pickDirectory(); + Map> imported = {}; if (dbFile == null || comicSrc == null) { return false; } @@ -60,129 +50,91 @@ class ImportComic { var controller = showLoadingDialog(App.rootContext, onCancel: () { cancelled = true; }); - bool isSuccessful = false; try { - var cache = FilePath.join(App.cachePath, dbFile.name); - await dbFile.saveTo(cache); - var db = sql.sqlite3.open(cache); + var db = sql.sqlite3.open(dbFile.path); - Future addTagComics(String destFolder, List comics) async { + Future> validateComics(List comics) async { + List imported = []; for (var comic in comics) { if (cancelled) { - return; + return imported; } - var comicDir = Directory( + var comicDir = openDirectoryPlatform( FilePath.join(comicSrc.path, comic['DIRNAME'] as String)); - if (!(await comicDir.exists())) { - continue; - } String titleJP = - comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String; + comic['TITLE_JPN'] == null ? "" : comic['TITLE_JPN'] as String; String title = titleJP == "" ? comic['TITLE'] as String : titleJP; - if (LocalManager().findByName(title) != null) { - Log.info("Import Comic", "Comic already exists: $title"); + int timeStamp = comic['TIME'] as int; + DateTime downloadTime = timeStamp != 0 + ? DateTime.fromMillisecondsSinceEpoch(timeStamp) + : DateTime.now(); + var comicObj = await _checkSingleComic(comicDir, + title: title, + tags: [ + //1 >> x + [ + "MISC", + "DOUJINSHI", + "MANGA", + "ARTISTCG", + "GAMECG", + "IMAGE SET", + "COSPLAY", + "ASIAN PORN", + "NON-H", + "WESTERN", + ][(log(comic['CATEGORY'] as int) / ln2).floor()] + ], + createTime: downloadTime); + if (comicObj == null) { continue; } - - String coverURL = await comicDir.joinFile(".thumb").exists() - ? comicDir.joinFile(".thumb").path - : (comic['THUMB'] as String) - .replaceAll('s.exhentai.org', 'ehgt.org'); - int downloadedTimeStamp = comic['TIME'] as int; - DateTime downloadedTime = downloadedTimeStamp != 0 - ? DateTime.fromMillisecondsSinceEpoch(downloadedTimeStamp) - : DateTime.now(); - var comicObj = LocalComic( - id: LocalManager().findValidId(ComicType.local), - title: title, - subtitle: '', - tags: [ - //1 >> x - [ - "MISC", - "DOUJINSHI", - "MANGA", - "ARTISTCG", - "GAMECG", - "IMAGE SET", - "COSPLAY", - "ASIAN PORN", - "NON-H", - "WESTERN", - ][(log(comic['CATEGORY'] as int) / ln2).floor()] - ], - directory: comicDir.path, - chapters: null, - cover: coverURL, - comicType: ComicType.local, - downloadedChapters: [], - createdAt: downloadedTime, - ); - LocalManager().add(comicObj, comicObj.id); - LocalFavoritesManager().addComic( - destFolder, - FavoriteItem( - id: comicObj.id, - name: comicObj.title, - coverPath: comicObj.cover, - author: comicObj.subtitle, - type: comicObj.comicType, - tags: comicObj.tags, - favoriteTime: downloadedTime, - ), - ); + imported.add(comicObj); } + return imported; } - { - var defaultFolderName = '(EhViewer)Default'; - if (!LocalFavoritesManager().existsFolder(defaultFolderName)) { - LocalFavoritesManager().createFolder(defaultFolderName); - } - var comicList = db.select(""" - SELECT * - FROM DOWNLOAD_DIRNAME DN - LEFT JOIN DOWNLOADS DL - ON DL.GID = DN.GID - WHERE DL.LABEL IS NULL AND DL.STATE = 3 - ORDER BY DL.TIME DESC - """).toList(); - await addTagComics(defaultFolderName, comicList); - } + var tags = [""]; + tags.addAll(db.select(""" + SELECT * FROM DOWNLOAD_LABELS LB + ORDER BY LB.TIME DESC; + """).map((r) => r['LABEL'] as String).toList()); - var folders = db.select(""" - SELECT * FROM DOWNLOAD_LABELS; - """); - - for (var folder in folders) { + for (var tag in tags) { if (cancelled) { break; } - var label = folder["LABEL"] as String; - var folderName = '(EhViewer)$label'; - if (!LocalFavoritesManager().existsFolder(folderName)) { - LocalFavoritesManager().createFolder(folderName); - } + var folderName = + tag == '' ? '(EhViewer)Default'.tl : '(EhViewer)$tag'; var comicList = db.select(""" SELECT * FROM DOWNLOAD_DIRNAME DN LEFT JOIN DOWNLOADS DL ON DL.GID = DN.GID - WHERE DL.LABEL = ? AND DL.STATE = 3 + WHERE DL.LABEL ${tag == '' ? 'IS NULL' : '= \'$tag\''} AND DL.STATE = 3 ORDER BY DL.TIME DESC - """, [label]).toList(); - await addTagComics(folderName, comicList); + """).toList(); + + var validComics = await validateComics(comicList); + imported[folderName] = validComics; + if (validComics.isNotEmpty && + !LocalFavoritesManager().existsFolder(folderName)) { + LocalFavoritesManager().createFolder(folderName); + } } db.dispose(); + + //Android specific + var cache = FilePath.join(App.cachePath, dbFile.name); await File(cache).deleteIgnoreError(); - isSuccessful = true; } catch (e, s) { Log.error("Import Comic", e.toString(), s); App.rootContext.showMessage(message: e.toString()); } controller.close(); - return isSuccessful; + if(cancelled) return false; + return registerComics(imported, copyToLocal); } Future directory(bool single) async { @@ -191,71 +143,43 @@ class ImportComic { if (path == null) { return false; } - Map comics = {}; - if (single) { - var result = await _checkSingleComic(path); - if (result != null) { - comics[path] = result; + Map> imported = {selectedFolder: []}; + try { + if (single) { + var result = await _checkSingleComic(path); + if (result != null) { + imported[selectedFolder]!.add(result); + } else { + App.rootContext.showMessage(message: "Invalid Comic".tl); + return false; + } } else { - App.rootContext.showMessage(message: "Invalid Comic".tl); - return false; - } - } else { - await for (var entry in path.list()) { - if (entry is Directory) { - var result = await _checkSingleComic(entry); - if (result != null) { - comics[entry] = result; + await for (var entry in path.list()) { + if (entry is Directory) { + var result = await _checkSingleComic(entry); + if (result != null) { + imported[selectedFolder]!.add(result); + } } } } + } catch (e, s) { + Log.error("Import Comic", e.toString(), s); + App.rootContext.showMessage(message: e.toString()); } - bool shouldCopy = true; - for (var comic in comics.keys) { - if (comic.parent.path == LocalManager().path) { - shouldCopy = false; - break; - } - } - if (shouldCopy && comics.isNotEmpty) { - try { - // copy the comics to the local directory - await compute, void>(_copyDirectories, { - 'toBeCopied': comics.keys.map((e) => e.path).toList(), - 'destination': LocalManager().path, - }); - } catch (e) { - App.rootContext.showMessage(message: "Failed to import comics".tl); - Log.error("Import Comic", e.toString()); - return false; - } - } - for (var comic in comics.values) { - LocalManager().add(comic, LocalManager().findValidId(ComicType.local)); - if (selectedFolder != null) { - LocalFavoritesManager().addComic( - selectedFolder!, - FavoriteItem( - id: comic.id, - name: comic.title, - coverPath: comic.cover, - author: comic.subtitle, - type: comic.comicType, - tags: comic.tags, - ), - ); - } - } - App.rootContext.showMessage( - message: "Imported @a comics".tlParams({ - 'a': comics.length, - })); - return true; + return registerComics(imported, copyToLocal); } - Future _checkSingleComic(Directory directory) async { + //Automatically search for cover image and chapters + Future _checkSingleComic(Directory directory, + {String? id, + String? title, + String? subtitle, + List? tags, + DateTime? createTime}) + async { if (!(await directory.exists())) return null; - var name = directory.name; + var name = title ?? directory.name; if (LocalManager().findByName(name) != null) { Log.info("Import Comic", "Comic already exists: $name"); return null; @@ -263,7 +187,8 @@ class ImportComic { bool hasChapters = false; var chapters = []; var coverPath = ''; // relative path to the cover image - for (var entry in directory.listSync()) { + var fileList = []; + await for (var entry in directory.list()) { if (entry is Directory) { hasChapters = true; chapters.add(entry.name); @@ -275,20 +200,24 @@ class ImportComic { } } } else if (entry is File) { - if (entry.name.startsWith('cover')) { - coverPath = entry.name; - } const imageExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'jpe']; - if (!coverPath.startsWith('cover') && - imageExtensions.contains(entry.extension)) { - coverPath = entry.name; + if (imageExtensions.contains(entry.extension)) { + fileList.add(entry.name); } } } + + if(fileList.isEmpty) { + return null; + } + + fileList.sort(); + coverPath = fileList.firstWhereOrNull((l) => l.startsWith('cover')) ?? fileList.first; + chapters.sort(); if (hasChapters && coverPath == '') { // use the first image in the first chapter as the cover - var firstChapter = Directory('${directory.path}/${chapters.first}'); + var firstChapter = openDirectoryPlatform('${directory.path}/${chapters.first}'); await for (var entry in firstChapter.list()) { if (entry is File) { coverPath = entry.name; @@ -301,25 +230,26 @@ class ImportComic { return null; } return LocalComic( - id: '0', + id: id ?? '0', title: name, - subtitle: '', - tags: [], - directory: directory.name, + subtitle: subtitle ?? '', + tags: tags ?? [], + directory: directory.path, chapters: hasChapters ? Map.fromIterables(chapters, chapters) : null, cover: coverPath, comicType: ComicType.local, downloadedChapters: chapters, - createdAt: DateTime.now(), + createdAt: createTime ?? DateTime.now(), ); } - static Future _copyDirectories(Map data) async { + static Future> _copyDirectories(Map data) async { var toBeCopied = data['toBeCopied'] as List; var destination = data['destination'] as String; + Map result = {}; for (var dir in toBeCopied) { - var source = Directory(dir); - var dest = Directory("$destination/${source.name}"); + var source = openDirectoryPlatform(dir); + var dest = openDirectoryPlatform("$destination/${source.name}"); if (dest.existsSync()) { // The destination directory already exists, and it is not managed by the app. // Rename the old directory to avoid conflicts. @@ -330,6 +260,95 @@ class ImportComic { } dest.createSync(); await copyDirectory(source, dest); + result[source.path] = dest.path; } + return result; + } + + Future>> _copyComicsToLocalDir( + Map> comics) async { + var destPath = LocalManager().path; + Map> result = {}; + for (var favoriteFolder in comics.keys) { + result[favoriteFolder] = comics[favoriteFolder]! + .where((c) => c.directory.startsWith(destPath)) + .toList(); + comics[favoriteFolder]! + .removeWhere((c) => c.directory.startsWith(destPath)); + + if (comics[favoriteFolder]!.isEmpty) { + continue; + } + + try { + // copy the comics to the local directory + var pathMap = await compute, Map>( + _copyDirectories, { + 'toBeCopied': comics[favoriteFolder]!.map((e) => e.directory).toList(), + 'destination': destPath, + }); + //Construct a new object since LocalComic.directory is a final String + for (var c in comics[favoriteFolder]!) { + result[favoriteFolder]!.add( + LocalComic( + id: c.id, + title: c.title, + subtitle: c.subtitle, + tags: c.tags, + directory: pathMap[c.directory]!, + chapters: c.chapters, + cover: c.cover, + comicType: c.comicType, + downloadedChapters: c.downloadedChapters, + createdAt: c.createdAt + ) + ); + } + } catch (e) { + App.rootContext.showMessage(message: "Failed to copy comics".tl); + Log.error("Import Comic", e.toString()); + return result; + } + } + return result; + } + + Future registerComics(Map> importedComics, bool copy) async { + try { + if (copy) { + importedComics = await _copyComicsToLocalDir(importedComics); + } + int importedCount = 0; + for (var folder in importedComics.keys) { + for (var comic in importedComics[folder]!) { + var id = LocalManager().findValidId(ComicType.local); + LocalManager().add(comic, id); + importedCount++; + if (folder != null) { + LocalFavoritesManager().addComic( + folder, + FavoriteItem( + id: id, + name: comic.title, + coverPath: comic.cover, + author: comic.subtitle, + type: comic.comicType, + tags: comic.tags, + favoriteTime: comic.createdAt + ) + ); + } + } + } + App.rootContext.showMessage( + message: "Imported @a comics".tlParams({ + 'a': importedCount, + })); + } catch(e) { + App.rootContext.showMessage(message: "Failed to register comics".tl); + Log.error("Import Comic", e.toString()); + return false; + } + return true; } } diff --git a/lib/utils/io.dart b/lib/utils/io.dart index 6593f75..50ef88a 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; import 'package:flutter/services.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart'; +import 'package:flutter_saf/flutter_saf.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/utils/ext.dart'; import 'package:path/path.dart' as p; @@ -80,7 +81,7 @@ extension DirectoryExtension on Directory { int total = 0; for (var f in listSync(recursive: true)) { if (FileSystemEntity.typeSync(f.path) == FileSystemEntityType.file) { - total += await File(f.path).length(); + total += await openFilePlatform(f.path).length(); } } return total; @@ -92,7 +93,7 @@ extension DirectoryExtension on Directory { } File joinFile(String name) { - return File(FilePath.join(path, name)); + return openFilePlatform(FilePath.join(path, name)); } } @@ -130,7 +131,7 @@ Future copyDirectory(Directory source, Directory destination) async { if (content is File) { content.copySync(newPath); } else if (content is Directory) { - Directory newDirectory = Directory(newPath); + Directory newDirectory = openDirectoryPlatform(newPath); newDirectory.createSync(); copyDirectory(content.absolute, newDirectory.absolute); } @@ -146,11 +147,11 @@ Future copyDirectoryIsolate( String findValidDirectoryName(String path, String directory) { var name = sanitizeFileName(directory); - var dir = Directory("$path/$name"); + var dir = openDirectoryPlatform("$path/$name"); var i = 1; while (dir.existsSync() && dir.listSync().isNotEmpty) { name = sanitizeFileName("$directory($i)"); - dir = Directory("$path/$name"); + dir = openDirectoryPlatform("$path/$name"); i++; } return name; @@ -180,14 +181,14 @@ class DirectoryPicker { if (App.isWindows || App.isLinux) { directory = await file_selector.getDirectoryPath(); } else if (App.isAndroid) { - directory = await _methodChannel.invokeMethod("getDirectoryPath"); + directory = (await AndroidDirectory.pickDirectory())?.path; } else { // ios, macos directory = await _methodChannel.invokeMethod("getDirectoryPath"); } if (directory == null) return null; _finalizer.attach(this, directory); - return Directory(directory); + return openDirectoryPlatform(directory); } finally { Future.delayed(const Duration(milliseconds: 100), () { IO._isSelectingFiles = false; @@ -310,6 +311,30 @@ Future saveFile( } } +Directory openDirectoryPlatform(String path) { + if(App.isAndroid) { + var dir = AndroidDirectory.fromPathSync(path); + if(dir == null) { + return Directory(path); + } + return dir; + } else { + return Directory(path); + } +} + +File openFilePlatform(String path) { + if(App.isAndroid) { + var f = AndroidFile.fromPathSync(path); + if(f == null) { + return File(path); + } + return f; + } else { + return File(path); + } +} + class Share { static void shareFile({ required Uint8List data, diff --git a/pubspec.lock b/pubspec.lock index 5e8daa2..4a58d34 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -389,6 +389,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.1" + flutter_saf: + dependency: "direct main" + description: + path: "." + ref: HEAD + resolved-ref: "51a27e2ca0e05becfb8ee3a506294dc4460721a8" + url: "https://github.com/pkuislm/flutter_saf.git" + source: git + version: "0.0.1" flutter_test: dependency: "direct dev" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 1b1a23c..463d582 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,10 @@ dependencies: ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1 battery_plus: ^6.2.0 local_auth: ^2.3.0 + flutter_saf: + git: + url: https://github.com/pkuislm/flutter_saf.git + ref: 829a566b738a26ea98e523807f49838e21308543 dev_dependencies: flutter_test: From c2b8760d867cf18060941e8afd2c09cb7fc2ed2d Mon Sep 17 00:00:00 2001 From: nyne Date: Sat, 23 Nov 2024 12:12:52 +0800 Subject: [PATCH 18/28] Add AppbarStyle.shadow; Improve favorites page ui. --- lib/components/appbar.dart | 121 +++++--- lib/components/comic.dart | 2 +- lib/pages/favorites/favorites_page.dart | 1 + lib/pages/favorites/local_favorites_page.dart | 285 ++++++++---------- .../favorites/network_favorites_page.dart | 6 + pubspec.lock | 4 +- 6 files changed, 219 insertions(+), 200 deletions(-) diff --git a/lib/components/appbar.dart b/lib/components/appbar.dart index 80c67f7..c5341f1 100644 --- a/lib/components/appbar.dart +++ b/lib/components/appbar.dart @@ -115,6 +115,11 @@ class _AppbarState extends State { } } +enum AppbarStyle { + blur, + shadow, +} + class SliverAppbar extends StatelessWidget { const SliverAppbar({ super.key, @@ -122,6 +127,7 @@ class SliverAppbar extends StatelessWidget { this.leading, this.actions, this.radius = 0, + this.style = AppbarStyle.blur, }); final Widget? leading; @@ -132,6 +138,8 @@ class SliverAppbar extends StatelessWidget { final double radius; + final AppbarStyle style; + @override Widget build(BuildContext context) { return SliverPersistentHeader( @@ -142,6 +150,7 @@ class SliverAppbar extends StatelessWidget { actions: actions, topPadding: MediaQuery.of(context).padding.top, radius: radius, + style: style, ), ); } @@ -160,57 +169,74 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate { final double radius; - _MySliverAppBarDelegate( - {this.leading, - required this.title, - this.actions, - required this.topPadding, - this.radius = 0}); + final AppbarStyle style; + + _MySliverAppBarDelegate({ + this.leading, + required this.title, + this.actions, + required this.topPadding, + this.radius = 0, + this.style = AppbarStyle.blur, + }); @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent) { - return SizedBox.expand( - child: BlurEffect( - blur: 15, - child: Material( - color: context.colorScheme.surface.withOpacity(0.72), - elevation: 0, - borderRadius: BorderRadius.circular(radius), - child: Row( - children: [ - const SizedBox(width: 8), - leading ?? - (Navigator.of(context).canPop() - ? Tooltip( - message: "Back".tl, - child: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.maybePop(context), - ), - ) - : const SizedBox()), - const SizedBox( - width: 16, + var body = Row( + children: [ + const SizedBox(width: 8), + leading ?? + (Navigator.of(context).canPop() + ? Tooltip( + message: "Back".tl, + child: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.maybePop(context), ), - Expanded( - child: DefaultTextStyle( - style: - DefaultTextStyle.of(context).style.copyWith(fontSize: 20), - maxLines: 1, - overflow: TextOverflow.ellipsis, - child: title, - ), - ), - ...?actions, - const SizedBox( - width: 8, - ) - ], - ).paddingTop(topPadding), + ) + : const SizedBox()), + const SizedBox( + width: 16, ), - ), - ); + Expanded( + child: DefaultTextStyle( + style: + DefaultTextStyle.of(context).style.copyWith(fontSize: 20), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: title, + ), + ), + ...?actions, + const SizedBox( + width: 8, + ) + ], + ).paddingTop(topPadding); + + if(style == AppbarStyle.blur) { + return SizedBox.expand( + child: BlurEffect( + blur: 15, + child: Material( + color: context.colorScheme.surface.withOpacity(0.72), + elevation: 0, + borderRadius: BorderRadius.circular(radius), + child: body, + ), + ), + ); + } else { + return SizedBox.expand( + child: Material( + color: context.colorScheme.surface, + elevation: shrinkOffset == 0 ? 0 : 2, + borderRadius: BorderRadius.circular(radius), + child: body, + ), + ); + } } @override @@ -224,7 +250,10 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate { return oldDelegate is! _MySliverAppBarDelegate || leading != oldDelegate.leading || title != oldDelegate.title || - actions != oldDelegate.actions; + actions != oldDelegate.actions || + topPadding != oldDelegate.topPadding || + radius != oldDelegate.radius || + style != oldDelegate.style; } } diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 488fd70..28d53a7 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -778,7 +778,7 @@ class _SliverGridComics extends StatelessWidget { duration: const Duration(milliseconds: 150), decoration: BoxDecoration( color: isSelected - ? Theme.of(context).colorScheme.secondaryContainer + ? Theme.of(context).colorScheme.secondaryContainer.withOpacity(0.72) : null, borderRadius: BorderRadius.circular(12), ), diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index afdf3b6..f71eb17 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -9,6 +9,7 @@ import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; +import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/res.dart'; diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index 1a9d784..d412c75 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -74,6 +74,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { body: SmoothCustomScrollView(slivers: [ if (!searchMode && !multiSelectMode) SliverAppbar( + style: context.width < changePoint + ? AppbarStyle.shadow + : AppbarStyle.blur, leading: Tooltip( message: "Folders".tl, child: context.width <= _kTwoPanelChangeWidth @@ -225,8 +228,7 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { showConfirmDialog( context: App.rootContext, title: "Delete".tl, - content: - "Delete folder '@f' ?".tlParams({ + content: "Delete folder '@f' ?".tlParams({ "f": widget.folder, }), btnColor: context.colorScheme.error, @@ -243,6 +245,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { ) else if (multiSelectMode) SliverAppbar( + style: context.width < changePoint + ? AppbarStyle.shadow + : AppbarStyle.blur, leading: Tooltip( message: "Cancel".tl, child: IconButton( @@ -287,8 +292,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { showConfirmDialog( context: context, title: "Delete".tl, - content: - "Delete @c comics?".tlParams({"c": selectedComics.length}), + content: "Delete @c comics?" + .tlParams({"c": selectedComics.length}), btnColor: context.colorScheme.error, onConfirm: () { _deleteComicWithId(); @@ -300,6 +305,9 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { ) else if (searchMode) SliverAppbar( + style: context.width < changePoint + ? AppbarStyle.shadow + : AppbarStyle.blur, leading: Tooltip( message: "Cancel".tl, child: IconButton( @@ -407,159 +415,134 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { .where((folder) => folder != favPage.folder) .toList(); - showDialog( - context: App.rootContext, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - return Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), - ), - child: Padding( - padding: const EdgeInsets.only(bottom: 50), - child: Container( - constraints: - const BoxConstraints(maxHeight: 700, maxWidth: 500), - child: Column( - children: [ - Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.vertical( - top: Radius.circular(12.0), - ), - ), - padding: const EdgeInsets.all(16.0), - child: Center( - child: Text( - favPage.folder ?? "Unselected".tl, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, + showPopUpWidget( + App.rootContext, + StatefulBuilder( + builder: (context, setState) { + return PopUpWidgetScaffold( + title: favPage.folder ?? "Unselected".tl, + body: Padding( + padding: EdgeInsets.only(bottom: context.padding.bottom + 16), + child: Container( + constraints: + const BoxConstraints(maxHeight: 700, maxWidth: 500), + child: Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: targetFolders.length + 1, + itemBuilder: (context, index) { + if (index == targetFolders.length) { + return SizedBox( + height: 36, + child: Center( + child: TextButton( + onPressed: () { + newFolder().then((v) { + setState(() { + targetFolders = LocalFavoritesManager() + .folderNames + .where((folder) => + folder != favPage.folder) + .toList(); + }); + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 20), + const SizedBox(width: 4), + Text("New Folder".tl), + ], + ), + ), ), + ); + } + var folder = targetFolders[index]; + var disabled = false; + if (selectedLocalFolders.isNotEmpty) { + if (added.contains(folder) && + !added.contains(selectedLocalFolders.first)) { + disabled = true; + } else if (!added.contains(folder) && + added.contains(selectedLocalFolders.first)) { + disabled = true; + } + } + return CheckboxListTile( + title: Row( + children: [ + Text(folder), + const SizedBox(width: 8), + ], ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: targetFolders.length + 1, - itemBuilder: (context, index) { - if (index == targetFolders.length) { - return SizedBox( - height: 36, - child: Center( - child: TextButton( - onPressed: () { - newFolder().then((v) { - setState(() { - targetFolders = - LocalFavoritesManager() - .folderNames - .where((folder) => - folder != - favPage.folder) - .toList(); - }); - }); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.add, size: 20), - const SizedBox(width: 4), - Text("New Folder".tl), - ], - ), - ), + value: selectedLocalFolders.contains(folder), + onChanged: disabled + ? null + : (v) { + setState(() { + if (v!) { + selectedLocalFolders.add(folder); + } else { + selectedLocalFolders.remove(folder); + } + }); + }, + ); + }, + ), + ), + Center( + child: FilledButton( + onPressed: () { + if (selectedLocalFolders.isEmpty) { + return; + } + if (option == 'move') { + for (var c in selectedComics.keys) { + for (var s in selectedLocalFolders) { + LocalFavoritesManager().moveFavorite( + favPage.folder as String, + s, + c.id, + (c as FavoriteItem).type); + } + } + } else { + for (var c in selectedComics.keys) { + for (var s in selectedLocalFolders) { + LocalFavoritesManager().addComic( + s, + FavoriteItem( + id: c.id, + name: c.title, + coverPath: c.cover, + author: c.subtitle ?? '', + type: ComicType((c.sourceKey == 'local' + ? 0 + : c.sourceKey.hashCode)), + tags: c.tags ?? [], ), ); } - var folder = targetFolders[index]; - var disabled = false; - if (selectedLocalFolders.isNotEmpty) { - if (added.contains(folder) && - !added - .contains(selectedLocalFolders.first)) { - disabled = true; - } else if (!added.contains(folder) && - added - .contains(selectedLocalFolders.first)) { - disabled = true; - } - } - return CheckboxListTile( - title: Row( - children: [ - Text(folder), - const SizedBox(width: 8), - ], - ), - value: selectedLocalFolders.contains(folder), - onChanged: disabled - ? null - : (v) { - setState(() { - if (v!) { - selectedLocalFolders.add(folder); - } else { - selectedLocalFolders.remove(folder); - } - }); - }, - ); - }, - ), - ), - Center( - child: FilledButton( - onPressed: () { - if (selectedLocalFolders.isEmpty) { - return; - } - if (option == 'move') { - for (var c in selectedComics.keys) { - for (var s in selectedLocalFolders) { - LocalFavoritesManager().moveFavorite( - favPage.folder as String, - s, - c.id, - (c as FavoriteItem).type); - } - } - } else { - for (var c in selectedComics.keys) { - for (var s in selectedLocalFolders) { - LocalFavoritesManager().addComic( - s, - FavoriteItem( - id: c.id, - name: c.title, - coverPath: c.cover, - author: c.subtitle ?? '', - type: ComicType((c.sourceKey == 'local' - ? 0 - : c.sourceKey.hashCode)), - tags: c.tags ?? [], - ), - ); - } - } - } - context.pop(); - updateComics(); - _cancel(); - }, - child: - Text(option == 'move' ? "Move".tl : "Add".tl), - ).paddingVertical(16), - ), - ], + } + } + App.rootContext.pop(); + updateComics(); + _cancel(); + }, + child: Text(option == 'move' ? "Move".tl : "Add".tl), + ), ), - ), - )); - }, - ); - }, + ], + ), + ), + ), + ); + }, + ), ); } diff --git a/lib/pages/favorites/network_favorites_page.dart b/lib/pages/favorites/network_favorites_page.dart index b11e803..c382b09 100644 --- a/lib/pages/favorites/network_favorites_page.dart +++ b/lib/pages/favorites/network_favorites_page.dart @@ -94,6 +94,9 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> { return ComicList( key: comicListKey, leadingSliver: SliverAppbar( + style: context.width < changePoint + ? AppbarStyle.shadow + : AppbarStyle.blur, leading: Tooltip( message: "Folders".tl, child: context.width <= _kTwoPanelChangeWidth @@ -211,6 +214,9 @@ class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> { @override Widget build(BuildContext context) { var sliverAppBar = SliverAppbar( + style: context.width < changePoint + ? AppbarStyle.shadow + : AppbarStyle.blur, leading: Tooltip( message: "Folders".tl, child: context.width <= _kTwoPanelChangeWidth diff --git a/pubspec.lock b/pubspec.lock index 4a58d34..eb64a71 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -393,8 +393,8 @@ packages: dependency: "direct main" description: path: "." - ref: HEAD - resolved-ref: "51a27e2ca0e05becfb8ee3a506294dc4460721a8" + ref: "829a566b738a26ea98e523807f49838e21308543" + resolved-ref: "829a566b738a26ea98e523807f49838e21308543" url: "https://github.com/pkuislm/flutter_saf.git" source: git version: "0.0.1" From 511a9fdc091b2af8a69cf958974d55ff8c069141 Mon Sep 17 00:00:00 2001 From: nyne Date: Sat, 23 Nov 2024 18:45:10 +0800 Subject: [PATCH 19/28] hide "Copy to app local path" option on iOS and macOS --- lib/pages/home_page.dart | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 49a4d0d..6d4614d 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -563,15 +563,16 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { }, ), ).paddingHorizontal(8), - CheckboxListTile( - enabled: true, - title: Text("Copy to app local path".tl), - value: copyToLocalFolder, - onChanged:(v) { - setState(() { - copyToLocalFolder = !copyToLocalFolder; - }); - }).paddingHorizontal(8), + if(!App.isIOS && !App.isMacOS) + CheckboxListTile( + enabled: true, + title: Text("Copy to app local path".tl), + value: copyToLocalFolder, + onChanged:(v) { + setState(() { + copyToLocalFolder = !copyToLocalFolder; + }); + }).paddingHorizontal(8), const SizedBox(height: 8), Text(info).paddingHorizontal(24), ], From 904e4f118676ac0e001b3a28e94ca97fbbc58329 Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 10:37:17 +0800 Subject: [PATCH 20/28] fix the issue of hiding UI --- lib/main.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index d2e827d..8d11045 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -79,7 +79,7 @@ class _MyAppState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (!App.isMobile) { + if (!App.isMobile || !appdata.settings['authorizationRequired']) { return; } if (state == AppLifecycleState.inactive && hideContentOverlay == null) { @@ -101,7 +101,6 @@ class _MyAppState extends State with WidgetsBindingObserver { hideContentOverlay = null; } if (state == AppLifecycleState.hidden && - appdata.settings['authorizationRequired'] && !isAuthPageActive && !IO.isSelectingFiles) { isAuthPageActive = true; From 5bc3ddaf2638d5710596bde1ebf650ba56307afc Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 10:43:53 +0800 Subject: [PATCH 21/28] fix the issue of opening a local comic in history page --- lib/pages/history_page.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/pages/history_page.dart b/lib/pages/history_page.dart index 05c9466..37e8c4e 100644 --- a/lib/pages/history_page.dart +++ b/lib/pages/history_page.dart @@ -97,7 +97,9 @@ class _HistoryPageState extends State { e.subtitle, null, getDescription(e), - e.type.comicSource?.key ?? "Invalid:${e.type.value}", + e.type == ComicType.local + ? 'local' + : e.type.comicSource?.key ?? "Unknown:${e.type.value}", null, null, ); @@ -113,11 +115,16 @@ class _HistoryPageState extends State { text: 'Remove'.tl, color: context.colorScheme.error, onClick: () { - if (c.sourceKey.startsWith("Invalid")) { + if (c.sourceKey.startsWith("Unknown")) { HistoryManager().remove( c.id, ComicType(int.parse(c.sourceKey.split(':')[1])), ); + } else if (c.sourceKey == 'local') { + HistoryManager().remove( + c.id, + ComicType.local, + ); } else { HistoryManager().remove( c.id, From 4b32165aae2c599a58fe515dd864e80a37e43992 Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 11:08:21 +0800 Subject: [PATCH 22/28] update version code --- lib/foundation/app.dart | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index 1cd8ab9..5428cac 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -10,7 +10,7 @@ export "widget_utils.dart"; export "context.dart"; class _App { - final version = "1.0.6"; + final version = "1.0.7"; bool get isAndroid => Platform.isAndroid; diff --git a/pubspec.yaml b/pubspec.yaml index 463d582..e43a9c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: venera description: "A comic app." publish_to: 'none' -version: 1.0.6+106 +version: 1.0.7+107 environment: sdk: '>=3.5.0 <4.0.0' From e2aceb857d381b32042a7fda1870dd02d6572d0e Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 12:03:12 +0800 Subject: [PATCH 23/28] handle invalid local path --- lib/foundation/local.dart | 50 ++++++++++++++++++++++++--------------- lib/foundation/log.dart | 22 ++++++++--------- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 2ff2a21..3482bab 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -175,6 +175,27 @@ class LocalManager with ChangeNotifier { return null; } + Future findDefaultPath() async { + if (App.isAndroid) { + var external = await getExternalStorageDirectories(); + if (external != null && external.isNotEmpty) { + return FilePath.join(external.first.path, 'local'); + } else { + return FilePath.join(App.dataPath, 'local'); + } + } else if (App.isIOS) { + var oldPath = FilePath.join(App.dataPath, 'local'); + if (Directory(oldPath).existsSync() && Directory(oldPath).listSync().isNotEmpty) { + return oldPath; + } else { + var directory = await getApplicationDocumentsDirectory(); + return FilePath.join(directory.path, 'local'); + } + } else { + return FilePath.join(App.dataPath, 'local'); + } + } + Future init() async { _db = sqlite3.open( '${App.dataPath}/local.db', @@ -196,28 +217,19 @@ class LocalManager with ChangeNotifier { '''); if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) { path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync(); + if (!Directory(path).existsSync()) { + path = await findDefaultPath(); + } } else { - if (App.isAndroid) { - var external = await getExternalStorageDirectories(); - if (external != null && external.isNotEmpty) { - path = FilePath.join(external.first.path, 'local'); - } else { - path = FilePath.join(App.dataPath, 'local'); - } - } else if (App.isIOS) { - var oldPath = FilePath.join(App.dataPath, 'local'); - if (Directory(oldPath).existsSync() && Directory(oldPath).listSync().isNotEmpty) { - path = oldPath; - } else { - var directory = await getApplicationDocumentsDirectory(); - path = FilePath.join(directory.path, 'local'); - } - } else { - path = FilePath.join(App.dataPath, 'local'); + path = await findDefaultPath(); + } + try { + if (!Directory(path).existsSync()) { + await Directory(path).create(); } } - if (!Directory(path).existsSync()) { - await Directory(path).create(); + catch(e, s) { + Log.error("IO", "Failed to create local folder: $e", s); } restoreDownloadingTasks(); } diff --git a/lib/foundation/log.dart b/lib/foundation/log.dart index ffe051b..54b47ed 100644 --- a/lib/foundation/log.dart +++ b/lib/foundation/log.dart @@ -32,11 +32,11 @@ class Log { static const String? logFile = null; static void printWarning(String text) { - print('\x1B[33m$text\x1B[0m'); + debugPrint('\x1B[33m$text\x1B[0m'); } static void printError(String text) { - print('\x1B[31m$text\x1B[0m'); + debugPrint('\x1B[31m$text\x1B[0m'); } static void addLog(LogLevel level, String title, String content) { @@ -44,15 +44,15 @@ class Log { content = "${content.substring(0, maxLogLength)}..."; } - if (kDebugMode) { - switch (level) { - case LogLevel.error: - printError(content); - case LogLevel.warning: - printWarning(content); - case LogLevel.info: - print(content); - } + switch (level) { + case LogLevel.error: + printError(content); + case LogLevel.warning: + printWarning(content); + case LogLevel.info: + if(kDebugMode) { + debugPrint(content); + } } var newLog = LogItem(level, title, content); From 5d99b6ed996d62628e7ef0770e2fa43cfeb02341 Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 12:47:08 +0800 Subject: [PATCH 24/28] fix download --- lib/network/download.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/network/download.dart b/lib/network/download.dart index 1d0683e..da2b93f 100644 --- a/lib/network/download.dart +++ b/lib/network/download.dart @@ -253,7 +253,7 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin { await LocalManager().saveCurrentDownloadingTasks(); - if (cover == null) { + if (_cover == null) { var res = await runWithRetry(() async { Uint8List? data; await for (var progress From bf1930cea2422cbad4f2786972a5cbfa40357d00 Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 12:48:37 +0800 Subject: [PATCH 25/28] show comment action button if comic.comments is empty --- lib/pages/comic_page.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 5833610..2305e76 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -288,7 +288,8 @@ class _ComicPageState extends LoadingState onLongPressed: quickFavorite, iconColor: context.useTextColor(Colors.purple), ), - if (comicSource.commentsLoader != null && comic.comments == null) + if (comicSource.commentsLoader != null && + (comic.comments == null || comic.comments!.isEmpty)) _ActionButton( icon: const Icon(Icons.comment), text: (comic.commentsCount ?? 'Comments'.tl).toString(), From 2408096a7c50ad3e9aaca1c11a82c4c99ee010ea Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 12:53:06 +0800 Subject: [PATCH 26/28] fix cbz export --- lib/utils/cbz.dart | 2 +- lib/utils/io.dart | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart index a2458f2..35a9fd6 100644 --- a/lib/utils/cbz.dart +++ b/lib/utils/cbz.dart @@ -187,7 +187,7 @@ abstract class CBZ { } int i = 1; for (var image in allImages) { - var src = File(image.replaceFirst('file://', '')); + var src = openFilePlatform(image); var width = allImages.length.toString().length; var dstName = '${i.toString().padLeft(width, '0')}.${image.split('.').last}'; diff --git a/lib/utils/io.dart b/lib/utils/io.dart index 50ef88a..9df3d29 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -324,6 +324,9 @@ Directory openDirectoryPlatform(String path) { } File openFilePlatform(String path) { + if(path.startsWith("file://")) { + path = path.substring(7); + } if(App.isAndroid) { var f = AndroidFile.fromPathSync(path); if(f == null) { From 1500d2a1d27bdbfaf799931735f078c7cbaba3d1 Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 13:12:06 +0800 Subject: [PATCH 27/28] fix getImages --- lib/foundation/local.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 3482bab..41bc3a5 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -364,8 +364,9 @@ class LocalManager with ChangeNotifier { var files = []; await for (var entity in directory.list()) { if (entity is File) { - if (entity.absolute.path.replaceFirst(path, '').substring(1) == - comic.cover) { + // Do not exclude comic.cover, since it may be the first page of the chapter. + // A file with name starting with 'cover.' is not a comic page. + if (entity.name.startsWith('cover.')) { continue; } //Hidden file in some file system From f155bed69493d7e9fdff91fde9d7e0531f910a86 Mon Sep 17 00:00:00 2001 From: nyne Date: Sun, 24 Nov 2024 15:21:22 +0800 Subject: [PATCH 28/28] update flutter_saf --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index e43a9c4..f20cec6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,7 +68,7 @@ dependencies: flutter_saf: git: url: https://github.com/pkuislm/flutter_saf.git - ref: 829a566b738a26ea98e523807f49838e21308543 + ref: dd5242918da0ea9a0a50b0f87ade7a2def65453d dev_dependencies: flutter_test: