diff --git a/assets/translation.json b/assets/translation.json index 33f9aa7..32392a4 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -362,7 +362,12 @@ "Mark all as read": "全部标记为已读", "Do you want to mark all as read?" : "您要全部标记为已读吗?", "Swipe down for previous chapter": "向下滑动查看上一章", - "Swipe up for next chapter": "向上滑动查看下一章" + "Swipe up for next chapter": "向上滑动查看下一章", + "Initial Page": "初始页面", + "Home Page": "主页", + "Favorites Page": "收藏页面", + "Explore Page": "探索页面", + "Categories Page": "分类页面" }, "zh_TW": { "Home": "首頁", @@ -727,6 +732,11 @@ "Mark all as read": "全部標記為已讀", "Do you want to mark all as read?" : "您要全部標記為已讀嗎?", "Swipe down for previous chapter": "向下滑動查看上一章", - "Swipe up for next chapter": "向上滑動查看下一章" + "Swipe up for next chapter": "向上滑動查看下一章", + "Initial Page": "初始頁面", + "Home Page": "主頁", + "Favorites Page": "收藏頁面", + "Explore Page": "探索頁面", + "Categories Page": "分類頁面" } } diff --git a/lib/components/message.dart b/lib/components/message.dart index 777ec92..1a67f61 100644 --- a/lib/components/message.dart +++ b/lib/components/message.dart @@ -125,11 +125,11 @@ class OverlayWidgetState extends State { void showDialogMessage(BuildContext context, String title, String message) { showDialog( context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(message), + builder: (context) => ContentDialog( + title: title, + content: Text(message).paddingHorizontal(16), actions: [ - TextButton( + FilledButton( onPressed: context.pop, child: Text("OK".tl), ) diff --git a/lib/components/side_bar.dart b/lib/components/side_bar.dart index 752cae4..a881500 100644 --- a/lib/components/side_bar.dart +++ b/lib/components/side_bar.dart @@ -1,15 +1,13 @@ part of 'components.dart'; class SideBarRoute extends PopupRoute { - SideBarRoute(this.title, this.widget, + SideBarRoute(this.widget, {this.showBarrier = true, this.useSurfaceTintColor = false, required this.width, this.addBottomPadding = true, this.addTopPadding = true}); - final String? title; - final Widget widget; final bool showBarrier; @@ -36,11 +34,7 @@ class SideBarRoute extends PopupRoute { Animation secondaryAnimation) { bool showSideBar = MediaQuery.of(context).size.width > width; - Widget body = SidebarBody( - title: title, - widget: widget, - autoChangeTitleBarColor: !useSurfaceTintColor, - ); + Widget body = widget; if (addTopPadding) { body = Padding( @@ -129,97 +123,13 @@ class SideBarRoute extends PopupRoute { } } -class SidebarBody extends StatefulWidget { - const SidebarBody( - {required this.title, - required this.widget, - required this.autoChangeTitleBarColor, - super.key}); - - final String? title; - final Widget widget; - final bool autoChangeTitleBarColor; - - @override - State createState() => _SidebarBodyState(); -} - -class _SidebarBodyState extends State { - bool top = true; - - @override - Widget build(BuildContext context) { - Widget body = Expanded(child: widget.widget); - - if (widget.autoChangeTitleBarColor) { - body = NotificationListener( - onNotification: (notifications) { - if (notifications.metrics.pixels == - notifications.metrics.minScrollExtent && - !top) { - setState(() { - top = true; - }); - } else if (notifications.metrics.pixels != - notifications.metrics.minScrollExtent && - top) { - setState(() { - top = false; - }); - } - return false; - }, - child: body, - ); - } - - return Column( - children: [ - if (widget.title != null) - Container( - height: 60 + MediaQuery.of(context).padding.top, - color: top - ? null - : Theme.of(context).colorScheme.surfaceTint.withAlpha(20), - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - child: Row( - children: [ - const SizedBox( - width: 8, - ), - Tooltip( - message: "Back".tl, - child: IconButton( - iconSize: 25, - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), - ), - ), - const SizedBox( - width: 10, - ), - Text( - widget.title!, - style: const TextStyle(fontSize: 22), - ) - ], - ), - ), - body - ], - ); - } -} - Future showSideBar(BuildContext context, Widget widget, - {String? title, - bool showBarrier = true, + {bool showBarrier = true, bool useSurfaceTintColor = false, double width = 500, bool addTopPadding = false}) { return Navigator.of(context).push( SideBarRoute( - title, widget, showBarrier: showBarrier, useSurfaceTintColor: useSurfaceTintColor, diff --git a/lib/components/window_frame.dart b/lib/components/window_frame.dart index bc57a99..6525970 100644 --- a/lib/components/window_frame.dart +++ b/lib/components/window_frame.dart @@ -10,6 +10,34 @@ import 'package:window_manager/window_manager.dart'; const _kTitleBarHeight = 36.0; +class WindowFrameController extends InheritedWidget { + /// Whether the window frame is hidden. + final bool isWindowFrameHidden; + + /// Sets the visibility of the window frame. + final void Function(bool) setWindowFrame; + + /// Adds a listener that will be called when close button is clicked. + /// The listener should return `true` to allow the window to be closed. + final void Function(WindowCloseListener listener) addCloseListener; + + /// Removes a close listener. + final void Function(WindowCloseListener listener) removeCloseListener; + + const WindowFrameController._create({ + required this.isWindowFrameHidden, + required this.setWindowFrame, + required this.addCloseListener, + required this.removeCloseListener, + required super.child, + }); + + @override + bool updateShouldNotify(covariant InheritedWidget oldWidget) { + return false; + } +} + class WindowFrame extends StatefulWidget { const WindowFrame(this.child, {super.key}); @@ -17,98 +45,145 @@ class WindowFrame extends StatefulWidget { @override State createState() => _WindowFrameState(); + + static WindowFrameController of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()!; + } } +typedef WindowCloseListener = bool Function(); + class _WindowFrameState extends State { - bool isHideWindowFrame = false; + bool isWindowFrameHidden = false; bool useDarkTheme = false; + var closeListeners = []; + + /// Sets the visibility of the window frame. + void setWindowFrame(bool show) { + setState(() { + isWindowFrameHidden = !show; + }); + } + + /// Adds a listener that will be called when close button is clicked. + /// The listener should return `true` to allow the window to be closed. + void addCloseListener(WindowCloseListener listener) { + closeListeners.add(listener); + } + + /// Removes a close listener. + void removeCloseListener(WindowCloseListener listener) { + closeListeners.remove(listener); + } + + void _onClose() { + for (var listener in closeListeners) { + if (!listener()) { + return; + } + } + windowManager.close(); + } @override Widget build(BuildContext context) { if (App.isMobile) return widget.child; - if (isHideWindowFrame) return widget.child; - var body = Stack( + Widget body = Stack( children: [ Positioned.fill( child: MediaQuery( data: MediaQuery.of(context).copyWith( - padding: const EdgeInsets.only(top: _kTitleBarHeight)), + padding: isWindowFrameHidden + ? null + : const EdgeInsets.only(top: _kTitleBarHeight), + ), child: widget.child, ), ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Material( - color: Colors.transparent, - child: Theme( - data: Theme.of(context).copyWith( - brightness: useDarkTheme ? Brightness.dark : null, - ), - child: Builder(builder: (context) { - return SizedBox( - height: _kTitleBarHeight, - child: Row( - children: [ - if (App.isMacOS) - const DragToMoveArea( - child: SizedBox( - height: double.infinity, - width: 16, - ), - ).paddingRight(52) - else - const SizedBox(width: 12), - Expanded( - child: DragToMoveArea( - child: Text( - 'Venera', - style: TextStyle( - fontSize: 13, - color: (useDarkTheme || - context.brightness == Brightness.dark) - ? Colors.white - : Colors.black, + if (!isWindowFrameHidden) + Positioned( + top: 0, + left: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: Theme( + data: Theme.of(context).copyWith( + brightness: useDarkTheme ? Brightness.dark : null, + ), + child: Builder(builder: (context) { + return SizedBox( + height: _kTitleBarHeight, + child: Row( + children: [ + if (App.isMacOS) + const DragToMoveArea( + child: SizedBox( + height: double.infinity, + width: 16, ), - ) - .toAlign(Alignment.centerLeft) - .paddingLeft(4 + (App.isMacOS ? 25 : 0)), + ).paddingRight(52) + else + const SizedBox(width: 12), + Expanded( + child: DragToMoveArea( + child: Text( + 'Venera', + style: TextStyle( + fontSize: 13, + color: (useDarkTheme || + context.brightness == Brightness.dark) + ? Colors.white + : Colors.black, + ), + ) + .toAlign(Alignment.centerLeft) + .paddingLeft(4 + (App.isMacOS ? 25 : 0)), + ), ), - ), - if (kDebugMode) - const TextButton( - onPressed: debug, - child: Text('Debug'), - ), - if (!App.isMacOS) const WindowButtons() - ], - ), - ); - }), + if (kDebugMode) + const TextButton( + onPressed: debug, + child: Text('Debug'), + ), + if (!App.isMacOS) _WindowButtons( + onClose: _onClose, + ) + ], + ), + ); + }), + ), ), - ), - ) + ) ], ); if (App.isLinux) { - return VirtualWindowFrame(child: body); - } else { - return body; + body = VirtualWindowFrame(child: body); } + + return WindowFrameController._create( + isWindowFrameHidden: isWindowFrameHidden, + setWindowFrame: setWindowFrame, + addCloseListener: addCloseListener, + removeCloseListener: removeCloseListener, + child: body, + ); } } -class WindowButtons extends StatefulWidget { - const WindowButtons({super.key}); +class _WindowButtons extends StatefulWidget { + const _WindowButtons({required this.onClose}); + + final void Function() onClose; @override - State createState() => _WindowButtonsState(); + State<_WindowButtons> createState() => _WindowButtonsState(); } -class _WindowButtonsState extends State with WindowListener { +class _WindowButtonsState extends State<_WindowButtons> with WindowListener { bool isMaximized = false; @override @@ -197,9 +272,7 @@ class _WindowButtonsState extends State with WindowListener { color: !dark ? Colors.white : Colors.black, ), hoverColor: Colors.red, - onPressed: () { - windowManager.close(); - }, + onPressed: widget.onClose, ) ], ), @@ -567,5 +640,5 @@ TransitionBuilder VirtualWindowFrameInit() { } void debug() { - ComicSource.reload(); + ComicSourceManager().reload(); } diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index 7415bbc..12e1fa7 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -13,7 +13,7 @@ export "widget_utils.dart"; export "context.dart"; class _App { - final version = "1.3.1"; + final version = "1.3.2"; bool get isAndroid => Platform.isAndroid; @@ -77,10 +77,15 @@ class _App { Future init() async { cachePath = (await getApplicationCacheDirectory()).path; dataPath = (await getApplicationSupportDirectory()).path; - await data.init(); - await history.init(); - await favorites.init(); - await local.init(); + } + + Future initComponents() async { + await Future.wait([ + data.init(), + history.init(), + favorites.init(), + local.init(), + ]); } Function? _forceRebuildHandler; diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index 567cdda..93c1405 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -7,7 +7,9 @@ import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/io.dart'; class Appdata { - final Settings settings = Settings(); + Appdata._create(); + + final Settings settings = Settings._create(); var searchHistory = []; @@ -110,10 +112,10 @@ class Appdata { } } -final appdata = Appdata(); +final appdata = Appdata._create(); class Settings with ChangeNotifier { - Settings(); + Settings._create(); final _data = { 'comicDisplayMode': 'detailed', // detailed, brief @@ -161,6 +163,7 @@ class Settings with ChangeNotifier { 'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json", 'preloadImageCount': 4, 'followUpdatesFolder': null, + 'initialPage': '0', }; operator [](String key) { diff --git a/lib/foundation/comic_source/category.dart b/lib/foundation/comic_source/category.dart index ed62432..7aae515 100644 --- a/lib/foundation/comic_source/category.dart +++ b/lib/foundation/comic_source/category.dart @@ -145,7 +145,7 @@ class RandomCategoryPartWithRuntimeData extends BaseCategoryPart { } CategoryData getCategoryDataWithKey(String key) { - for (var source in ComicSource._sources) { + for (var source in ComicSource.all()) { if (source.categoryData?.key == key) { return source.categoryData!; } diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index a0d3cc7..1ef748c 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -13,6 +13,7 @@ import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/utils/data_sync.dart'; import 'package:venera/utils/ext.dart'; +import 'package:venera/utils/init.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/translations.dart'; @@ -27,81 +28,29 @@ part 'parser.dart'; part 'models.dart'; -/// build comic list, [Res.subData] should be maxPage or null if there is no limit. -typedef ComicListBuilder = Future>> Function(int page); +part 'types.dart'; -/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page. -typedef ComicListBuilderWithNext = Future>> Function( - String? next); +class ComicSourceManager with ChangeNotifier, Init { + final List _sources = []; -typedef LoginFunction = Future> Function(String, String); + static ComicSourceManager? _instance; -typedef LoadComicFunc = Future> Function(String id); + ComicSourceManager._create(); -typedef LoadComicPagesFunc = Future>> Function( - String id, String? ep); + factory ComicSourceManager() => _instance ??= ComicSourceManager._create(); -typedef CommentsLoader = Future>> Function( - String id, String? subId, int page, String? replyTo); + List all() => List.from(_sources); -typedef SendCommentFunc = Future> Function( - String id, String? subId, String content, String? replyTo); - -typedef GetImageLoadingConfigFunc = Future> Function( - String imageKey, String comicId, String epId)?; -typedef GetThumbnailLoadingConfigFunc = Map Function( - String imageKey)?; - -typedef ComicThumbnailLoader = Future>> Function( - String comicId, String? next); - -typedef LikeOrUnlikeComicFunc = Future> Function( - String comicId, bool isLiking); - -/// [isLiking] is true if the user is liking the comment, false if unliking. -/// return the new likes count or null. -typedef LikeCommentFunc = Future> Function( - String comicId, String? subId, String commentId, bool isLiking); - -/// [isUp] is true if the user is upvoting the comment, false if downvoting. -/// return the new vote count or null. -typedef VoteCommentFunc = Future> Function( - String comicId, String? subId, String commentId, bool isUp, bool isCancel); - -typedef HandleClickTagEvent = Map Function( - String namespace, String tag); - -/// [rating] is the rating value, 0-10. 1 represents 0.5 star. -typedef StarRatingFunc = Future> Function(String comicId, int rating); - -class ComicSource { - static final List _sources = []; - - static final List _listeners = []; - - static void addListener(Function listener) { - _listeners.add(listener); - } - - static void removeListener(Function listener) { - _listeners.remove(listener); - } - - static void notifyListeners() { - for (var listener in _listeners) { - listener(); - } - } - - static List all() => List.from(_sources); - - static ComicSource? find(String key) => + ComicSource? find(String key) => _sources.firstWhereOrNull((element) => element.key == key); - static ComicSource? fromIntKey(int key) => + ComicSource? fromIntKey(int key) => _sources.firstWhereOrNull((element) => element.key.hashCode == key); - static Future init() async { + @override + @protected + Future doInit() async { + await JsEngine().ensureInit(); final path = "${App.dataPath}/comic_source"; if (!(await Directory(path).exists())) { Directory(path).create(); @@ -120,26 +69,49 @@ class ComicSource { } } - static Future reload() async { + Future reload() async { _sources.clear(); JsEngine().runCode("ComicSource.sources = {};"); - await init(); + await doInit(); notifyListeners(); } - static void add(ComicSource source) { + void add(ComicSource source) { _sources.add(source); notifyListeners(); } - static void remove(String key) { + void remove(String key) { _sources.removeWhere((element) => element.key == key); notifyListeners(); } - static final availableUpdates = {}; + bool get isEmpty => _sources.isEmpty; - static bool get isEmpty => _sources.isEmpty; + /// Key is the source key, value is the version. + final _availableUpdates = {}; + + void updateAvailableUpdates(Map updates) { + _availableUpdates.addAll(updates); + notifyListeners(); + } + + Map get availableUpdates => Map.from(_availableUpdates); + + void notifyStateChange() { + notifyListeners(); + } +} + +class ComicSource { + static List all() => ComicSourceManager().all(); + + static ComicSource? find(String key) => ComicSourceManager().find(key); + + static ComicSource? fromIntKey(int key) => + ComicSourceManager().fromIntKey(key); + + static bool get isEmpty => ComicSourceManager().isEmpty; /// Name of this source. final String name; @@ -321,7 +293,7 @@ class AccountConfig { this.onLoginWithWebviewSuccess, this.cookieFields, this.validateCookies, - ) : infoItems = const []; + ) : infoItems = const []; } class AccountInfoItem { @@ -478,4 +450,4 @@ class ArchiveDownloader { 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 c80fea0..2ecc171 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -336,8 +336,10 @@ class ComicChapters { } if (chapters.isNotEmpty) { return ComicChapters(chapters); - } else { + } else if (groupedChapters.isNotEmpty) { return ComicChapters.grouped(groupedChapters); + } else { + throw ArgumentError("Empty chapter list"); } } diff --git a/lib/foundation/comic_source/types.dart b/lib/foundation/comic_source/types.dart new file mode 100644 index 0000000..fbacc8f --- /dev/null +++ b/lib/foundation/comic_source/types.dart @@ -0,0 +1,48 @@ +part of 'comic_source.dart'; + +/// build comic list, [Res.subData] should be maxPage or null if there is no limit. +typedef ComicListBuilder = Future>> Function(int page); + +/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page. +typedef ComicListBuilderWithNext = Future>> Function( + String? next); + +typedef LoginFunction = Future> Function(String, String); + +typedef LoadComicFunc = Future> Function(String id); + +typedef LoadComicPagesFunc = Future>> Function( + String id, String? ep); + +typedef CommentsLoader = Future>> Function( + String id, String? subId, int page, String? replyTo); + +typedef SendCommentFunc = Future> Function( + String id, String? subId, String content, String? replyTo); + +typedef GetImageLoadingConfigFunc = Future> Function( + String imageKey, String comicId, String epId)?; +typedef GetThumbnailLoadingConfigFunc = Map Function( + String imageKey)?; + +typedef ComicThumbnailLoader = Future>> Function( + String comicId, String? next); + +typedef LikeOrUnlikeComicFunc = Future> Function( + String comicId, bool isLiking); + +/// [isLiking] is true if the user is liking the comment, false if unliking. +/// return the new likes count or null. +typedef LikeCommentFunc = Future> Function( + String comicId, String? subId, String commentId, bool isLiking); + +/// [isUp] is true if the user is upvoting the comment, false if downvoting. +/// return the new vote count or null. +typedef VoteCommentFunc = Future> Function( + String comicId, String? subId, String commentId, bool isUp, bool isCancel); + +typedef HandleClickTagEvent = Map Function( + String namespace, String tag); + +/// [rating] is the rating value, 0-10. 1 represents 0.5 star. +typedef StarRatingFunc = Future> Function(String comicId, int rating); \ No newline at end of file diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index 574c69c..3772491 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math' as math; import 'package:crypto/crypto.dart'; import 'package:dio/io.dart'; +import 'package:flutter/foundation.dart' show protected; import 'package:flutter/services.dart'; import 'package:html/parser.dart' as html; import 'package:html/dom.dart' as dom; @@ -24,6 +25,7 @@ import 'package:venera/components/js_ui.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/network/app_dio.dart'; import 'package:venera/network/cookie_jar.dart'; +import 'package:venera/utils/init.dart'; import 'comic_source/comic_source.dart'; import 'consts.dart'; @@ -40,7 +42,7 @@ class JavaScriptRuntimeException implements Exception { } } -class JsEngine with _JSEngineApi, JsUiApi { +class JsEngine with _JSEngineApi, JsUiApi, Init { factory JsEngine() => _cache ?? (_cache = JsEngine._create()); static JsEngine? _cache; @@ -64,7 +66,9 @@ class JsEngine with _JSEngineApi, JsUiApi { responseType: ResponseType.plain, validateStatus: (status) => true)); } - Future init() async { + @override + @protected + Future doInit() async { if (!_closed) { return; } diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index c2b462c..be0efe0 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -265,6 +265,7 @@ class LocalManager with ChangeNotifier { } _checkPathValidation(); _checkNoMedia(); + await ComicSourceManager().ensureInit(); restoreDownloadingTasks(); } diff --git a/lib/init.dart b/lib/init.dart index 8435771..939ea70 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -30,13 +30,15 @@ extension _FutureInit on Future { Future init() async { await App.init().wait(); - SingleInstanceCookieJar("${App.dataPath}/cookie.db"); + await SingleInstanceCookieJar.createInstance(); var futures = [ Rhttp.init(), + App.initComponents(), SAFTaskWorker().init().wait(), AppTranslation.init().wait(), TagsTranslation.readData().wait(), - JsEngine().init().then((_) => ComicSource.init()).wait(), + JsEngine().init().wait(), + ComicSourceManager().init().wait(), ]; await Future.wait(futures); CacheManager().setLimitSize(appdata.settings['cacheSize']); diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index 816b226..7689410 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -257,18 +257,7 @@ class RHttpAdapter implements HttpClientAdapter { Future? cancelFuture, ) async { var res = await rhttp.Rhttp.request( - method: switch (options.method) { - 'GET' => rhttp.HttpMethod.get, - 'POST' => rhttp.HttpMethod.post, - 'PUT' => rhttp.HttpMethod.put, - 'PATCH' => rhttp.HttpMethod.patch, - 'DELETE' => rhttp.HttpMethod.delete, - 'HEAD' => rhttp.HttpMethod.head, - 'OPTIONS' => rhttp.HttpMethod.options, - 'TRACE' => rhttp.HttpMethod.trace, - 'CONNECT' => rhttp.HttpMethod.connect, - _ => throw ArgumentError('Unsupported method: ${options.method}'), - }, + method: rhttp.HttpMethod(options.method), url: options.uri.toString(), settings: settings, expectBody: rhttp.HttpExpectBody.stream, diff --git a/lib/network/cookie_jar.dart b/lib/network/cookie_jar.dart index a8c073a..d06cc1e 100644 --- a/lib/network/cookie_jar.dart +++ b/lib/network/cookie_jar.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:sqlite3/sqlite3.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/utils/ext.dart'; @@ -200,6 +201,11 @@ class SingleInstanceCookieJar extends CookieJarSql { SingleInstanceCookieJar._create(super.path); static SingleInstanceCookieJar? instance; + + static Future createInstance() async { + var dataPath = (await getApplicationSupportDirectory()).path; + instance = SingleInstanceCookieJar("$dataPath/cookie.db"); + } } class CookieManagerSql extends Interceptor { diff --git a/lib/network/download.dart b/lib/network/download.dart index 39d77c0..d21017c 100644 --- a/lib/network/download.dart +++ b/lib/network/download.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:isolate'; import 'package:flutter/widgets.dart' show ChangeNotifier; +import 'package:flutter_saf/flutter_saf.dart'; +import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; @@ -739,11 +741,12 @@ class ArchiveDownloadTask extends DownloadTask { path = dir.path; } - var resultFile = File(FilePath.join(path!, "archive.zip")); + var archiveFile = + File(FilePath.join(App.dataPath, "archive_downloading.zip")); Log.info("Download", "Downloading $archiveUrl"); - _downloader = FileDownloader(archiveUrl, resultFile.path); + _downloader = FileDownloader(archiveUrl, archiveFile.path); bool isDownloaded = false; @@ -772,22 +775,33 @@ class ArchiveDownloadTask extends DownloadTask { } try { - await extractArchive(path!); + await _extractArchive(archiveFile.path, path!); } catch (e) { _setError("Failed to extract archive: $e"); return; } - await resultFile.deleteIgnoreError(); + await archiveFile.deleteIgnoreError(); LocalManager().completeTask(this); } - static Future extractArchive(String path) async { - var resultFile = FilePath.join(path, "archive.zip"); - await Isolate.run(() { - ZipFile.openAndExtract(resultFile, path); - }); + static Future _extractArchive(String archive, String outDir) async { + var out = Directory(outDir); + if (out is AndroidDirectory) { + // Saf directory can't be accessed by native code. + var cacheDir = FilePath.join(App.cachePath, "archive_downloading"); + Directory(cacheDir).forceCreateSync(); + await Isolate.run(() { + ZipFile.openAndExtract(archive, cacheDir); + }); + await copyDirectoryIsolate(Directory(cacheDir), Directory(outDir)); + await Directory(cacheDir).deleteIgnoreError(recursive: true); + } else { + await Isolate.run(() { + ZipFile.openAndExtract(archive, outDir); + }); + } } @override diff --git a/lib/pages/comic_details_page/comments_page.dart b/lib/pages/comic_details_page/comments_page.dart index de2589b..0240c46 100644 --- a/lib/pages/comic_details_page/comments_page.dart +++ b/lib/pages/comic_details_page/comments_page.dart @@ -651,10 +651,16 @@ class _CommentImage { } class RichCommentContent extends StatefulWidget { - const RichCommentContent({super.key, required this.text}); + const RichCommentContent({ + super.key, + required this.text, + this.showImages = true, + }); final String text; + final bool showImages; + @override State createState() => _RichCommentContentState(); } @@ -808,7 +814,7 @@ class _RichCommentContentState extends State { children: textSpan, ), ); - if (images.isNotEmpty) { + if (images.isNotEmpty && widget.showImages) { content = Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/pages/comic_details_page/comments_preview.dart b/lib/pages/comic_details_page/comments_preview.dart index b0b4c43..f6fc60e 100644 --- a/lib/pages/comic_details_page/comments_preview.dart +++ b/lib/pages/comic_details_page/comments_preview.dart @@ -138,7 +138,10 @@ class _CommentWidget extends StatelessWidget { ), const SizedBox(height: 4), Expanded( - child: RichCommentContent(text: comment.content).fixWidth(324), + child: RichCommentContent( + text: comment.content, + showImages: false, + ).fixWidth(324), ), const SizedBox(height: 4), if (comment.time != null) @@ -147,4 +150,4 @@ class _CommentWidget extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index 49d7d03..96fd888 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -40,10 +40,11 @@ class ComicSourcePage extends StatelessWidget { } } if (shouldUpdate.isNotEmpty) { + var updates = {}; for (var key in shouldUpdate) { - ComicSource.availableUpdates[key] = versions[key]!; + updates[key] = versions[key]!; } - ComicSource.notifyListeners(); + ComicSourceManager().updateAvailableUpdates(updates); } return shouldUpdate.length; } @@ -73,13 +74,13 @@ class _BodyState extends State<_Body> { @override void initState() { super.initState(); - ComicSource.addListener(updateUI); + ComicSourceManager().addListener(updateUI); } @override void dispose() { super.dispose(); - ComicSource.removeListener(updateUI); + ComicSourceManager().removeListener(updateUI); } @override @@ -115,7 +116,7 @@ class _BodyState extends State<_Body> { onConfirm: () { var file = File(source.filePath); file.delete(); - ComicSource.remove(source.key); + ComicSourceManager().remove(source.key); _validatePages(); App.forceRebuild(); }, @@ -136,7 +137,7 @@ class _BodyState extends State<_Body> { child: const Text("cancel")), TextButton( onPressed: () async { - await ComicSource.reload(); + await ComicSourceManager().reload(); App.forceRebuild(); }, child: const Text("continue")), @@ -150,7 +151,7 @@ class _BodyState extends State<_Body> { } context.to( () => _EditFilePage(source.filePath, () async { - await ComicSource.reload(); + await ComicSourceManager().reload(); setState(() {}); }), ); @@ -162,7 +163,7 @@ class _BodyState extends State<_Body> { App.rootContext.showMessage(message: "Invalid url config"); return; } - ComicSource.remove(source.key); + ComicSourceManager().remove(source.key); bool cancel = false; LoadingDialogController? controller; if (showLoading) { @@ -179,14 +180,14 @@ class _BodyState extends State<_Body> { controller?.close(); await ComicSourceParser().parse(res.data!, source.filePath); await File(source.filePath).writeAsString(res.data!); - if (ComicSource.availableUpdates.containsKey(source.key)) { - ComicSource.availableUpdates.remove(source.key); + if (ComicSourceManager().availableUpdates.containsKey(source.key)) { + ComicSourceManager().availableUpdates.remove(source.key); } } catch (e) { if (cancel) return; App.rootContext.showMessage(message: e.toString()); } - await ComicSource.reload(); + await ComicSourceManager().reload(); App.forceRebuild(); } @@ -304,7 +305,7 @@ class _BodyState extends State<_Body> { Future addSource(String js, String fileName) async { var comicSource = await ComicSourceParser().createAndParse(js, fileName); - ComicSource.add(comicSource); + ComicSourceManager().add(comicSource); _addAllPagesWithComicSource(comicSource); appdata.saveData(); App.forceRebuild(); @@ -563,7 +564,7 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> { } void showUpdateDialog() async { - var text = ComicSource.availableUpdates.entries.map((e) { + var text = ComicSourceManager().availableUpdates.entries.map((e) { return "${ComicSource.find(e.key)!.name}: ${e.value}"; }).join("\n"); bool doUpdate = false; @@ -592,9 +593,9 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> { withProgress: true, ); int current = 0; - int total = ComicSource.availableUpdates.length; + int total = ComicSourceManager().availableUpdates.length; try { - var shouldUpdate = ComicSource.availableUpdates.keys.toList(); + var shouldUpdate = ComicSourceManager().availableUpdates.keys.toList(); for (var key in shouldUpdate) { var source = ComicSource.find(key)!; await _BodyState.update(source, false); @@ -692,7 +693,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> { @override Widget build(BuildContext context) { - var newVersion = ComicSource.availableUpdates[source.key]; + var newVersion = ComicSourceManager().availableUpdates[source.key]; bool hasUpdate = newVersion != null && compareSemVer(newVersion, source.version); @@ -960,7 +961,7 @@ class _SliverComicSourceState extends State<_SliverComicSource> { source.data["account"] = null; source.account?.logout(); source.saveData(); - ComicSource.notifyListeners(); + ComicSourceManager().notifyStateChange(); setState(() {}); }, trailing: const Icon(Icons.logout), diff --git a/lib/pages/follow_updates_page.dart b/lib/pages/follow_updates_page.dart index 7b56868..0633f49 100644 --- a/lib/pages/follow_updates_page.dart +++ b/lib/pages/follow_updates_page.dart @@ -703,8 +703,9 @@ abstract class FollowUpdatesService { if (_isInitialized) return; _isInitialized = true; _check(); + DataSync().addListener(updateFollowUpdatesUI); // A short interval will not affect the performance since every comic has a check time. - Timer.periodic(const Duration(minutes: 5), (timer) { + Timer.periodic(const Duration(minutes: 10), (timer) { _check(); }); } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index c715e22..94a281f 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -162,16 +162,50 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + if (DataSync().lastError != null) + InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + showDialogMessage( + App.rootContext, + "Error".tl, + DataSync().lastError!, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: context.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: Colors.red, + size: 18, + ), + const SizedBox(width: 4), + Text('Error'.tl, style: ts.s12), + ], + ), + ), + ).paddingRight(4), IconButton( - icon: const Icon(Icons.cloud_upload_outlined), - onPressed: () async { - DataSync().uploadData(); - }), + icon: const Icon(Icons.cloud_upload_outlined), + onPressed: () async { + DataSync().uploadData(); + }, + ), IconButton( - icon: const Icon(Icons.cloud_download_outlined), - onPressed: () async { - DataSync().downloadData(); - }), + icon: const Icon(Icons.cloud_download_outlined), + onPressed: () async { + DataSync().downloadData(); + }, + ), ], ), ), @@ -538,7 +572,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> { ], ), onPressed: () { - launchUrlString("https://github.com/venera-app/venera/blob/master/doc/import_comic.md"); + launchUrlString( + "https://github.com/venera-app/venera/blob/master/doc/import_comic.md"); }, ).fixWidth(90).paddingRight(8), Button.filled( @@ -595,19 +630,19 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> { @override void initState() { comicSources = ComicSource.all().map((e) => e.name).toList(); - ComicSource.addListener(onComicSourceChange); + ComicSourceManager().addListener(onComicSourceChange); super.initState(); } @override void dispose() { - ComicSource.removeListener(onComicSourceChange); + ComicSourceManager().removeListener(onComicSourceChange); super.dispose(); } int get _availableUpdates { int c = 0; - ComicSource.availableUpdates.forEach((key, version) { + ComicSourceManager().availableUpdates.forEach((key, version) { var source = ComicSource.find(key); if (source != null) { if (compareSemVer(version, source.version)) { @@ -697,14 +732,24 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.update, color: context.colorScheme.primary, size: 20,), + Icon( + Icons.update, + color: context.colorScheme.primary, + size: 20, + ), const SizedBox(width: 8), - Text("@c updates".tlParams({ - 'c': _availableUpdates, - }), style: ts.withColor(context.colorScheme.primary),), + Text( + "@c updates".tlParams({ + 'c': _availableUpdates, + }), + style: ts.withColor(context.colorScheme.primary), + ), ], ), - ).toAlign(Alignment.centerLeft).paddingHorizontal(16).paddingBottom(8), + ) + .toAlign(Alignment.centerLeft) + .paddingHorizontal(16) + .paddingBottom(8), ], ), ), @@ -844,7 +889,8 @@ class _ImageFavoritesState extends State { padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 2), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, + color: + Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), child: Text( diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index 5ae04c5..dee8bb0 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'; @@ -39,6 +40,7 @@ class _MainPageState extends State { _observer = NaviObserver(); _navigatorKey = GlobalKey(); App.mainNavigatorKey = _navigatorKey; + index = int.tryParse(appdata.settings['initialPage'].toString()) ?? 0; super.initState(); } @@ -60,6 +62,7 @@ class _MainPageState extends State { @override Widget build(BuildContext context) { return NaviPane( + initialPage: index, observer: _observer, navigatorKey: _navigatorKey!, paneItems: [ diff --git a/lib/pages/reader/reader.dart b/lib/pages/reader/reader.dart index d1d0cea..057e78b 100644 --- a/lib/pages/reader/reader.dart +++ b/lib/pages/reader/reader.dart @@ -15,6 +15,7 @@ import 'package:photo_view/photo_view_gallery.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:venera/components/components.dart'; import 'package:venera/components/custom_slider.dart'; +import 'package:venera/components/window_frame.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/cache_manager.dart'; @@ -169,6 +170,7 @@ class _ReaderState extends State void didChangeDependencies() { super.didChangeDependencies(); initImagesPerPage(widget.initialPage ?? 1); + initReaderWindow(); } void setImageCacheSize() async { @@ -191,6 +193,9 @@ class _ReaderState extends State @override void dispose() { + if (isFullscreen) { + fullscreen(); + } autoPageTurningTimer?.cancel(); focusNode.dispose(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -199,6 +204,7 @@ class _ReaderState extends State DataSync().onDataChanged(); }); PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; + disposeReaderWindow(); super.dispose(); } @@ -218,6 +224,9 @@ class _ReaderState extends State } void onKeyEvent(KeyEvent event) { + if (event.logicalKey == LogicalKeyboardKey.f12 && event is KeyUpEvent) { + fullscreen(); + } _imageViewController?.handleKeyEvent(event); } @@ -429,11 +438,8 @@ abstract mixin class _ReaderLocation { bool toPage(int page) { if (_validatePage(page)) { - if (page == this.page) { - if (!(chapter == 1 && page == 1) && - !(chapter == maxChapter && page == maxPage)) { - return false; - } + if (page == this.page && page != 1 && page != maxPage) { + return false; } this.page = page; update(); @@ -495,9 +501,38 @@ abstract mixin class _ReaderLocation { mixin class _ReaderWindow { bool isFullscreen = false; - void fullscreen() { - windowManager.setFullScreen(!isFullscreen); + late WindowFrameController windowFrame; + + bool _isInit = false; + + void initReaderWindow() { + if (!App.isDesktop || _isInit) return; + windowFrame = WindowFrame.of(App.rootContext); + windowFrame.addCloseListener(onWindowClose); + _isInit = true; + } + + void fullscreen() async { + if (!App.isDesktop) return; + await windowManager.hide(); + await windowManager.setFullScreen(!isFullscreen); + await windowManager.show(); isFullscreen = !isFullscreen; + WindowFrame.of(App.rootContext).setWindowFrame(!isFullscreen); + } + + bool onWindowClose() { + if (Navigator.of(App.rootContext).canPop()) { + Navigator.of(App.rootContext).pop(); + return false; + } else { + return true; + } + } + + void disposeReaderWindow() { + if (!App.isDesktop) return; + windowFrame.removeCloseListener(onWindowClose); } } diff --git a/lib/pages/settings/explore_settings.dart b/lib/pages/settings/explore_settings.dart index 957182e..6721a5e 100644 --- a/lib/pages/settings/explore_settings.dart +++ b/lib/pages/settings/explore_settings.dart @@ -80,6 +80,16 @@ class _ExploreSettingsState extends State { 'japanese': "Japanese", }, ).toSliver(), + SelectSetting( + title: "Initial Page".tl, + settingKey: "initialPage", + optionTranslation: { + '0': "Home Page".tl, + '1': "Favorites Page".tl, + '2': "Explore Page".tl, + '3': "Categories Page".tl, + }, + ).toSliver(), ], ); } diff --git a/lib/utils/cbz.dart b/lib/utils/cbz.dart index dcd68d0..4710421 100644 --- a/lib/utils/cbz.dart +++ b/lib/utils/cbz.dart @@ -177,7 +177,7 @@ abstract class CBZ { tags: metaData.tags, comicType: ComicType.local, directory: dest.name, - chapters: ComicChapters.fromJson(cpMap), + chapters: ComicChapters.fromJsonOrNull(cpMap), downloadedChapters: cpMap?.keys.toList() ?? [], cover: 'cover.${coverFile.extension}', createdAt: DateTime.now(), diff --git a/lib/utils/data.dart b/lib/utils/data.dart index 08ab2e7..56f14c9 100644 --- a/lib/utils/data.dart +++ b/lib/utils/data.dart @@ -103,7 +103,7 @@ Future importAppData(File file, [bool checkVersion = false]) async { await file.copy(targetFile); } } - await ComicSource.reload(); + await ComicSourceManager().reload(); } } finally { cacheDir.deleteIgnoreError(recursive: true); diff --git a/lib/utils/data_sync.dart b/lib/utils/data_sync.dart index e73cd9f..763685e 100644 --- a/lib/utils/data_sync.dart +++ b/lib/utils/data_sync.dart @@ -1,4 +1,3 @@ -import 'package:dio/io.dart'; import 'package:flutter/foundation.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; @@ -19,7 +18,7 @@ class DataSync with ChangeNotifier { downloadData(); } LocalFavoritesManager().addListener(onDataChanged); - ComicSource.addListener(onDataChanged); + ComicSourceManager().addListener(onDataChanged); } void onDataChanged() { @@ -40,7 +39,11 @@ class DataSync with ChangeNotifier { bool get isUploading => _isUploading; - bool haveWaitingTask = false; + bool _haveWaitingTask = false; + + String? _lastError; + + String? get lastError => _lastError; bool get isEnabled { var config = appdata.settings['webdav']; @@ -64,17 +67,19 @@ class DataSync with ChangeNotifier { Future> uploadData() async { if (isDownloading) return const Res(true); - if (haveWaitingTask) return const Res(true); + if (_haveWaitingTask) return const Res(true); while (isUploading) { - haveWaitingTask = true; + _haveWaitingTask = true; await Future.delayed(const Duration(milliseconds: 100)); } - haveWaitingTask = false; + _haveWaitingTask = false; _isUploading = true; + _lastError = null; notifyListeners(); try { var config = _validateConfig(); if (config == null) { + _lastError = 'Invalid WebDAV configuration'; return const Res.error('Invalid WebDAV configuration'); } if (config.isEmpty) { @@ -84,27 +89,13 @@ class DataSync with ChangeNotifier { String user = config[1]; String pass = config[2]; - var proxy = await AppDio.getProxy(); - var client = newClient( url, user: user, password: pass, - adapter: IOHttpClientAdapter( - createHttpClient: () { - return HttpClient() - ..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy"; - }, - ), + adapter: RHttpAdapter(), ); - try { - await client.ping(); - } catch (e) { - Log.error("Upload Data", 'Failed to connect to WebDAV server'); - return const Res.error('Failed to connect to WebDAV server'); - } - try { appdata.settings['dataVersion']++; await appdata.saveData(false); @@ -131,6 +122,7 @@ class DataSync with ChangeNotifier { return const Res(true); } catch (e, s) { Log.error("Upload Data", e, s); + _lastError = e.toString(); return Res.error(e.toString()); } } finally { @@ -140,17 +132,19 @@ class DataSync with ChangeNotifier { } Future> downloadData() async { - if (haveWaitingTask) return const Res(true); + if (_haveWaitingTask) return const Res(true); while (isDownloading || isUploading) { - haveWaitingTask = true; + _haveWaitingTask = true; await Future.delayed(const Duration(milliseconds: 100)); } - haveWaitingTask = false; + _haveWaitingTask = false; _isDownloading = true; + _lastError = null; notifyListeners(); try { var config = _validateConfig(); if (config == null) { + _lastError = 'Invalid WebDAV configuration'; return const Res.error('Invalid WebDAV configuration'); } if (config.isEmpty) { @@ -160,27 +154,13 @@ class DataSync with ChangeNotifier { String user = config[1]; String pass = config[2]; - var proxy = await AppDio.getProxy(); - var client = newClient( url, user: user, password: pass, - adapter: IOHttpClientAdapter( - createHttpClient: () { - return HttpClient() - ..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy"; - }, - ), + adapter: RHttpAdapter(), ); - try { - await client.ping(); - } catch (e) { - Log.error("Data Sync", 'Failed to connect to WebDAV server'); - return const Res.error('Failed to connect to WebDAV server'); - } - try { var files = await client.readDir('/'); files.sort((a, b) => b.name!.compareTo(a.name!)); @@ -206,6 +186,7 @@ class DataSync with ChangeNotifier { return const Res(true); } catch (e, s) { Log.error("Data Sync", e, s); + _lastError = e.toString(); return Res.error(e.toString()); } } finally { diff --git a/lib/utils/init.dart b/lib/utils/init.dart new file mode 100644 index 0000000..96d4bc7 --- /dev/null +++ b/lib/utils/init.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +/// A mixin class that provides a way to ensure the class is initialized. +abstract mixin class Init { + bool _isInit = false; + + final _initCompleter = >[]; + + /// Ensure the class is initialized. + Future ensureInit() async { + if (_isInit) { + return; + } + var completer = Completer(); + _initCompleter.add(completer); + return completer.future; + } + + Future _markInit() async { + _isInit = true; + for (var completer in _initCompleter) { + completer.complete(); + } + _initCompleter.clear(); + } + + @protected + Future doInit(); + + /// Initialize the class. + Future init() async { + if (_isInit) { + return; + } + await doInit(); + await _markInit(); + } +} \ No newline at end of file diff --git a/lib/utils/io.dart b/lib/utils/io.dart index d5ec62b..98b39dc 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -40,6 +40,7 @@ extension FileSystemEntityExt on FileSystemEntity { return p.basename(path); } + /// Delete the file or directory and ignore errors. Future deleteIgnoreError({bool recursive = false}) async { try { await delete(recursive: recursive); @@ -48,12 +49,14 @@ extension FileSystemEntityExt on FileSystemEntity { } } + /// Delete the file or directory if it exists. Future deleteIfExists({bool recursive = false}) async { if (existsSync()) { await delete(recursive: recursive); } } + /// Delete the file or directory if it exists. void deleteIfExistsSync({bool recursive = false}) { if (existsSync()) { deleteSync(recursive: recursive); @@ -74,12 +77,14 @@ extension FileExtension on File { await newFile.writeAsBytes(await readAsBytes()); } + /// Get the base name of the file without the extension. String get basenameWithoutExt { return p.basenameWithoutExtension(path); } } extension DirectoryExtension on Directory { + /// Calculate the size of the directory. Future get size async { if (!existsSync()) return 0; int total = 0; @@ -91,6 +96,7 @@ extension DirectoryExtension on Directory { return total; } + /// Change the base name of the directory. Directory renameX(String newName) { newName = sanitizeFileName(newName); return renameSync(path.replaceLast(name, newName)); @@ -100,6 +106,7 @@ extension DirectoryExtension on Directory { return File(FilePath.join(path, name)); } + /// Delete the contents of the directory. void deleteContentsSync({recursive = true}) { if (!existsSync()) return; for (var f in listSync()) { @@ -107,14 +114,24 @@ extension DirectoryExtension on Directory { } } + /// Delete the contents of the directory. Future deleteContents({recursive = true}) async { if (!existsSync()) return; for (var f in listSync()) { await f.deleteIfExists(recursive: recursive); } } + + /// Create the directory. If the directory already exists, delete it first. + void forceCreateSync() { + if (existsSync()) { + deleteSync(recursive: true); + } + createSync(recursive: true); + } } +/// Sanitize the file name. Remove invalid characters and trim the file name. String sanitizeFileName(String fileName) { if (fileName.endsWith('.')) { fileName = fileName.substring(0, fileName.length - 1); @@ -157,6 +174,8 @@ Future copyDirectory(Directory source, Directory destination) async { } } +/// Copy the **contents** of the source directory to the destination directory. +/// This function is executed in an isolate to prevent the UI from freezing. Future copyDirectoryIsolate( Directory source, Directory destination) async { await Isolate.run(() => overrideIO(() => copyDirectory(source, destination))); diff --git a/pubspec.lock b/pubspec.lock index 3a12883..84c2b4b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -757,10 +757,11 @@ packages: rhttp: dependency: "direct main" description: - name: rhttp - sha256: "3deabc6c3384b4efa252dfb4a5059acc6530117fdc1b10f5f67ff9768c9af75a" - url: "https://pub.dev" - source: hosted + path: rhttp + ref: HEAD + resolved-ref: "18d430cc45fd4f0114885c5235090abf65106257" + url: "https://github.com/wgh136/rhttp" + source: git version: "0.10.0" screen_retriever: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index f5e3026..37a2bad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: venera description: "A comic app." publish_to: 'none' -version: 1.3.1+131 +version: 1.3.2+132 environment: sdk: '>=3.6.0 <4.0.0' @@ -57,7 +57,10 @@ dependencies: git: url: https://github.com/venera-app/lodepng_flutter ref: 9a784b193af5d55b2a35e58fa390bda3e4f35d00 - rhttp: 0.10.0 + rhttp: + git: + url: https://github.com/wgh136/rhttp + path: rhttp webdav_client: git: url: https://github.com/wgh136/webdav_client