mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
@@ -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": "分類頁面"
|
||||
}
|
||||
}
|
||||
|
@@ -125,11 +125,11 @@ class OverlayWidgetState extends State<OverlayWidget> {
|
||||
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),
|
||||
)
|
||||
|
@@ -1,15 +1,13 @@
|
||||
part of 'components.dart';
|
||||
|
||||
class SideBarRoute<T> extends PopupRoute<T> {
|
||||
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<T> extends PopupRoute<T> {
|
||||
Animation<double> 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<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,
|
||||
{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,
|
||||
|
@@ -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<WindowFrame> createState() => _WindowFrameState();
|
||||
|
||||
static WindowFrameController of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<WindowFrameController>()!;
|
||||
}
|
||||
}
|
||||
|
||||
typedef WindowCloseListener = bool Function();
|
||||
|
||||
class _WindowFrameState extends State<WindowFrame> {
|
||||
bool isHideWindowFrame = false;
|
||||
bool isWindowFrameHidden = 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
|
||||
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<WindowButtons> createState() => _WindowButtonsState();
|
||||
State<_WindowButtons> createState() => _WindowButtonsState();
|
||||
}
|
||||
|
||||
class _WindowButtonsState extends State<WindowButtons> with WindowListener {
|
||||
class _WindowButtonsState extends State<_WindowButtons> with WindowListener {
|
||||
bool isMaximized = false;
|
||||
|
||||
@override
|
||||
@@ -197,9 +272,7 @@ class _WindowButtonsState extends State<WindowButtons> 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();
|
||||
}
|
||||
|
@@ -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<void> init() async {
|
||||
cachePath = (await getApplicationCacheDirectory()).path;
|
||||
dataPath = (await getApplicationSupportDirectory()).path;
|
||||
await data.init();
|
||||
await history.init();
|
||||
await favorites.init();
|
||||
await local.init();
|
||||
}
|
||||
|
||||
Future<void> initComponents() async {
|
||||
await Future.wait([
|
||||
data.init(),
|
||||
history.init(),
|
||||
favorites.init(),
|
||||
local.init(),
|
||||
]);
|
||||
}
|
||||
|
||||
Function? _forceRebuildHandler;
|
||||
|
@@ -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 = <String>[];
|
||||
|
||||
@@ -110,10 +112,10 @@ class Appdata {
|
||||
}
|
||||
}
|
||||
|
||||
final appdata = Appdata();
|
||||
final appdata = Appdata._create();
|
||||
|
||||
class Settings with ChangeNotifier {
|
||||
Settings();
|
||||
Settings._create();
|
||||
|
||||
final _data = <String, dynamic>{
|
||||
'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) {
|
||||
|
@@ -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!;
|
||||
}
|
||||
|
@@ -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<Res<List<Comic>>> 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<Res<List<Comic>>> Function(
|
||||
String? next);
|
||||
class ComicSourceManager with ChangeNotifier, Init {
|
||||
final List<ComicSource> _sources = [];
|
||||
|
||||
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(
|
||||
String id, String? ep);
|
||||
factory ComicSourceManager() => _instance ??= ComicSourceManager._create();
|
||||
|
||||
typedef CommentsLoader = Future<Res<List<Comment>>> Function(
|
||||
String id, String? subId, int page, String? replyTo);
|
||||
List<ComicSource> all() => List.from(_sources);
|
||||
|
||||
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);
|
||||
|
||||
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) =>
|
||||
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<void> init() async {
|
||||
@override
|
||||
@protected
|
||||
Future<void> 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 = <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.
|
||||
final String name;
|
||||
@@ -321,7 +293,7 @@ class AccountConfig {
|
||||
this.onLoginWithWebviewSuccess,
|
||||
this.cookieFields,
|
||||
this.validateCookies,
|
||||
) : infoItems = const [];
|
||||
) : infoItems = const [];
|
||||
}
|
||||
|
||||
class AccountInfoItem {
|
||||
|
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
48
lib/foundation/comic_source/types.dart
Normal file
48
lib/foundation/comic_source/types.dart
Normal 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);
|
@@ -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<void> init() async {
|
||||
@override
|
||||
@protected
|
||||
Future<void> doInit() async {
|
||||
if (!_closed) {
|
||||
return;
|
||||
}
|
||||
|
@@ -265,6 +265,7 @@ class LocalManager with ChangeNotifier {
|
||||
}
|
||||
_checkPathValidation();
|
||||
_checkNoMedia();
|
||||
await ComicSourceManager().ensureInit();
|
||||
restoreDownloadingTasks();
|
||||
}
|
||||
|
||||
|
@@ -30,13 +30,15 @@ extension _FutureInit<T> on Future<T> {
|
||||
|
||||
Future<void> 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']);
|
||||
|
@@ -257,18 +257,7 @@ class RHttpAdapter implements HttpClientAdapter {
|
||||
Future<void>? 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,
|
||||
|
@@ -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<void> createInstance() async {
|
||||
var dataPath = (await getApplicationSupportDirectory()).path;
|
||||
instance = SingleInstanceCookieJar("$dataPath/cookie.db");
|
||||
}
|
||||
}
|
||||
|
||||
class CookieManagerSql extends Interceptor {
|
||||
|
@@ -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<void> extractArchive(String path) async {
|
||||
var resultFile = FilePath.join(path, "archive.zip");
|
||||
await Isolate.run(() {
|
||||
ZipFile.openAndExtract(resultFile, path);
|
||||
});
|
||||
static Future<void> _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
|
||||
|
@@ -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<RichCommentContent> createState() => _RichCommentContentState();
|
||||
}
|
||||
@@ -808,7 +814,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
|
||||
children: textSpan,
|
||||
),
|
||||
);
|
||||
if (images.isNotEmpty) {
|
||||
if (images.isNotEmpty && widget.showImages) {
|
||||
content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
@@ -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)
|
||||
|
@@ -40,10 +40,11 @@ class ComicSourcePage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
if (shouldUpdate.isNotEmpty) {
|
||||
var updates = <String, String>{};
|
||||
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<void> 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),
|
||||
|
@@ -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();
|
||||
});
|
||||
}
|
||||
|
@@ -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<ImageFavorites> {
|
||||
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(
|
||||
|
@@ -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<MainPage> {
|
||||
_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<MainPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NaviPane(
|
||||
initialPage: index,
|
||||
observer: _observer,
|
||||
navigatorKey: _navigatorKey!,
|
||||
paneItems: [
|
||||
|
@@ -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<Reader>
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
initImagesPerPage(widget.initialPage ?? 1);
|
||||
initReaderWindow();
|
||||
}
|
||||
|
||||
void setImageCacheSize() async {
|
||||
@@ -191,6 +193,9 @@ class _ReaderState extends State<Reader>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (isFullscreen) {
|
||||
fullscreen();
|
||||
}
|
||||
autoPageTurningTimer?.cancel();
|
||||
focusNode.dispose();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
@@ -199,6 +204,7 @@ class _ReaderState extends State<Reader>
|
||||
DataSync().onDataChanged();
|
||||
});
|
||||
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20;
|
||||
disposeReaderWindow();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -218,6 +224,9 @@ class _ReaderState extends State<Reader>
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -80,6 +80,16 @@ class _ExploreSettingsState extends State<ExploreSettings> {
|
||||
'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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@@ -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(),
|
||||
|
@@ -103,7 +103,7 @@ Future<void> importAppData(File file, [bool checkVersion = false]) async {
|
||||
await file.copy(targetFile);
|
||||
}
|
||||
}
|
||||
await ComicSource.reload();
|
||||
await ComicSourceManager().reload();
|
||||
}
|
||||
} finally {
|
||||
cacheDir.deleteIgnoreError(recursive: true);
|
||||
|
@@ -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<Res<bool>> 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<Res<bool>> 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 {
|
||||
|
40
lib/utils/init.dart
Normal file
40
lib/utils/init.dart
Normal 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();
|
||||
}
|
||||
}
|
@@ -40,6 +40,7 @@ extension FileSystemEntityExt on FileSystemEntity {
|
||||
return p.basename(path);
|
||||
}
|
||||
|
||||
/// Delete the file or directory and ignore errors.
|
||||
Future<void> 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<void> 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<int> 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<void> 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<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(
|
||||
Directory source, Directory destination) async {
|
||||
await Isolate.run(() => overrideIO(() => copyDirectory(source, destination)));
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user