Merge pull request #245 from venera-app/v1.3.2-dev

v1.3.2
This commit is contained in:
nyne
2025-03-06 13:16:57 +08:00
committed by GitHub
31 changed files with 554 additions and 366 deletions

View File

@@ -362,7 +362,12 @@
"Mark all as read": "全部标记为已读", "Mark all as read": "全部标记为已读",
"Do you want to mark all as read?" : "您要全部标记为已读吗?", "Do you want to mark all as read?" : "您要全部标记为已读吗?",
"Swipe down for previous chapter": "向下滑动查看上一章", "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": { "zh_TW": {
"Home": "首頁", "Home": "首頁",
@@ -727,6 +732,11 @@
"Mark all as read": "全部標記為已讀", "Mark all as read": "全部標記為已讀",
"Do you want to mark all as read?" : "您要全部標記為已讀嗎?", "Do you want to mark all as read?" : "您要全部標記為已讀嗎?",
"Swipe down for previous chapter": "向下滑動查看上一章", "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": "分類頁面"
} }
} }

View File

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

View File

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

View File

@@ -10,6 +10,34 @@ import 'package:window_manager/window_manager.dart';
const _kTitleBarHeight = 36.0; 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 { class WindowFrame extends StatefulWidget {
const WindowFrame(this.child, {super.key}); const WindowFrame(this.child, {super.key});
@@ -17,98 +45,145 @@ class WindowFrame extends StatefulWidget {
@override @override
State<WindowFrame> createState() => _WindowFrameState(); State<WindowFrame> createState() => _WindowFrameState();
static WindowFrameController of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
}
} }
typedef WindowCloseListener = bool Function();
class _WindowFrameState extends State<WindowFrame> { class _WindowFrameState extends State<WindowFrame> {
bool isHideWindowFrame = false; bool isWindowFrameHidden = false;
bool useDarkTheme = false; bool useDarkTheme = false;
var closeListeners = <WindowCloseListener>[];
/// Sets the visibility of the window frame.
void setWindowFrame(bool show) {
setState(() {
isWindowFrameHidden = !show;
});
}
/// Adds a listener that will be called when close button is clicked.
/// The listener should return `true` to allow the window to be closed.
void addCloseListener(WindowCloseListener listener) {
closeListeners.add(listener);
}
/// Removes a close listener.
void removeCloseListener(WindowCloseListener listener) {
closeListeners.remove(listener);
}
void _onClose() {
for (var listener in closeListeners) {
if (!listener()) {
return;
}
}
windowManager.close();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (App.isMobile) return widget.child; if (App.isMobile) return widget.child;
if (isHideWindowFrame) return widget.child;
var body = Stack( Widget body = Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: MediaQuery( child: MediaQuery(
data: MediaQuery.of(context).copyWith( data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)), padding: isWindowFrameHidden
? null
: const EdgeInsets.only(top: _kTitleBarHeight),
),
child: widget.child, child: widget.child,
), ),
), ),
Positioned( if (!isWindowFrameHidden)
top: 0, Positioned(
left: 0, top: 0,
right: 0, left: 0,
child: Material( right: 0,
color: Colors.transparent, child: Material(
child: Theme( color: Colors.transparent,
data: Theme.of(context).copyWith( child: Theme(
brightness: useDarkTheme ? Brightness.dark : null, data: Theme.of(context).copyWith(
), brightness: useDarkTheme ? Brightness.dark : null,
child: Builder(builder: (context) { ),
return SizedBox( child: Builder(builder: (context) {
height: _kTitleBarHeight, return SizedBox(
child: Row( height: _kTitleBarHeight,
children: [ child: Row(
if (App.isMacOS) children: [
const DragToMoveArea( if (App.isMacOS)
child: SizedBox( const DragToMoveArea(
height: double.infinity, child: SizedBox(
width: 16, 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,
), ),
) ).paddingRight(52)
.toAlign(Alignment.centerLeft) else
.paddingLeft(4 + (App.isMacOS ? 25 : 0)), 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)
if (kDebugMode) const TextButton(
const TextButton( onPressed: debug,
onPressed: debug, child: Text('Debug'),
child: Text('Debug'), ),
), if (!App.isMacOS) _WindowButtons(
if (!App.isMacOS) const WindowButtons() onClose: _onClose,
], )
), ],
); ),
}), );
}),
),
), ),
), )
)
], ],
); );
if (App.isLinux) { if (App.isLinux) {
return VirtualWindowFrame(child: body); body = VirtualWindowFrame(child: body);
} else {
return body;
} }
return WindowFrameController._create(
isWindowFrameHidden: isWindowFrameHidden,
setWindowFrame: setWindowFrame,
addCloseListener: addCloseListener,
removeCloseListener: removeCloseListener,
child: body,
);
} }
} }
class WindowButtons extends StatefulWidget { class _WindowButtons extends StatefulWidget {
const WindowButtons({super.key}); const _WindowButtons({required this.onClose});
final void Function() onClose;
@override @override
State<WindowButtons> createState() => _WindowButtonsState(); State<_WindowButtons> createState() => _WindowButtonsState();
} }
class _WindowButtonsState extends State<WindowButtons> with WindowListener { class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
bool isMaximized = false; bool isMaximized = false;
@override @override
@@ -197,9 +272,7 @@ class _WindowButtonsState extends State<WindowButtons> with WindowListener {
color: !dark ? Colors.white : Colors.black, color: !dark ? Colors.white : Colors.black,
), ),
hoverColor: Colors.red, hoverColor: Colors.red,
onPressed: () { onPressed: widget.onClose,
windowManager.close();
},
) )
], ],
), ),
@@ -567,5 +640,5 @@ TransitionBuilder VirtualWindowFrameInit() {
} }
void debug() { void debug() {
ComicSource.reload(); ComicSourceManager().reload();
} }

View File

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

View File

@@ -7,7 +7,9 @@ import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
class Appdata { class Appdata {
final Settings settings = Settings(); Appdata._create();
final Settings settings = Settings._create();
var searchHistory = <String>[]; var searchHistory = <String>[];
@@ -110,10 +112,10 @@ class Appdata {
} }
} }
final appdata = Appdata(); final appdata = Appdata._create();
class Settings with ChangeNotifier { class Settings with ChangeNotifier {
Settings(); Settings._create();
final _data = <String, dynamic>{ final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief '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", 'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'preloadImageCount': 4, 'preloadImageCount': 4,
'followUpdatesFolder': null, 'followUpdatesFolder': null,
'initialPage': '0',
}; };
operator [](String key) { operator [](String key) {

View File

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

View File

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

View File

@@ -336,8 +336,10 @@ class ComicChapters {
} }
if (chapters.isNotEmpty) { if (chapters.isNotEmpty) {
return ComicChapters(chapters); return ComicChapters(chapters);
} else { } else if (groupedChapters.isNotEmpty) {
return ComicChapters.grouped(groupedChapters); return ComicChapters.grouped(groupedChapters);
} else {
throw ArgumentError("Empty chapter list");
} }
} }

View File

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

View File

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

View File

@@ -265,6 +265,7 @@ class LocalManager with ChangeNotifier {
} }
_checkPathValidation(); _checkPathValidation();
_checkNoMedia(); _checkNoMedia();
await ComicSourceManager().ensureInit();
restoreDownloadingTasks(); restoreDownloadingTasks();
} }

View File

@@ -30,13 +30,15 @@ extension _FutureInit<T> on Future<T> {
Future<void> init() async { Future<void> init() async {
await App.init().wait(); await App.init().wait();
SingleInstanceCookieJar("${App.dataPath}/cookie.db"); await SingleInstanceCookieJar.createInstance();
var futures = [ var futures = [
Rhttp.init(), Rhttp.init(),
App.initComponents(),
SAFTaskWorker().init().wait(), SAFTaskWorker().init().wait(),
AppTranslation.init().wait(), AppTranslation.init().wait(),
TagsTranslation.readData().wait(), TagsTranslation.readData().wait(),
JsEngine().init().then((_) => ComicSource.init()).wait(), JsEngine().init().wait(),
ComicSourceManager().init().wait(),
]; ];
await Future.wait(futures); await Future.wait(futures);
CacheManager().setLimitSize(appdata.settings['cacheSize']); CacheManager().setLimitSize(appdata.settings['cacheSize']);

View File

@@ -257,18 +257,7 @@ class RHttpAdapter implements HttpClientAdapter {
Future<void>? cancelFuture, Future<void>? cancelFuture,
) async { ) async {
var res = await rhttp.Rhttp.request( var res = await rhttp.Rhttp.request(
method: switch (options.method) { method: rhttp.HttpMethod(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}'),
},
url: options.uri.toString(), url: options.uri.toString(),
settings: settings, settings: settings,
expectBody: rhttp.HttpExpectBody.stream, expectBody: rhttp.HttpExpectBody.stream,

View File

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

View File

@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:isolate'; import 'dart:isolate';
import 'package:flutter/widgets.dart' show ChangeNotifier; 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/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/comic_type.dart';
@@ -739,11 +741,12 @@ class ArchiveDownloadTask extends DownloadTask {
path = dir.path; 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"); Log.info("Download", "Downloading $archiveUrl");
_downloader = FileDownloader(archiveUrl, resultFile.path); _downloader = FileDownloader(archiveUrl, archiveFile.path);
bool isDownloaded = false; bool isDownloaded = false;
@@ -772,22 +775,33 @@ class ArchiveDownloadTask extends DownloadTask {
} }
try { try {
await extractArchive(path!); await _extractArchive(archiveFile.path, path!);
} catch (e) { } catch (e) {
_setError("Failed to extract archive: $e"); _setError("Failed to extract archive: $e");
return; return;
} }
await resultFile.deleteIgnoreError(); await archiveFile.deleteIgnoreError();
LocalManager().completeTask(this); LocalManager().completeTask(this);
} }
static Future<void> extractArchive(String path) async { static Future<void> _extractArchive(String archive, String outDir) async {
var resultFile = FilePath.join(path, "archive.zip"); var out = Directory(outDir);
await Isolate.run(() { if (out is AndroidDirectory) {
ZipFile.openAndExtract(resultFile, path); // 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 @override

View File

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

View File

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

View File

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

View File

@@ -703,8 +703,9 @@ abstract class FollowUpdatesService {
if (_isInitialized) return; if (_isInitialized) return;
_isInitialized = true; _isInitialized = true;
_check(); _check();
DataSync().addListener(updateFollowUpdatesUI);
// A short interval will not affect the performance since every comic has a check time. // 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(); _check();
}); });
} }

View File

@@ -162,16 +162,50 @@ class _SyncDataWidgetState extends State<_SyncDataWidget>
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( IconButton(
icon: const Icon(Icons.cloud_upload_outlined), icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async { onPressed: () async {
DataSync().uploadData(); DataSync().uploadData();
}), },
),
IconButton( IconButton(
icon: const Icon(Icons.cloud_download_outlined), icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async { onPressed: () async {
DataSync().downloadData(); DataSync().downloadData();
}), },
),
], ],
), ),
), ),
@@ -538,7 +572,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
], ],
), ),
onPressed: () { 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), ).fixWidth(90).paddingRight(8),
Button.filled( Button.filled(
@@ -595,19 +630,19 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
@override @override
void initState() { void initState() {
comicSources = ComicSource.all().map((e) => e.name).toList(); comicSources = ComicSource.all().map((e) => e.name).toList();
ComicSource.addListener(onComicSourceChange); ComicSourceManager().addListener(onComicSourceChange);
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
ComicSource.removeListener(onComicSourceChange); ComicSourceManager().removeListener(onComicSourceChange);
super.dispose(); super.dispose();
} }
int get _availableUpdates { int get _availableUpdates {
int c = 0; int c = 0;
ComicSource.availableUpdates.forEach((key, version) { ComicSourceManager().availableUpdates.forEach((key, version) {
var source = ComicSource.find(key); var source = ComicSource.find(key);
if (source != null) { if (source != null) {
if (compareSemVer(version, source.version)) { if (compareSemVer(version, source.version)) {
@@ -697,14 +732,24 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.update, color: context.colorScheme.primary, size: 20,), Icon(
Icons.update,
color: context.colorScheme.primary,
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text("@c updates".tlParams({ Text(
'c': _availableUpdates, "@c updates".tlParams({
}), style: ts.withColor(context.colorScheme.primary),), '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<ImageFavorites> {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 8, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color:
Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(

View File

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

View File

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

View File

@@ -80,6 +80,16 @@ class _ExploreSettingsState extends State<ExploreSettings> {
'japanese': "Japanese", 'japanese': "Japanese",
}, },
).toSliver(), ).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(),
], ],
); );
} }

View File

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

View File

@@ -103,7 +103,7 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
await file.copy(targetFile); await file.copy(targetFile);
} }
} }
await ComicSource.reload(); await ComicSourceManager().reload();
} }
} finally { } finally {
cacheDir.deleteIgnoreError(recursive: true); cacheDir.deleteIgnoreError(recursive: true);

View File

@@ -1,4 +1,3 @@
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
@@ -19,7 +18,7 @@ class DataSync with ChangeNotifier {
downloadData(); downloadData();
} }
LocalFavoritesManager().addListener(onDataChanged); LocalFavoritesManager().addListener(onDataChanged);
ComicSource.addListener(onDataChanged); ComicSourceManager().addListener(onDataChanged);
} }
void onDataChanged() { void onDataChanged() {
@@ -40,7 +39,11 @@ class DataSync with ChangeNotifier {
bool get isUploading => _isUploading; bool get isUploading => _isUploading;
bool haveWaitingTask = false; bool _haveWaitingTask = false;
String? _lastError;
String? get lastError => _lastError;
bool get isEnabled { bool get isEnabled {
var config = appdata.settings['webdav']; var config = appdata.settings['webdav'];
@@ -64,17 +67,19 @@ class DataSync with ChangeNotifier {
Future<Res<bool>> uploadData() async { Future<Res<bool>> uploadData() async {
if (isDownloading) return const Res(true); if (isDownloading) return const Res(true);
if (haveWaitingTask) return const Res(true); if (_haveWaitingTask) return const Res(true);
while (isUploading) { while (isUploading) {
haveWaitingTask = true; _haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
} }
haveWaitingTask = false; _haveWaitingTask = false;
_isUploading = true; _isUploading = true;
_lastError = null;
notifyListeners(); notifyListeners();
try { try {
var config = _validateConfig(); var config = _validateConfig();
if (config == null) { if (config == null) {
_lastError = 'Invalid WebDAV configuration';
return const Res.error('Invalid WebDAV configuration'); return const Res.error('Invalid WebDAV configuration');
} }
if (config.isEmpty) { if (config.isEmpty) {
@@ -84,27 +89,13 @@ class DataSync with ChangeNotifier {
String user = config[1]; String user = config[1];
String pass = config[2]; String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient( var client = newClient(
url, url,
user: user, user: user,
password: pass, password: pass,
adapter: IOHttpClientAdapter( adapter: RHttpAdapter(),
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
),
); );
try {
await client.ping();
} catch (e) {
Log.error("Upload Data", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try { try {
appdata.settings['dataVersion']++; appdata.settings['dataVersion']++;
await appdata.saveData(false); await appdata.saveData(false);
@@ -131,6 +122,7 @@ class DataSync with ChangeNotifier {
return const Res(true); return const Res(true);
} catch (e, s) { } catch (e, s) {
Log.error("Upload Data", e, s); Log.error("Upload Data", e, s);
_lastError = e.toString();
return Res.error(e.toString()); return Res.error(e.toString());
} }
} finally { } finally {
@@ -140,17 +132,19 @@ class DataSync with ChangeNotifier {
} }
Future<Res<bool>> downloadData() async { Future<Res<bool>> downloadData() async {
if (haveWaitingTask) return const Res(true); if (_haveWaitingTask) return const Res(true);
while (isDownloading || isUploading) { while (isDownloading || isUploading) {
haveWaitingTask = true; _haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
} }
haveWaitingTask = false; _haveWaitingTask = false;
_isDownloading = true; _isDownloading = true;
_lastError = null;
notifyListeners(); notifyListeners();
try { try {
var config = _validateConfig(); var config = _validateConfig();
if (config == null) { if (config == null) {
_lastError = 'Invalid WebDAV configuration';
return const Res.error('Invalid WebDAV configuration'); return const Res.error('Invalid WebDAV configuration');
} }
if (config.isEmpty) { if (config.isEmpty) {
@@ -160,27 +154,13 @@ class DataSync with ChangeNotifier {
String user = config[1]; String user = config[1];
String pass = config[2]; String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient( var client = newClient(
url, url,
user: user, user: user,
password: pass, password: pass,
adapter: IOHttpClientAdapter( adapter: RHttpAdapter(),
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
),
); );
try {
await client.ping();
} catch (e) {
Log.error("Data Sync", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try { try {
var files = await client.readDir('/'); var files = await client.readDir('/');
files.sort((a, b) => b.name!.compareTo(a.name!)); files.sort((a, b) => b.name!.compareTo(a.name!));
@@ -206,6 +186,7 @@ class DataSync with ChangeNotifier {
return const Res(true); return const Res(true);
} catch (e, s) { } catch (e, s) {
Log.error("Data Sync", e, s); Log.error("Data Sync", e, s);
_lastError = e.toString();
return Res.error(e.toString()); return Res.error(e.toString());
} }
} finally { } finally {

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

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

View File

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

View File

@@ -757,10 +757,11 @@ packages:
rhttp: rhttp:
dependency: "direct main" dependency: "direct main"
description: description:
name: rhttp path: rhttp
sha256: "3deabc6c3384b4efa252dfb4a5059acc6530117fdc1b10f5f67ff9768c9af75a" ref: HEAD
url: "https://pub.dev" resolved-ref: "18d430cc45fd4f0114885c5235090abf65106257"
source: hosted url: "https://github.com/wgh136/rhttp"
source: git
version: "0.10.0" version: "0.10.0"
screen_retriever: screen_retriever:
dependency: transitive dependency: transitive

View File

@@ -2,7 +2,7 @@ name: venera
description: "A comic app." description: "A comic app."
publish_to: 'none' publish_to: 'none'
version: 1.3.1+131 version: 1.3.2+132
environment: environment:
sdk: '>=3.6.0 <4.0.0' sdk: '>=3.6.0 <4.0.0'
@@ -57,7 +57,10 @@ dependencies:
git: git:
url: https://github.com/venera-app/lodepng_flutter url: https://github.com/venera-app/lodepng_flutter
ref: 9a784b193af5d55b2a35e58fa390bda3e4f35d00 ref: 9a784b193af5d55b2a35e58fa390bda3e4f35d00
rhttp: 0.10.0 rhttp:
git:
url: https://github.com/wgh136/rhttp
path: rhttp
webdav_client: webdav_client:
git: git:
url: https://github.com/wgh136/webdav_client url: https://github.com/wgh136/webdav_client