mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
initial commit
This commit is contained in:
73
lib/foundation/app.dart
Normal file
73
lib/foundation/app.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'appdata.dart';
|
||||
|
||||
|
||||
export "widget_utils.dart";
|
||||
export "context.dart";
|
||||
|
||||
class _App {
|
||||
final version = "1.0.0";
|
||||
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
bool get isIOS => Platform.isIOS;
|
||||
bool get isWindows => Platform.isWindows;
|
||||
bool get isLinux => Platform.isLinux;
|
||||
bool get isMacOS => Platform.isMacOS;
|
||||
bool get isDesktop =>
|
||||
Platform.isWindows || Platform.isLinux || Platform.isMacOS;
|
||||
bool get isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
Locale get locale {
|
||||
Locale deviceLocale = PlatformDispatcher.instance.locale;
|
||||
if (deviceLocale.languageCode == "zh" &&
|
||||
deviceLocale.scriptCode == "Hant") {
|
||||
deviceLocale = const Locale("zh", "TW");
|
||||
}
|
||||
return deviceLocale;
|
||||
}
|
||||
|
||||
late String dataPath;
|
||||
late String cachePath;
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
GlobalKey<NavigatorState>? mainNavigatorKey;
|
||||
|
||||
BuildContext get rootContext => rootNavigatorKey.currentContext!;
|
||||
|
||||
void rootPop() {
|
||||
rootNavigatorKey.currentState?.pop();
|
||||
}
|
||||
|
||||
void pop() {
|
||||
if(rootNavigatorKey.currentState?.canPop() ?? false) {
|
||||
rootNavigatorKey.currentState?.pop();
|
||||
} else {
|
||||
mainNavigatorKey?.currentState?.pop();
|
||||
}
|
||||
}
|
||||
|
||||
var mainColor = Colors.blue;
|
||||
|
||||
Future<void> init() async {
|
||||
cachePath = (await getApplicationCacheDirectory()).path;
|
||||
dataPath = (await getApplicationSupportDirectory()).path;
|
||||
mainColor = switch(appdata.settings['color']) {
|
||||
'red' => Colors.red,
|
||||
'pink' => Colors.pink,
|
||||
'purple' => Colors.purple,
|
||||
'green' => Colors.green,
|
||||
'orange' => Colors.orange,
|
||||
'blue' => Colors.blue,
|
||||
_ => Colors.blue,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
final App = _App();
|
356
lib/foundation/app_page_route.dart
Normal file
356
lib/foundation/app_page_route.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
|
||||
const int _kMaxPageBackAnimationTime = 300;
|
||||
const double _kMinFlingVelocity = 1.0;
|
||||
|
||||
class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin{
|
||||
/// Construct a MaterialPageRoute whose contents are defined by [builder].
|
||||
AppPageRoute({
|
||||
required this.builder,
|
||||
super.settings,
|
||||
this.maintainState = true,
|
||||
super.fullscreenDialog,
|
||||
super.allowSnapshotting = true,
|
||||
super.barrierDismissible = false,
|
||||
this.enableIOSGesture = true,
|
||||
this.preventRebuild = true,
|
||||
this.isRootRoute = false,
|
||||
}) {
|
||||
assert(opaque);
|
||||
}
|
||||
|
||||
/// Builds the primary contents of the route.
|
||||
final WidgetBuilder builder;
|
||||
|
||||
String? label;
|
||||
|
||||
@override
|
||||
toString() => "/$label";
|
||||
|
||||
@override
|
||||
Widget buildContent(BuildContext context) {
|
||||
var widget = builder(context);
|
||||
label = widget.runtimeType.toString();
|
||||
return widget;
|
||||
}
|
||||
|
||||
@override
|
||||
final bool maintainState;
|
||||
|
||||
@override
|
||||
String get debugLabel => '${super.debugLabel}(${settings.name})';
|
||||
|
||||
@override
|
||||
final bool enableIOSGesture;
|
||||
|
||||
@override
|
||||
final bool preventRebuild;
|
||||
|
||||
@override
|
||||
final bool isRootRoute;
|
||||
}
|
||||
|
||||
mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
/// Builds the primary contents of the route.
|
||||
@protected
|
||||
Widget buildContent(BuildContext context);
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => null;
|
||||
|
||||
@override
|
||||
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
|
||||
// Don't perform outgoing animation if the next route is a fullscreen dialog.
|
||||
return nextRoute is PageRoute && !nextRoute.fullscreenDialog;
|
||||
}
|
||||
|
||||
bool get enableIOSGesture;
|
||||
|
||||
bool get preventRebuild;
|
||||
|
||||
bool get isRootRoute;
|
||||
|
||||
Widget? _child;
|
||||
|
||||
@override
|
||||
Widget buildPage(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
Widget result;
|
||||
|
||||
if(preventRebuild){
|
||||
result = _child ?? (_child = buildContent(context));
|
||||
} else {
|
||||
result = buildContent(context);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
|
||||
if (route.isFirst ||
|
||||
route.willHandlePopInternally ||
|
||||
route.popDisposition == RoutePopDisposition.doNotPop ||
|
||||
route.fullscreenDialog ||
|
||||
route.animation!.status != AnimationStatus.completed ||
|
||||
route.secondaryAnimation!.status != AnimationStatus.dismissed ||
|
||||
route.navigator!.userGestureInProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
||||
if(isRootRoute) {
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0, end: 1.0).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.ease
|
||||
)),
|
||||
child: FadeTransition(
|
||||
opacity: Tween<double>(begin: 1.0, end: 0).animate(CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: Curves.ease
|
||||
)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SlidePageTransitionBuilder().buildTransitions(
|
||||
this,
|
||||
context,
|
||||
animation,
|
||||
secondaryAnimation,
|
||||
enableIOSGesture
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
onStartPopGesture: () => _startPopGesture(this),
|
||||
child: child)
|
||||
: child);
|
||||
}
|
||||
|
||||
IOSBackGestureController _startPopGesture(PageRoute<T> route) {
|
||||
return IOSBackGestureController(route.controller!, route.navigator!);
|
||||
}
|
||||
}
|
||||
|
||||
class IOSBackGestureController {
|
||||
final AnimationController controller;
|
||||
|
||||
final NavigatorState navigator;
|
||||
|
||||
IOSBackGestureController(this.controller, this.navigator) {
|
||||
navigator.didStartUserGesture();
|
||||
}
|
||||
|
||||
void dragEnd(double velocity) {
|
||||
const Curve animationCurve = Curves.fastLinearToSlowEaseIn;
|
||||
final bool animateForward;
|
||||
|
||||
if (velocity.abs() >= _kMinFlingVelocity) {
|
||||
animateForward = velocity <= 0;
|
||||
} else {
|
||||
animateForward = controller.value > 0.5;
|
||||
}
|
||||
|
||||
if (animateForward) {
|
||||
final droppedPageForwardAnimationTime = min(
|
||||
lerpDouble(
|
||||
_kMaxDroppedSwipePageForwardAnimationTime, 0, controller.value)!
|
||||
.floor(),
|
||||
_kMaxPageBackAnimationTime,
|
||||
);
|
||||
controller.animateTo(1.0,
|
||||
duration: Duration(milliseconds: droppedPageForwardAnimationTime),
|
||||
curve: animationCurve);
|
||||
} else {
|
||||
navigator.pop();
|
||||
if (controller.isAnimating) {
|
||||
final droppedPageBackAnimationTime = lerpDouble(
|
||||
0, _kMaxDroppedSwipePageForwardAnimationTime, controller.value)!
|
||||
.floor();
|
||||
controller.animateBack(0.0,
|
||||
duration: Duration(milliseconds: droppedPageBackAnimationTime),
|
||||
curve: animationCurve);
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.isAnimating) {
|
||||
late AnimationStatusListener animationStatusCallback;
|
||||
animationStatusCallback = (status) {
|
||||
navigator.didStopUserGesture();
|
||||
controller.removeStatusListener(animationStatusCallback);
|
||||
};
|
||||
controller.addStatusListener(animationStatusCallback);
|
||||
} else {
|
||||
navigator.didStopUserGesture();
|
||||
}
|
||||
}
|
||||
|
||||
void dragUpdate(double delta) {
|
||||
controller.value -= delta;
|
||||
}
|
||||
}
|
||||
|
||||
class IOSBackGestureDetector extends StatefulWidget {
|
||||
const IOSBackGestureDetector(
|
||||
{required this.enabledCallback,
|
||||
required this.child,
|
||||
required this.gestureWidth,
|
||||
required this.onStartPopGesture,
|
||||
super.key});
|
||||
|
||||
final double gestureWidth;
|
||||
|
||||
final bool Function() enabledCallback;
|
||||
|
||||
final IOSBackGestureController Function() onStartPopGesture;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<IOSBackGestureDetector> createState() => _IOSBackGestureDetectorState();
|
||||
}
|
||||
|
||||
class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
IOSBackGestureController? _backGestureController;
|
||||
|
||||
late HorizontalDragGestureRecognizer _recognizer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
|
||||
..onStart = _handleDragStart
|
||||
..onUpdate = _handleDragUpdate
|
||||
..onEnd = _handleDragEnd
|
||||
..onCancel = _handleDragCancel;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr
|
||||
? MediaQuery.of(context).padding.left
|
||||
: MediaQuery.of(context).padding.right;
|
||||
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth);
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
widget.child,
|
||||
Positioned(
|
||||
width: dragAreaWidth,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (widget.enabledCallback()) _recognizer.addPointer(event);
|
||||
}
|
||||
|
||||
void _handleDragCancel() {
|
||||
assert(mounted);
|
||||
_backGestureController?.dragEnd(0.0);
|
||||
_backGestureController = null;
|
||||
}
|
||||
|
||||
double _convertToLogical(double value) {
|
||||
switch (Directionality.of(context)) {
|
||||
case TextDirection.rtl:
|
||||
return -value;
|
||||
case TextDirection.ltr:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragEnd(_convertToLogical(
|
||||
details.velocity.pixelsPerSecond.dx / context.size!.width));
|
||||
_backGestureController = null;
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController == null);
|
||||
_backGestureController = widget.onStartPopGesture();
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
}
|
||||
|
||||
class SlidePageTransitionBuilder extends PageTransitionsBuilder {
|
||||
@override
|
||||
Widget buildTransitions<T>(
|
||||
PageRoute<T> route,
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.ease,
|
||||
)),
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: const Offset(-0.4, 0),
|
||||
).animate(CurvedAnimation(
|
||||
parent: secondaryAnimation,
|
||||
curve: Curves.ease,
|
||||
)),
|
||||
child: PhysicalModel(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.zero,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
elevation: 6,
|
||||
child: Material(child: child,),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
23
lib/foundation/appdata.dart
Normal file
23
lib/foundation/appdata.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
class _Appdata {
|
||||
final _Settings settings = _Settings();
|
||||
}
|
||||
|
||||
final appdata = _Appdata();
|
||||
|
||||
class _Settings {
|
||||
_Settings();
|
||||
|
||||
final _data = <String, dynamic>{
|
||||
'comicDisplayMode': 'detailed', // detailed, brief
|
||||
'comicTileScale': 1.0, // 0.8-1.2
|
||||
'color': 'blue', // red, pink, purple, green, orange, blue
|
||||
'theme_mode': 'system', // light, dark, system
|
||||
'newFavoriteAddTo': 'end', // start, end
|
||||
'moveFavoriteAfterRead': 'none', // none, end, start
|
||||
'proxy': 'direct', // direct, system, proxy string
|
||||
};
|
||||
|
||||
operator[](String key) {
|
||||
return _data[key];
|
||||
}
|
||||
}
|
294
lib/foundation/cache_manager.dart
Normal file
294
lib/foundation/cache_manager.dart
Normal file
@@ -0,0 +1,294 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
class CacheManager {
|
||||
static String get cachePath => '${App.cachePath}/cache';
|
||||
|
||||
static CacheManager? instance;
|
||||
|
||||
late Database _db;
|
||||
|
||||
int? _currentSize;
|
||||
|
||||
/// size in bytes
|
||||
int get currentSize => _currentSize ?? 0;
|
||||
|
||||
int dir = 0;
|
||||
|
||||
int _limitSize = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
CacheManager._create(){
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db = sqlite3.open('${App.dataPath}/cache.db');
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
dir TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
type TEXT
|
||||
)
|
||||
''');
|
||||
compute((path) => Directory(path).size, cachePath)
|
||||
.then((value) => _currentSize = value);
|
||||
}
|
||||
|
||||
factory CacheManager() => instance ??= CacheManager._create();
|
||||
|
||||
/// set cache size limit in MB
|
||||
void setLimitSize(int size){
|
||||
_limitSize = size * 1024 * 1024;
|
||||
}
|
||||
|
||||
void setType(String key, String? type){
|
||||
_db.execute('''
|
||||
UPDATE cache
|
||||
SET type = ?
|
||||
WHERE key = ?
|
||||
''', [type, key]);
|
||||
}
|
||||
|
||||
String? getType(String key){
|
||||
var res = _db.select('''
|
||||
SELECT type FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return null;
|
||||
}
|
||||
return res.first[0];
|
||||
}
|
||||
|
||||
Future<void> writeCache(String key, List<int> data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsBytes(data);
|
||||
var expires = DateTime.now().millisecondsSinceEpoch + duration;
|
||||
_db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir.toString(), name, expires]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! + data.length;
|
||||
}
|
||||
if(_currentSize != null && _currentSize! > _limitSize){
|
||||
await checkCache();
|
||||
}
|
||||
}
|
||||
|
||||
Future<CachingFile> openWrite(String key) async{
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
return CachingFile._(key, dir.toString(), name, file);
|
||||
}
|
||||
|
||||
Future<File?> findCache(String key) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return null;
|
||||
}
|
||||
var row = res.first;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isChecking = false;
|
||||
|
||||
Future<void> checkCache() async{
|
||||
if(_isChecking){
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
for(var row in res){
|
||||
var dir = row[1] as int;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
|
||||
int count = 0;
|
||||
var res2 = _db.select('''
|
||||
SELECT COUNT(*) FROM cache
|
||||
''');
|
||||
if(res2.isNotEmpty){
|
||||
count = res2.first[0] as int;
|
||||
}
|
||||
|
||||
while((_currentSize != null && _currentSize! > _limitSize) || count > 2000){
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
ORDER BY time ASC
|
||||
limit 10
|
||||
''');
|
||||
for(var row in res){
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as int;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
var size = await file.length();
|
||||
await file.delete();
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
_currentSize = _currentSize! - size;
|
||||
if(_currentSize! <= _limitSize){
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
}
|
||||
count--;
|
||||
}
|
||||
}
|
||||
_isChecking = false;
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return;
|
||||
}
|
||||
var row = res.first;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
fileSize = await file.length();
|
||||
await file.delete();
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await Directory(cachePath).delete(recursive: true);
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
''');
|
||||
_currentSize = 0;
|
||||
}
|
||||
|
||||
Future<void> deleteKeyword(String keyword) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key LIKE ?
|
||||
''', ['%$keyword%']);
|
||||
for(var row in res){
|
||||
var key = row[0] as String;
|
||||
var dir = row[1] as String;
|
||||
var name = row[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
fileSize = await file.length();
|
||||
try {
|
||||
await file.delete();
|
||||
}
|
||||
finally {}
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CachingFile{
|
||||
CachingFile._(this.key, this.dir, this.name, this.file);
|
||||
|
||||
final String key;
|
||||
|
||||
final String dir;
|
||||
|
||||
final String name;
|
||||
|
||||
final File file;
|
||||
|
||||
final List<int> _buffer = [];
|
||||
|
||||
Future<void> writeBytes(List<int> data) async{
|
||||
_buffer.addAll(data);
|
||||
if(_buffer.length > 1024 * 1024){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
_buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async{
|
||||
if(_buffer.isNotEmpty){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
}
|
||||
CacheManager()._db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
|
||||
}
|
||||
|
||||
Future<void> cancel() async{
|
||||
await file.deleteIgnoreError();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_buffer.clear();
|
||||
if(file.existsSync()) {
|
||||
file.deleteSync();
|
||||
}
|
||||
}
|
||||
}
|
153
lib/foundation/comic_source/category.dart
Normal file
153
lib/foundation/comic_source/category.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
part of comic_source;
|
||||
|
||||
class CategoryData {
|
||||
/// The title is displayed in the tab bar.
|
||||
final String title;
|
||||
|
||||
/// 当使用中文语言时, 英文的分类标签将在构建页面时被翻译为中文
|
||||
final List<BaseCategoryPart> categories;
|
||||
|
||||
final bool enableRankingPage;
|
||||
|
||||
final String key;
|
||||
|
||||
final List<CategoryButtonData> buttons;
|
||||
|
||||
/// Data class for building category page.
|
||||
const CategoryData({
|
||||
required this.title,
|
||||
required this.categories,
|
||||
required this.enableRankingPage,
|
||||
required this.key,
|
||||
this.buttons = const [],
|
||||
});
|
||||
}
|
||||
|
||||
class CategoryButtonData {
|
||||
final String label;
|
||||
|
||||
final void Function() onTap;
|
||||
|
||||
const CategoryButtonData({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class BaseCategoryPart {
|
||||
String get title;
|
||||
|
||||
List<String> get categories;
|
||||
|
||||
List<String>? get categoryParams => null;
|
||||
|
||||
bool get enableRandom;
|
||||
|
||||
String get categoryType;
|
||||
|
||||
/// Data class for building a part of category page.
|
||||
const BaseCategoryPart();
|
||||
}
|
||||
|
||||
class FixedCategoryPart extends BaseCategoryPart {
|
||||
@override
|
||||
final List<String> categories;
|
||||
|
||||
@override
|
||||
bool get enableRandom => false;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
@override
|
||||
final List<String>? categoryParams;
|
||||
|
||||
/// A [BaseCategoryPart] that show fixed tags on category page.
|
||||
const FixedCategoryPart(this.title, this.categories, this.categoryType,
|
||||
[this.categoryParams]);
|
||||
}
|
||||
|
||||
class RandomCategoryPart extends BaseCategoryPart {
|
||||
final List<String> tags;
|
||||
|
||||
final int randomNumber;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
bool get enableRandom => true;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
List<String> _categories() {
|
||||
if (randomNumber >= tags.length) {
|
||||
return tags;
|
||||
}
|
||||
return tags.sublist(math.Random().nextInt(tags.length - randomNumber));
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get categories => _categories();
|
||||
|
||||
/// A [BaseCategoryPart] that show random tags on category page.
|
||||
const RandomCategoryPart(
|
||||
this.title, this.tags, this.randomNumber, this.categoryType);
|
||||
}
|
||||
|
||||
class RandomCategoryPartWithRuntimeData extends BaseCategoryPart {
|
||||
final Iterable<String> Function() loadTags;
|
||||
|
||||
final int randomNumber;
|
||||
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
bool get enableRandom => true;
|
||||
|
||||
@override
|
||||
final String categoryType;
|
||||
|
||||
static final random = math.Random();
|
||||
|
||||
List<String> _categories() {
|
||||
var tags = loadTags();
|
||||
if (randomNumber >= tags.length) {
|
||||
return tags.toList();
|
||||
}
|
||||
final start = random.nextInt(tags.length - randomNumber);
|
||||
var res = List.filled(randomNumber, '');
|
||||
int index = -1;
|
||||
for (var s in tags) {
|
||||
index++;
|
||||
if (start > index) {
|
||||
continue;
|
||||
} else if (index == start + randomNumber) {
|
||||
break;
|
||||
}
|
||||
res[index - start] = s;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get categories => _categories();
|
||||
|
||||
/// A [BaseCategoryPart] that show random tags on category page.
|
||||
RandomCategoryPartWithRuntimeData(
|
||||
this.title, this.loadTags, this.randomNumber, this.categoryType);
|
||||
}
|
||||
|
||||
CategoryData getCategoryDataWithKey(String key) {
|
||||
for (var source in ComicSource.sources) {
|
||||
if (source.categoryData?.key == key) {
|
||||
return source.categoryData!;
|
||||
}
|
||||
}
|
||||
throw "Unknown category key $key";
|
||||
}
|
540
lib/foundation/comic_source/comic_source.dart
Normal file
540
lib/foundation/comic_source/comic_source.dart
Normal file
@@ -0,0 +1,540 @@
|
||||
library comic_source;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/res.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
import '../js_engine.dart';
|
||||
import '../log.dart';
|
||||
|
||||
part 'category.dart';
|
||||
|
||||
part 'favorites.dart';
|
||||
|
||||
part 'parser.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);
|
||||
|
||||
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 = Map<String, dynamic> Function(
|
||||
String imageKey, String comicId, String epId)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
|
||||
class ComicSource {
|
||||
static List<ComicSource> sources = [];
|
||||
|
||||
static ComicSource? find(String key) =>
|
||||
sources.firstWhereOrNull((element) => element.key == key);
|
||||
|
||||
static ComicSource? fromIntKey(int key) =>
|
||||
sources.firstWhereOrNull((element) => element.key.hashCode == key);
|
||||
|
||||
static Future<void> init() async {
|
||||
final path = "${App.dataPath}/comic_source";
|
||||
if (!(await Directory(path).exists())) {
|
||||
Directory(path).create();
|
||||
return;
|
||||
}
|
||||
await for (var entity in Directory(path).list()) {
|
||||
if (entity is File && entity.path.endsWith(".js")) {
|
||||
try {
|
||||
var source = await ComicSourceParser()
|
||||
.parse(await entity.readAsString(), entity.absolute.path);
|
||||
sources.add(source);
|
||||
} catch (e, s) {
|
||||
Log.error("ComicSource", "$e\n$s");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future reload() async {
|
||||
sources.clear();
|
||||
JsEngine().runCode("ComicSource.sources = {};");
|
||||
await init();
|
||||
}
|
||||
|
||||
/// Name of this source.
|
||||
final String name;
|
||||
|
||||
/// Identifier of this source.
|
||||
final String key;
|
||||
|
||||
int get intKey {
|
||||
return key.hashCode;
|
||||
}
|
||||
|
||||
/// Account config.
|
||||
final AccountConfig? account;
|
||||
|
||||
/// Category data used to build a static category tags page.
|
||||
final CategoryData? categoryData;
|
||||
|
||||
/// Category comics data used to build a comics page with a category tag.
|
||||
final CategoryComicsData? categoryComicsData;
|
||||
|
||||
/// Favorite data used to build favorite page.
|
||||
final FavoriteData? favoriteData;
|
||||
|
||||
/// Explore pages.
|
||||
final List<ExplorePageData> explorePages;
|
||||
|
||||
/// Search page.
|
||||
final SearchPageData? searchPageData;
|
||||
|
||||
/// Settings.
|
||||
final List<SettingItem> settings;
|
||||
|
||||
/// Load comic info.
|
||||
final LoadComicFunc? loadComicInfo;
|
||||
|
||||
/// Load comic pages.
|
||||
final LoadComicPagesFunc? loadComicPages;
|
||||
|
||||
final Map<String, dynamic> Function(
|
||||
String imageKey, String comicId, String epId)? getImageLoadingConfig;
|
||||
|
||||
final Map<String, dynamic> Function(String imageKey)?
|
||||
getThumbnailLoadingConfig;
|
||||
|
||||
final String? matchBriefIdReg;
|
||||
|
||||
var data = <String, dynamic>{};
|
||||
|
||||
bool get isLogin => data["account"] != null;
|
||||
|
||||
final String filePath;
|
||||
|
||||
final String url;
|
||||
|
||||
final String version;
|
||||
|
||||
final CommentsLoader? commentsLoader;
|
||||
|
||||
final SendCommentFunc? sendCommentFunc;
|
||||
|
||||
final RegExp? idMatcher;
|
||||
|
||||
Future<void> loadData() async {
|
||||
var file = File("${App.dataPath}/comic_source/$key.data");
|
||||
if (await file.exists()) {
|
||||
data = Map.from(jsonDecode(await file.readAsString()));
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSaving = false;
|
||||
bool _haveWaitingTask = false;
|
||||
|
||||
Future<void> saveData() async {
|
||||
if (_haveWaitingTask) return;
|
||||
while (_isSaving) {
|
||||
_haveWaitingTask = true;
|
||||
await Future.delayed(const Duration(milliseconds: 20));
|
||||
_haveWaitingTask = false;
|
||||
}
|
||||
_isSaving = true;
|
||||
var file = File("${App.dataPath}/comic_source/$key.data");
|
||||
if (!await file.exists()) {
|
||||
await file.create(recursive: true);
|
||||
}
|
||||
await file.writeAsString(jsonEncode(data));
|
||||
_isSaving = false;
|
||||
}
|
||||
|
||||
Future<bool> reLogin() async {
|
||||
if (data["account"] == null) {
|
||||
return false;
|
||||
}
|
||||
final List accountData = data["account"];
|
||||
var res = await account!.login!(accountData[0], accountData[1]);
|
||||
if (res.error) {
|
||||
Log.error("Failed to re-login", res.errorMessage ?? "Error");
|
||||
}
|
||||
return !res.error;
|
||||
}
|
||||
|
||||
ComicSource(
|
||||
this.name,
|
||||
this.key,
|
||||
this.account,
|
||||
this.categoryData,
|
||||
this.categoryComicsData,
|
||||
this.favoriteData,
|
||||
this.explorePages,
|
||||
this.searchPageData,
|
||||
this.settings,
|
||||
this.loadComicInfo,
|
||||
this.loadComicPages,
|
||||
this.getImageLoadingConfig,
|
||||
this.getThumbnailLoadingConfig,
|
||||
this.matchBriefIdReg,
|
||||
this.filePath,
|
||||
this.url,
|
||||
this.version,
|
||||
this.commentsLoader,
|
||||
this.sendCommentFunc)
|
||||
: idMatcher = null;
|
||||
|
||||
ComicSource.unknown(this.key)
|
||||
: name = "Unknown",
|
||||
account = null,
|
||||
categoryData = null,
|
||||
categoryComicsData = null,
|
||||
favoriteData = null,
|
||||
explorePages = [],
|
||||
searchPageData = null,
|
||||
settings = [],
|
||||
loadComicInfo = null,
|
||||
loadComicPages = null,
|
||||
getImageLoadingConfig = null,
|
||||
getThumbnailLoadingConfig = null,
|
||||
matchBriefIdReg = null,
|
||||
filePath = "",
|
||||
url = "",
|
||||
version = "",
|
||||
commentsLoader = null,
|
||||
sendCommentFunc = null,
|
||||
idMatcher = null;
|
||||
}
|
||||
|
||||
class AccountConfig {
|
||||
final LoginFunction? login;
|
||||
|
||||
final FutureOr<void> Function(BuildContext)? onLogin;
|
||||
|
||||
final String? loginWebsite;
|
||||
|
||||
final String? registerWebsite;
|
||||
|
||||
final void Function() logout;
|
||||
|
||||
final bool allowReLogin;
|
||||
|
||||
final List<AccountInfoItem> infoItems;
|
||||
|
||||
const AccountConfig(
|
||||
this.login, this.loginWebsite, this.registerWebsite, this.logout,
|
||||
{this.onLogin})
|
||||
: allowReLogin = true,
|
||||
infoItems = const [];
|
||||
}
|
||||
|
||||
class AccountInfoItem {
|
||||
final String title;
|
||||
final String Function()? data;
|
||||
final void Function()? onTap;
|
||||
final WidgetBuilder? builder;
|
||||
|
||||
AccountInfoItem({required this.title, this.data, this.onTap, this.builder});
|
||||
}
|
||||
|
||||
class LoadImageRequest {
|
||||
String url;
|
||||
|
||||
Map<String, String> headers;
|
||||
|
||||
LoadImageRequest(this.url, this.headers);
|
||||
}
|
||||
|
||||
class ExplorePageData {
|
||||
final String title;
|
||||
|
||||
final ExplorePageType type;
|
||||
|
||||
final ComicListBuilder? loadPage;
|
||||
|
||||
final Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
|
||||
|
||||
/// return a `List` contains `List<Comic>` or `ExplorePagePart`
|
||||
final Future<Res<List<Object>>> Function(int index)? loadMixed;
|
||||
|
||||
final WidgetBuilder? overridePageBuilder;
|
||||
|
||||
ExplorePageData(this.title, this.type, this.loadPage, this.loadMultiPart)
|
||||
: loadMixed = null,
|
||||
overridePageBuilder = null;
|
||||
}
|
||||
|
||||
class ExplorePagePart {
|
||||
final String title;
|
||||
|
||||
final List<Comic> comics;
|
||||
|
||||
/// If this is not null, the [ExplorePagePart] will show a button to jump to new page.
|
||||
///
|
||||
/// Value of this field should match the following format:
|
||||
/// - search:keyword
|
||||
/// - category:categoryName
|
||||
///
|
||||
/// End with `@`+`param` if the category has a parameter.
|
||||
final String? viewMore;
|
||||
|
||||
const ExplorePagePart(this.title, this.comics, this.viewMore);
|
||||
}
|
||||
|
||||
enum ExplorePageType {
|
||||
multiPageComicList,
|
||||
singlePageWithMultiPart,
|
||||
mixed,
|
||||
override,
|
||||
}
|
||||
|
||||
typedef SearchFunction = Future<Res<List<Comic>>> Function(
|
||||
String keyword, int page, List<String> searchOption);
|
||||
|
||||
class SearchPageData {
|
||||
/// If this is not null, the default value of search options will be first element.
|
||||
final List<SearchOptions>? searchOptions;
|
||||
|
||||
final Widget Function(BuildContext, List<String> initialValues, void Function(List<String>))?
|
||||
customOptionsBuilder;
|
||||
|
||||
final Widget Function(String keyword, List<String> options)?
|
||||
overrideSearchResultBuilder;
|
||||
|
||||
final SearchFunction? loadPage;
|
||||
|
||||
final bool enableLanguageFilter;
|
||||
|
||||
final bool enableTagsSuggestions;
|
||||
|
||||
const SearchPageData(this.searchOptions, this.loadPage)
|
||||
: enableLanguageFilter = false,
|
||||
customOptionsBuilder = null,
|
||||
overrideSearchResultBuilder = null,
|
||||
enableTagsSuggestions = false;
|
||||
}
|
||||
|
||||
class SearchOptions {
|
||||
final LinkedHashMap<String, String> options;
|
||||
|
||||
final String label;
|
||||
|
||||
const SearchOptions(this.options, this.label);
|
||||
|
||||
String get defaultValue => options.keys.first;
|
||||
}
|
||||
|
||||
class SettingItem {
|
||||
final String name;
|
||||
final String iconName;
|
||||
final SettingType type;
|
||||
final List<String>? options;
|
||||
|
||||
const SettingItem(this.name, this.iconName, this.type, this.options);
|
||||
}
|
||||
|
||||
enum SettingType {
|
||||
switcher,
|
||||
selector,
|
||||
input,
|
||||
}
|
||||
|
||||
class Comic {
|
||||
final String title;
|
||||
|
||||
final String cover;
|
||||
|
||||
final String id;
|
||||
|
||||
final String? subTitle;
|
||||
|
||||
final List<String>? tags;
|
||||
|
||||
final String description;
|
||||
|
||||
final String sourceKey;
|
||||
|
||||
const Comic(this.title, this.cover, this.id, this.subTitle, this.tags, this.description, this.sourceKey);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"title": title,
|
||||
"cover": cover,
|
||||
"id": id,
|
||||
"subTitle": subTitle,
|
||||
"tags": tags,
|
||||
"description": description,
|
||||
"sourceKey": sourceKey,
|
||||
};
|
||||
}
|
||||
|
||||
Comic.fromJson(Map<String, dynamic> json, this.sourceKey)
|
||||
: title = json["title"],
|
||||
subTitle = json["subTitle"] ?? "",
|
||||
cover = json["cover"],
|
||||
id = json["id"],
|
||||
tags = List<String>.from(json["tags"] ?? []),
|
||||
description = json["description"] ?? "";
|
||||
}
|
||||
|
||||
class ComicDetails with HistoryMixin {
|
||||
@override
|
||||
final String title;
|
||||
|
||||
@override
|
||||
final String? subTitle;
|
||||
|
||||
@override
|
||||
final String cover;
|
||||
|
||||
final String? description;
|
||||
|
||||
final Map<String, List<String>> tags;
|
||||
|
||||
/// id-name
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
final List<String>? thumbnails;
|
||||
|
||||
final Future<Res<List<String>>> Function(String id, int page)?
|
||||
thumbnailLoader;
|
||||
|
||||
final int thumbnailMaxPage;
|
||||
|
||||
final List<Comic>? suggestions;
|
||||
|
||||
final String sourceKey;
|
||||
|
||||
final String comicId;
|
||||
|
||||
final bool? isFavorite;
|
||||
|
||||
final String? subId;
|
||||
|
||||
const ComicDetails(
|
||||
this.title,
|
||||
this.subTitle,
|
||||
this.cover,
|
||||
this.description,
|
||||
this.tags,
|
||||
this.chapters,
|
||||
this.thumbnails,
|
||||
this.thumbnailLoader,
|
||||
this.thumbnailMaxPage,
|
||||
this.suggestions,
|
||||
this.sourceKey,
|
||||
this.comicId,
|
||||
{this.isFavorite,
|
||||
this.subId});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
"title": title,
|
||||
"subTitle": subTitle,
|
||||
"cover": cover,
|
||||
"description": description,
|
||||
"tags": tags,
|
||||
"chapters": chapters,
|
||||
"sourceKey": sourceKey,
|
||||
"comicId": comicId,
|
||||
"isFavorite": isFavorite,
|
||||
"subId": subId,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
|
||||
var res = <String, List<String>>{};
|
||||
map.forEach((key, value) {
|
||||
res[key] = List<String>.from(value);
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
ComicDetails.fromJson(Map<String, dynamic> json)
|
||||
: title = json["title"],
|
||||
subTitle = json["subTitle"],
|
||||
cover = json["cover"],
|
||||
description = json["description"],
|
||||
tags = _generateMap(json["tags"]),
|
||||
chapters = Map<String, String>.from(json["chapters"]),
|
||||
sourceKey = json["sourceKey"],
|
||||
comicId = json["comicId"],
|
||||
thumbnails = null,
|
||||
thumbnailLoader = null,
|
||||
thumbnailMaxPage = 0,
|
||||
suggestions = null,
|
||||
isFavorite = json["isFavorite"],
|
||||
subId = json["subId"];
|
||||
|
||||
@override
|
||||
HistoryType get historyType => HistoryType(sourceKey.hashCode);
|
||||
|
||||
@override
|
||||
String get id => comicId;
|
||||
}
|
||||
|
||||
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
|
||||
String category, String? param, List<String> options, int page);
|
||||
|
||||
class CategoryComicsData {
|
||||
/// options
|
||||
final List<CategoryComicsOptions> options;
|
||||
|
||||
/// [category] is the one clicked by the user on the category page.
|
||||
|
||||
/// if [BaseCategoryPart.categoryParams] is not null, [param] will be not null.
|
||||
///
|
||||
/// [Res.subData] should be maxPage or null if there is no limit.
|
||||
final CategoryComicsLoader load;
|
||||
|
||||
final RankingData? rankingData;
|
||||
|
||||
const CategoryComicsData(this.options, this.load, {this.rankingData});
|
||||
}
|
||||
|
||||
class RankingData {
|
||||
final Map<String, String> options;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(String option, int page) load;
|
||||
|
||||
const RankingData(this.options, this.load);
|
||||
}
|
||||
|
||||
class CategoryComicsOptions {
|
||||
/// Use a [LinkedHashMap] to describe an option list.
|
||||
/// key is for loading comics, value is the name displayed on screen.
|
||||
/// Default value will be the first of the Map.
|
||||
final LinkedHashMap<String, String> options;
|
||||
|
||||
/// If [notShowWhen] contains category's name, the option will not be shown.
|
||||
final List<String> notShowWhen;
|
||||
|
||||
final List<String>? showWhen;
|
||||
|
||||
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
|
||||
}
|
||||
|
||||
class Comment {
|
||||
final String userName;
|
||||
final String? avatar;
|
||||
final String content;
|
||||
final String? time;
|
||||
final int? replyCount;
|
||||
final String? id;
|
||||
|
||||
const Comment(this.userName, this.avatar, this.content, this.time,
|
||||
this.replyCount, this.id);
|
||||
}
|
50
lib/foundation/comic_source/favorites.dart
Normal file
50
lib/foundation/comic_source/favorites.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
part of 'comic_source.dart';
|
||||
|
||||
typedef AddOrDelFavFunc = Future<Res<bool>> Function(String comicId, String folderId, bool isAdding);
|
||||
|
||||
class FavoriteData{
|
||||
final String key;
|
||||
|
||||
final String title;
|
||||
|
||||
final bool multiFolder;
|
||||
|
||||
final Future<Res<List<Comic>>> Function(int page, [String? folder]) loadComic;
|
||||
|
||||
/// key-id, value-name
|
||||
///
|
||||
/// if comicId is not null, Res.subData is the folders that the comic is in
|
||||
final Future<Res<Map<String, String>>> Function([String? comicId])? loadFolders;
|
||||
|
||||
/// A value of null disables this feature
|
||||
final Future<Res<bool>> Function(String key)? deleteFolder;
|
||||
|
||||
/// A value of null disables this feature
|
||||
final Future<Res<bool>> Function(String name)? addFolder;
|
||||
|
||||
/// A value of null disables this feature
|
||||
final String? allFavoritesId;
|
||||
|
||||
final AddOrDelFavFunc? addOrDelFavorite;
|
||||
|
||||
const FavoriteData({
|
||||
required this.key,
|
||||
required this.title,
|
||||
required this.multiFolder,
|
||||
required this.loadComic,
|
||||
this.loadFolders,
|
||||
this.deleteFolder,
|
||||
this.addFolder,
|
||||
this.allFavoritesId,
|
||||
this.addOrDelFavorite});
|
||||
}
|
||||
|
||||
FavoriteData getFavoriteData(String key){
|
||||
var source = ComicSource.find(key) ?? (throw "Unknown source key: $key");
|
||||
return source.favoriteData!;
|
||||
}
|
||||
|
||||
FavoriteData? getFavoriteDataOrNull(String key){
|
||||
var source = ComicSource.find(key);
|
||||
return source?.favoriteData;
|
||||
}
|
652
lib/foundation/comic_source/parser.dart
Normal file
652
lib/foundation/comic_source/parser.dart
Normal file
@@ -0,0 +1,652 @@
|
||||
part of 'comic_source.dart';
|
||||
|
||||
bool compareSemVer(String ver1, String ver2) {
|
||||
ver1 = ver1.replaceFirst("-", ".");
|
||||
ver2 = ver2.replaceFirst("-", ".");
|
||||
List<String> v1 = ver1.split('.');
|
||||
List<String> v2 = ver2.split('.');
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int num1 = int.parse(v1[i]);
|
||||
int num2 = int.parse(v2[i]);
|
||||
|
||||
if (num1 > num2) {
|
||||
return true;
|
||||
} else if (num1 < num2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var v14 = v1.elementAtOrNull(3);
|
||||
var v24 = v2.elementAtOrNull(3);
|
||||
|
||||
if (v14 != v24) {
|
||||
if (v14 == null && v24 != "hotfix") {
|
||||
return true;
|
||||
} else if (v14 == null) {
|
||||
return false;
|
||||
}
|
||||
if (v24 == null) {
|
||||
if (v14 == "hotfix") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return v14.compareTo(v24) > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
class ComicSourceParseException implements Exception {
|
||||
final String message;
|
||||
|
||||
ComicSourceParseException(this.message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
class ComicSourceParser {
|
||||
/// comic source key
|
||||
String? _key;
|
||||
|
||||
String? _name;
|
||||
|
||||
Future<ComicSource> createAndParse(String js, String fileName) async{
|
||||
if(!fileName.endsWith("js")){
|
||||
fileName = "$fileName.js";
|
||||
}
|
||||
var file = File("${App.dataPath}/comic_source/$fileName");
|
||||
if(file.existsSync()){
|
||||
int i = 0;
|
||||
while(file.existsSync()){
|
||||
file = File("${App.dataPath}/comic_source/$fileName($i).js");
|
||||
i++;
|
||||
}
|
||||
}
|
||||
await file.writeAsString(js);
|
||||
try{
|
||||
return await parse(js, file.path);
|
||||
} catch (e) {
|
||||
await file.delete();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ComicSource> parse(String js, String filePath) async {
|
||||
js = js.replaceAll("\r\n", "\n");
|
||||
var line1 = js.split('\n')
|
||||
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
|
||||
if(line1 == null || !line1.startsWith("class ") || !line1.contains("extends ComicSource")){
|
||||
throw ComicSourceParseException("Invalid Content");
|
||||
}
|
||||
var className = line1.split("class")[1].split("extends ComicSource").first;
|
||||
className = className.trim();
|
||||
JsEngine().runCode("""
|
||||
(() => {
|
||||
$js
|
||||
this['temp'] = new $className()
|
||||
}).call()
|
||||
""");
|
||||
_name = JsEngine().runCode("this['temp'].name")
|
||||
?? (throw ComicSourceParseException('name is required'));
|
||||
var key = JsEngine().runCode("this['temp'].key")
|
||||
?? (throw ComicSourceParseException('key is required'));
|
||||
var version = JsEngine().runCode("this['temp'].version")
|
||||
?? (throw ComicSourceParseException('version is required'));
|
||||
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
|
||||
var url = JsEngine().runCode("this['temp'].url");
|
||||
var matchBriefIdRegex = JsEngine().runCode("this['temp'].comic.matchBriefIdRegex");
|
||||
if(minAppVersion != null){
|
||||
if(compareSemVer(minAppVersion, App.version.split('-').first)){
|
||||
throw ComicSourceParseException("minAppVersion $minAppVersion is required");
|
||||
}
|
||||
}
|
||||
for(var source in ComicSource.sources){
|
||||
if(source.key == key){
|
||||
throw ComicSourceParseException("key($key) already exists");
|
||||
}
|
||||
}
|
||||
_key = key;
|
||||
_checkKeyValidation();
|
||||
|
||||
JsEngine().runCode("""
|
||||
ComicSource.sources.$_key = this['temp'];
|
||||
""");
|
||||
|
||||
final account = _loadAccountConfig();
|
||||
final explorePageData = _loadExploreData();
|
||||
final categoryPageData = _loadCategoryData();
|
||||
final categoryComicsData =
|
||||
_loadCategoryComicsData();
|
||||
final searchData = _loadSearchData();
|
||||
final loadComicFunc = _parseLoadComicFunc();
|
||||
final loadComicPagesFunc = _parseLoadComicPagesFunc();
|
||||
final getImageLoadingConfigFunc = _parseImageLoadingConfigFunc();
|
||||
final getThumbnailLoadingConfigFunc = _parseThumbnailLoadingConfigFunc();
|
||||
final favoriteData = _loadFavoriteData();
|
||||
final commentsLoader = _parseCommentsLoader();
|
||||
final sendCommentFunc = _parseSendCommentFunc();
|
||||
|
||||
var source = ComicSource(
|
||||
_name!,
|
||||
key,
|
||||
account,
|
||||
categoryPageData,
|
||||
categoryComicsData,
|
||||
favoriteData,
|
||||
explorePageData,
|
||||
searchData,
|
||||
[],
|
||||
loadComicFunc,
|
||||
loadComicPagesFunc,
|
||||
getImageLoadingConfigFunc,
|
||||
getThumbnailLoadingConfigFunc,
|
||||
matchBriefIdRegex,
|
||||
filePath,
|
||||
url ?? "",
|
||||
version ?? "1.0.0",
|
||||
commentsLoader,
|
||||
sendCommentFunc);
|
||||
|
||||
await source.loadData();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
JsEngine().runCode("ComicSource.sources.$_key.init()");
|
||||
});
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
_checkKeyValidation() {
|
||||
// 仅允许数字和字母以及下划线
|
||||
if (!_key!.contains(RegExp(r"^[a-zA-Z0-9_]+$"))) {
|
||||
throw ComicSourceParseException("key $_key is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
bool _checkExists(String index){
|
||||
return JsEngine().runCode("ComicSource.sources.$_key.$index !== null "
|
||||
"&& ComicSource.sources.$_key.$index !== undefined");
|
||||
}
|
||||
|
||||
dynamic _getValue(String index) {
|
||||
return JsEngine().runCode("ComicSource.sources.$_key.$index");
|
||||
}
|
||||
|
||||
AccountConfig? _loadAccountConfig() {
|
||||
if (!_checkExists("account")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<Res<bool>> login(account, pwd) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.account.login(${jsonEncode(account)},
|
||||
${jsonEncode(pwd)})
|
||||
""");
|
||||
var source = ComicSource.sources
|
||||
.firstWhere((element) => element.key == _key);
|
||||
source.data["account"] = <String>[account, pwd];
|
||||
source.saveData();
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
void logout(){
|
||||
JsEngine().runCode("ComicSource.sources.$_key.account.logout()");
|
||||
}
|
||||
|
||||
return AccountConfig(
|
||||
login,
|
||||
_getValue("account.login.website"),
|
||||
_getValue("account.registerWebsite"),
|
||||
logout
|
||||
);
|
||||
}
|
||||
|
||||
List<ExplorePageData> _loadExploreData() {
|
||||
if (!_checkExists("explore")) {
|
||||
return const [];
|
||||
}
|
||||
var length = JsEngine().runCode("ComicSource.sources.$_key.explore.length");
|
||||
var pages = <ExplorePageData>[];
|
||||
for (int i=0; i<length; i++) {
|
||||
final String title = _getValue("explore[$i].title");
|
||||
final String type = _getValue("explore[$i].type");
|
||||
Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
|
||||
Future<Res<List<Comic>>> Function(int page)? loadPage;
|
||||
if (type == "singlePageWithMultiPart") {
|
||||
loadMultiPart = () async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load()");
|
||||
return Res(List.from(res.keys.map((e) => ExplorePagePart(
|
||||
e,
|
||||
(res[e] as List)
|
||||
.map<Comic>((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
null))
|
||||
.toList()));
|
||||
} catch (e, s) {
|
||||
Log.error("Data Analysis", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
} else if (type == "multiPageComicList") {
|
||||
loadPage = (int page) async {
|
||||
try {
|
||||
var res = await JsEngine()
|
||||
.runCode("ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
pages.add(ExplorePageData(
|
||||
title,
|
||||
switch (type) {
|
||||
"singlePageWithMultiPart" =>
|
||||
ExplorePageType.singlePageWithMultiPart,
|
||||
"multiPageComicList" => ExplorePageType.multiPageComicList,
|
||||
_ =>
|
||||
throw ComicSourceParseException("Unknown explore page type $type")
|
||||
},
|
||||
loadPage,
|
||||
loadMultiPart));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
CategoryData? _loadCategoryData() {
|
||||
var doc = _getValue("category");
|
||||
|
||||
if (doc?["title"] == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final String title = doc["title"];
|
||||
final bool? enableRankingPage = doc["enableRankingPage"];
|
||||
|
||||
var categoryParts = <BaseCategoryPart>[];
|
||||
|
||||
for (var c in doc["parts"]) {
|
||||
final String name = c["name"];
|
||||
final String type = c["type"];
|
||||
final List<String> tags = List.from(c["categories"]);
|
||||
final String itemType = c["itemType"];
|
||||
final List<String>? categoryParams =
|
||||
c["categoryParams"] == null ? null : List.from(c["categoryParams"]);
|
||||
if (type == "fixed") {
|
||||
categoryParts
|
||||
.add(FixedCategoryPart(name, tags, itemType, categoryParams));
|
||||
} else if (type == "random") {
|
||||
categoryParts.add(
|
||||
RandomCategoryPart(name, tags, c["randomNumber"] ?? 1, itemType));
|
||||
}
|
||||
}
|
||||
|
||||
return CategoryData(
|
||||
title: title,
|
||||
categories: categoryParts,
|
||||
enableRankingPage: enableRankingPage ?? false,
|
||||
key: title);
|
||||
}
|
||||
|
||||
CategoryComicsData? _loadCategoryComicsData() {
|
||||
if (!_checkExists("categoryComics")) return null;
|
||||
var options = <CategoryComicsOptions>[];
|
||||
for (var element in _getValue("categoryComics.optionList")) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(
|
||||
CategoryComicsOptions(
|
||||
map,
|
||||
List.from(element["notShowWhen"] ?? []),
|
||||
element["showWhen"] == null ? null : List.from(element["showWhen"])
|
||||
));
|
||||
}
|
||||
RankingData? rankingData;
|
||||
if(_checkExists("categoryComics.ranking")){
|
||||
var options = <String, String>{};
|
||||
for(var option in _getValue("categoryComics.ranking.options")){
|
||||
if(option.isEmpty || !option.contains("-")){
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
options[key] = value;
|
||||
}
|
||||
rankingData = RankingData(options, (option, page) async{
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.ranking.load(
|
||||
${jsonEncode(option)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
return CategoryComicsData(options, (category, param, options, page) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.categoryComics.load(
|
||||
${jsonEncode(category)},
|
||||
${jsonEncode(param)},
|
||||
${jsonEncode(options)},
|
||||
${jsonEncode(page)}
|
||||
)
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}, rankingData: rankingData);
|
||||
}
|
||||
|
||||
SearchPageData? _loadSearchData() {
|
||||
if (!_checkExists("search")) return null;
|
||||
var options = <SearchOptions>[];
|
||||
for (var element in _getValue("search.optionList") ?? []) {
|
||||
LinkedHashMap<String, String> map = LinkedHashMap<String, String>();
|
||||
for (var option in element["options"]) {
|
||||
if (option.isEmpty || !option.contains("-")) {
|
||||
continue;
|
||||
}
|
||||
var split = option.split("-");
|
||||
var key = split.removeAt(0);
|
||||
var value = split.join("-");
|
||||
map[key] = value;
|
||||
}
|
||||
options.add(SearchOptions(map, element["label"]));
|
||||
}
|
||||
return SearchPageData(options, (keyword, page, searchOption) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.search.load(
|
||||
${jsonEncode(keyword)}, ${jsonEncode(searchOption)}, ${jsonEncode(page)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
LoadComicFunc? _parseLoadComicFunc() {
|
||||
return (id) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.loadInfo(${jsonEncode(id)})
|
||||
""");
|
||||
var tags = <String, List<String>>{};
|
||||
(res["tags"] as Map<String, dynamic>?)
|
||||
?.forEach((key, value) => tags[key] = List.from(value ?? const []));
|
||||
return Res(ComicDetails(
|
||||
res["title"],
|
||||
res["subTitle"],
|
||||
res["cover"],
|
||||
res["description"],
|
||||
tags,
|
||||
res["chapters"] == null ? null : Map.from(res["chapters"]),
|
||||
ListOrNull.from(res["thumbnails"]),
|
||||
// TODO: implement thumbnailLoader
|
||||
null,
|
||||
res["thumbnailMaxPage"] ?? 1,
|
||||
(res["recommend"] as List?)
|
||||
?.map((e) => Comic.fromJson(e, _key!))
|
||||
.toList(),
|
||||
_key!,
|
||||
id,
|
||||
isFavorite: res["isFavorite"],
|
||||
subId: res["subId"],));
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
LoadComicPagesFunc? _parseLoadComicPagesFunc() {
|
||||
return (id, ep) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.loadEp(${jsonEncode(id)}, ${jsonEncode(ep)})
|
||||
""");
|
||||
return Res(List.from(res["images"]));
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
FavoriteData? _loadFavoriteData() {
|
||||
if (!_checkExists("favorites")) return null;
|
||||
|
||||
final bool multiFolder = _getValue("favorites.multiFolder");
|
||||
|
||||
Future<Res<T>> retryZone<T>(Future<Res<T>> Function() func) async{
|
||||
if(!ComicSource.find(_key!)!.isLogin){
|
||||
return const Res.error("Not login");
|
||||
}
|
||||
var res = await func();
|
||||
if (res.error && res.errorMessage!.contains("Login expired")) {
|
||||
var reLoginRes = await ComicSource.find(_key!)!.reLogin();
|
||||
if (!reLoginRes) {
|
||||
return const Res.error("Login expired and re-login failed");
|
||||
} else {
|
||||
return func();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<Res<bool>> addOrDelFavFunc(comicId, folderId, isAdding) async {
|
||||
func() async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.addOrDelFavorite(
|
||||
${jsonEncode(comicId)}, ${jsonEncode(folderId)}, ${jsonEncode(isAdding)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res<bool>.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return retryZone(func);
|
||||
}
|
||||
|
||||
Future<Res<List<Comic>>> loadComic(int page, [String? folder]) async {
|
||||
Future<Res<List<Comic>>> func() async{
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.loadComics(
|
||||
${jsonEncode(page)}, ${jsonEncode(folder)})
|
||||
""");
|
||||
return Res(
|
||||
List.generate(res["comics"].length,
|
||||
(index) => Comic.fromJson(res["comics"][index], _key!)),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
return retryZone(func);
|
||||
}
|
||||
|
||||
Future<Res<Map<String, String>>> Function([String? comicId])? loadFolders;
|
||||
|
||||
Future<Res<bool>> Function(String name)? addFolder;
|
||||
|
||||
Future<Res<bool>> Function(String key)? deleteFolder;
|
||||
|
||||
if(multiFolder) {
|
||||
loadFolders = ([String? comicId]) async {
|
||||
Future<Res<Map<String, String>>> func() async{
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.loadFolders(${jsonEncode(comicId)})
|
||||
""");
|
||||
List<String>? subData;
|
||||
if(res["favorited"] != null){
|
||||
subData = List.from(res["favorited"]);
|
||||
}
|
||||
return Res(Map.from(res["folders"]), subData: subData);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return retryZone(func);
|
||||
};
|
||||
addFolder = (name) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.addFolder(${jsonEncode(name)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
deleteFolder = (key) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.favorites.deleteFolder(${jsonEncode(key)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return FavoriteData(
|
||||
key: _key!,
|
||||
title: _name!,
|
||||
multiFolder: multiFolder,
|
||||
loadComic: loadComic,
|
||||
loadFolders: loadFolders,
|
||||
addFolder: addFolder,
|
||||
deleteFolder: deleteFolder,
|
||||
addOrDelFavorite: addOrDelFavFunc,
|
||||
);
|
||||
}
|
||||
|
||||
CommentsLoader? _parseCommentsLoader(){
|
||||
if(!_checkExists("comic.loadComments")) return null;
|
||||
return (id, subId, page, replyTo) async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.loadComments(
|
||||
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return Res(
|
||||
(res["comments"] as List).map((e) => Comment(
|
||||
e["userName"], e["avatar"], e["content"], e["time"], e["replyCount"], e["id"].toString()
|
||||
)).toList(),
|
||||
subData: res["maxPage"]);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
SendCommentFunc? _parseSendCommentFunc(){
|
||||
if(!_checkExists("comic.sendComment")) return null;
|
||||
return (id, subId, content, replyTo) async {
|
||||
Future<Res<bool>> func() async{
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.sendComment(
|
||||
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(content)}, ${jsonEncode(replyTo)})
|
||||
""");
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e.toString());
|
||||
}
|
||||
}
|
||||
var res = await func();
|
||||
if(res.error && res.errorMessage!.contains("Login expired")){
|
||||
var reLoginRes = await ComicSource.find(_key!)!.reLogin();
|
||||
if (!reLoginRes) {
|
||||
return const Res.error("Login expired and re-login failed");
|
||||
} else {
|
||||
return func();
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
GetImageLoadingConfigFunc? _parseImageLoadingConfigFunc(){
|
||||
if(!_checkExists("comic.onImageLoad")){
|
||||
return null;
|
||||
}
|
||||
return (imageKey, comicId, ep) {
|
||||
return JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.onImageLoad(
|
||||
${jsonEncode(imageKey)}, ${jsonEncode(comicId)}, ${jsonEncode(ep)})
|
||||
""") as Map<String, dynamic>;
|
||||
};
|
||||
}
|
||||
|
||||
GetThumbnailLoadingConfigFunc? _parseThumbnailLoadingConfigFunc(){
|
||||
if(!_checkExists("comic.onThumbnailLoad")){
|
||||
return null;
|
||||
}
|
||||
return (imageKey) {
|
||||
var res = JsEngine().runCode("""
|
||||
ComicSource.sources.$_key.comic.onThumbnailLoad(${jsonEncode(imageKey)})
|
||||
""");
|
||||
if(res is! Map) {
|
||||
Log.error("Network", "function onThumbnailLoad return invalid data");
|
||||
throw "function onThumbnailLoad return invalid data";
|
||||
}
|
||||
return res as Map<String, dynamic>;
|
||||
};
|
||||
}
|
||||
}
|
23
lib/foundation/comic_type.dart
Normal file
23
lib/foundation/comic_type.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
|
||||
class ComicType {
|
||||
final int value;
|
||||
|
||||
const ComicType(this.value);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is ComicType && other.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => value.hashCode;
|
||||
|
||||
ComicSource? get comicSource {
|
||||
if(this == local) {
|
||||
return null;
|
||||
} else {
|
||||
return ComicSource.sources.firstWhere((element) => element.intKey == value);
|
||||
}
|
||||
}
|
||||
|
||||
static const local = ComicType(0);
|
||||
}
|
6
lib/foundation/consts.dart
Normal file
6
lib/foundation/consts.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
const changePoint = 600;
|
||||
|
||||
const changePoint2 = 1300;
|
||||
|
||||
const webUA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36";
|
36
lib/foundation/context.dart
Normal file
36
lib/foundation/context.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_page_route.dart';
|
||||
|
||||
extension Navigation on BuildContext {
|
||||
void pop<T>([T? result]) {
|
||||
if(mounted) {
|
||||
Navigator.of(this).pop(result);
|
||||
}
|
||||
}
|
||||
|
||||
bool canPop() {
|
||||
return Navigator.of(this).canPop();
|
||||
}
|
||||
|
||||
Future<T?> to<T>(Widget Function() builder) {
|
||||
return Navigator.of(this)
|
||||
.push<T>(AppPageRoute(builder: (context) => builder()));
|
||||
}
|
||||
|
||||
double get width => MediaQuery.of(this).size.width;
|
||||
|
||||
double get height => MediaQuery.of(this).size.height;
|
||||
|
||||
EdgeInsets get padding => MediaQuery.of(this).padding;
|
||||
|
||||
EdgeInsets get viewInsets => MediaQuery.of(this).viewInsets;
|
||||
|
||||
ColorScheme get colorScheme => Theme.of(this).colorScheme;
|
||||
|
||||
Brightness get brightness => Theme.of(this).brightness;
|
||||
|
||||
void showMessage({required String message}) {
|
||||
// TODO: show message
|
||||
}
|
||||
}
|
487
lib/foundation/favorites.dart
Normal file
487
lib/foundation/favorites.dart
Normal file
@@ -0,0 +1,487 @@
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'app.dart';
|
||||
import 'comic_type.dart';
|
||||
|
||||
String _getCurTime() {
|
||||
return DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceFirst("T", " ")
|
||||
.substring(0, 19);
|
||||
}
|
||||
|
||||
class FavoriteItem {
|
||||
String name;
|
||||
String author;
|
||||
ComicType type;
|
||||
List<String> tags;
|
||||
String id;
|
||||
String coverPath;
|
||||
String time = _getCurTime();
|
||||
|
||||
FavoriteItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.coverPath,
|
||||
required this.author,
|
||||
required this.type,
|
||||
required this.tags,
|
||||
});
|
||||
|
||||
FavoriteItem.fromRow(Row row)
|
||||
: name = row["name"],
|
||||
author = row["author"],
|
||||
type = ComicType(row["type"]),
|
||||
tags = (row["tags"] as String).split(","),
|
||||
id = row["id"],
|
||||
coverPath = row["cover_path"],
|
||||
time = row["time"] {
|
||||
tags.remove("");
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FavoriteItem && other.id == id && other.type == type;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ type.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
var s = "FavoriteItem: $name $author $coverPath $hashCode $tags";
|
||||
if(s.length > 100) {
|
||||
return s.substring(0, 100);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
class FavoriteItemWithFolderInfo {
|
||||
FavoriteItem comic;
|
||||
String folder;
|
||||
|
||||
FavoriteItemWithFolderInfo(this.comic, this.folder);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FavoriteItemWithFolderInfo &&
|
||||
other.comic == comic &&
|
||||
other.folder == folder;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => comic.hashCode ^ folder.hashCode;
|
||||
}
|
||||
|
||||
class LocalFavoritesManager {
|
||||
factory LocalFavoritesManager() =>
|
||||
cache ?? (cache = LocalFavoritesManager._create());
|
||||
|
||||
LocalFavoritesManager._create();
|
||||
|
||||
static LocalFavoritesManager? cache;
|
||||
|
||||
late Database _db;
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open("${App.dataPath}/local_favorite.db");
|
||||
_db.execute("""
|
||||
create table if not exists folder_order (
|
||||
folder_name text primary key,
|
||||
order_value int
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
Future<List<String>> find(String id, ComicType type) async {
|
||||
var res = <String>[];
|
||||
for (var folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
if (rows.isNotEmpty) {
|
||||
res.add(folder);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<List<String>> findWithModel(FavoriteItem item) async {
|
||||
var res = <String>[];
|
||||
for (var folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [item.id, item.type.value]);
|
||||
if (rows.isNotEmpty) {
|
||||
res.add(folder);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
List<String> _getTablesWithDB() {
|
||||
final tables = _db
|
||||
.select("SELECT name FROM sqlite_master WHERE type='table';")
|
||||
.map((element) => element["name"] as String)
|
||||
.toList();
|
||||
return tables;
|
||||
}
|
||||
|
||||
List<String> _getFolderNamesWithDB() {
|
||||
final folders = _getTablesWithDB();
|
||||
folders.remove('folder_sync');
|
||||
folders.remove('folder_order');
|
||||
var folderToOrder = <String, int>{};
|
||||
for (var folder in folders) {
|
||||
var res = _db.select("""
|
||||
select * from folder_order
|
||||
where folder_name == ?;
|
||||
""", [folder]);
|
||||
if (res.isNotEmpty) {
|
||||
folderToOrder[folder] = res.first["order_value"];
|
||||
} else {
|
||||
folderToOrder[folder] = 0;
|
||||
}
|
||||
}
|
||||
folders.sort((a, b) {
|
||||
return folderToOrder[a]! - folderToOrder[b]!;
|
||||
});
|
||||
return folders;
|
||||
}
|
||||
|
||||
void updateOrder(Map<String, int> order) {
|
||||
for (var folder in order.keys) {
|
||||
_db.execute("""
|
||||
insert or replace into folder_order (folder_name, order_value)
|
||||
values (?, ?);
|
||||
""", [folder, order[folder]]);
|
||||
}
|
||||
}
|
||||
|
||||
int count(String folderName) {
|
||||
return _db.select("""
|
||||
select count(*) as c
|
||||
from "$folderName"
|
||||
""").first["c"];
|
||||
}
|
||||
|
||||
List<String> get folderNames => _getFolderNamesWithDB();
|
||||
|
||||
int maxValue(String folder) {
|
||||
return _db.select("""
|
||||
SELECT MAX(display_order) AS max_value
|
||||
FROM "$folder";
|
||||
""").firstOrNull?["max_value"] ?? 0;
|
||||
}
|
||||
|
||||
int minValue(String folder) {
|
||||
return _db.select("""
|
||||
SELECT MIN(display_order) AS min_value
|
||||
FROM "$folder";
|
||||
""").firstOrNull?["min_value"] ?? 0;
|
||||
}
|
||||
|
||||
List<FavoriteItem> getAllComics(String folder) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
ORDER BY display_order;
|
||||
""");
|
||||
return rows.map((element) => FavoriteItem.fromRow(element)).toList();
|
||||
}
|
||||
|
||||
void addTagTo(String folder, String id, String tag) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set tags = '$tag,' || tags
|
||||
where id == ?
|
||||
""", [id]);
|
||||
}
|
||||
|
||||
List<FavoriteItemWithFolderInfo> allComics() {
|
||||
var res = <FavoriteItemWithFolderInfo>[];
|
||||
for (final folder in folderNames) {
|
||||
var comics = _db.select("""
|
||||
select * from "$folder";
|
||||
""");
|
||||
res.addAll(comics.map((element) =>
|
||||
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(element), folder)));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/// create a folder
|
||||
String createFolder(String name, [bool renameWhenInvalidName = false]) {
|
||||
if (name.isEmpty) {
|
||||
if (renameWhenInvalidName) {
|
||||
int i = 0;
|
||||
while (folderNames.contains(i.toString())) {
|
||||
i++;
|
||||
}
|
||||
name = i.toString();
|
||||
} else {
|
||||
throw "name is empty!";
|
||||
}
|
||||
}
|
||||
if (folderNames.contains(name)) {
|
||||
if (renameWhenInvalidName) {
|
||||
var prevName = name;
|
||||
int i = 0;
|
||||
while (folderNames.contains(i.toString())) {
|
||||
i++;
|
||||
}
|
||||
name = prevName + i.toString();
|
||||
} else {
|
||||
throw Exception("Folder is existing");
|
||||
}
|
||||
}
|
||||
_db.execute("""
|
||||
create table "$name"(
|
||||
id text,
|
||||
name TEXT,
|
||||
author TEXT,
|
||||
type int,
|
||||
tags TEXT,
|
||||
cover_path TEXT,
|
||||
time TEXT,
|
||||
display_order int,
|
||||
primary key (id, type)
|
||||
);
|
||||
""");
|
||||
return name;
|
||||
}
|
||||
|
||||
bool comicExists(String folder, String id, ComicType type) {
|
||||
var res = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
return res.isNotEmpty;
|
||||
}
|
||||
|
||||
FavoriteItem getComic(String folder, String id, ComicType type) {
|
||||
var res = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
if (res.isEmpty) {
|
||||
throw Exception("Comic not found");
|
||||
}
|
||||
return FavoriteItem.fromRow(res.first);
|
||||
}
|
||||
|
||||
/// add comic to a folder
|
||||
///
|
||||
/// This method will download cover to local, to avoid problems like changing url
|
||||
void addComic(String folder, FavoriteItem comic, [int? order]) async {
|
||||
_modifiedAfterLastCache = true;
|
||||
if (!folderNames.contains(folder)) {
|
||||
throw Exception("Folder does not exists");
|
||||
}
|
||||
var res = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == '${comic.id}';
|
||||
""");
|
||||
if (res.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
final params = [
|
||||
comic.id,
|
||||
comic.name,
|
||||
comic.author,
|
||||
comic.type.value,
|
||||
comic.tags.join(","),
|
||||
comic.coverPath,
|
||||
comic.time
|
||||
];
|
||||
if (order != null) {
|
||||
_db.execute("""
|
||||
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [...params, order]);
|
||||
} else if (appdata.settings['newFavoriteAddTo'] == "end") {
|
||||
_db.execute("""
|
||||
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [...params, maxValue(folder) + 1]);
|
||||
} else {
|
||||
_db.execute("""
|
||||
insert into "$folder" (id, name, author, type, tags, cover_path, time, display_order)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [...params, minValue(folder) - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// delete a folder
|
||||
void deleteFolder(String name) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("""
|
||||
delete from folder_sync where folder_name == ?;
|
||||
""", [name]);
|
||||
_db.execute("""
|
||||
drop table "$name";
|
||||
""");
|
||||
}
|
||||
|
||||
void deleteComic(String folder, FavoriteItem comic) {
|
||||
_modifiedAfterLastCache = true;
|
||||
deleteComicWithId(folder, comic.id, comic.type);
|
||||
}
|
||||
|
||||
void deleteComicWithId(String folder, String id, ComicType type) {
|
||||
_modifiedAfterLastCache = true;
|
||||
_db.execute("""
|
||||
delete from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_db.dispose();
|
||||
File("${App.dataPath}/local_favorite.db").deleteSync();
|
||||
await init();
|
||||
}
|
||||
|
||||
void reorder(List<FavoriteItem> newFolder, String folder) async {
|
||||
if (!folderNames.contains(folder)) {
|
||||
throw Exception("Failed to reorder: folder not found");
|
||||
}
|
||||
deleteFolder(folder);
|
||||
createFolder(folder);
|
||||
for (int i = 0; i < newFolder.length; i++) {
|
||||
addComic(folder, newFolder[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
void rename(String before, String after) {
|
||||
if (folderNames.contains(after)) {
|
||||
throw "Name already exists!";
|
||||
}
|
||||
if (after.contains('"')) {
|
||||
throw "Invalid name";
|
||||
}
|
||||
_db.execute("""
|
||||
ALTER TABLE "$before"
|
||||
RENAME TO "$after";
|
||||
""");
|
||||
}
|
||||
|
||||
void onReadEnd(String id, ComicType type) async {
|
||||
_modifiedAfterLastCache = true;
|
||||
for (final folder in folderNames) {
|
||||
var rows = _db.select("""
|
||||
select * from "$folder"
|
||||
where id == ? and type == ?;
|
||||
""", [id, type.value]);
|
||||
if (rows.isNotEmpty) {
|
||||
var newTime = DateTime.now()
|
||||
.toIso8601String()
|
||||
.replaceFirst("T", " ")
|
||||
.substring(0, 19);
|
||||
String updateLocationSql = "";
|
||||
if (appdata.settings['moveFavoriteAfterRead'] == "end") {
|
||||
int maxValue = _db.select("""
|
||||
SELECT MAX(display_order) AS max_value
|
||||
FROM "$folder";
|
||||
""").firstOrNull?["max_value"] ?? 0;
|
||||
updateLocationSql = "display_order = ${maxValue + 1},";
|
||||
} else if (appdata.settings['moveFavoriteAfterRead'] == "start") {
|
||||
int minValue = _db.select("""
|
||||
SELECT MIN(display_order) AS min_value
|
||||
FROM "$folder";
|
||||
""").firstOrNull?["min_value"] ?? 0;
|
||||
updateLocationSql = "display_order = ${minValue - 1},";
|
||||
}
|
||||
_db.execute("""
|
||||
UPDATE "$folder"
|
||||
SET
|
||||
$updateLocationSql
|
||||
time = ?
|
||||
WHERE id == ?;
|
||||
""", [newTime, id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<FavoriteItemWithFolderInfo> search(String keyword) {
|
||||
var keywordList = keyword.split(" ");
|
||||
keyword = keywordList.first;
|
||||
var comics = <FavoriteItemWithFolderInfo>[];
|
||||
for (var table in folderNames) {
|
||||
keyword = "%$keyword%";
|
||||
var res = _db.select("""
|
||||
SELECT * FROM "$table"
|
||||
WHERE name LIKE ? OR author LIKE ? OR tags LIKE ?;
|
||||
""", [keyword, keyword, keyword]);
|
||||
for (var comic in res) {
|
||||
comics.add(
|
||||
FavoriteItemWithFolderInfo(FavoriteItem.fromRow(comic), table));
|
||||
}
|
||||
if (comics.length > 200) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool test(FavoriteItemWithFolderInfo comic, String keyword) {
|
||||
if (comic.comic.name.contains(keyword)) {
|
||||
return true;
|
||||
} else if (comic.comic.author.contains(keyword)) {
|
||||
return true;
|
||||
} else if (comic.comic.tags.any((element) => element.contains(keyword))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 1; i < keywordList.length; i++) {
|
||||
comics =
|
||||
comics.where((element) => test(element, keywordList[i])).toList();
|
||||
}
|
||||
|
||||
return comics;
|
||||
}
|
||||
|
||||
void editTags(String id, String folder, List<String> tags) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set tags = ?
|
||||
where id == ?;
|
||||
""", [tags.join(","), id]);
|
||||
}
|
||||
|
||||
final _cachedFavoritedIds = <String, bool>{};
|
||||
|
||||
bool isExist(String id) {
|
||||
if (_modifiedAfterLastCache) {
|
||||
_cacheFavoritedIds();
|
||||
}
|
||||
return _cachedFavoritedIds.containsKey(id);
|
||||
}
|
||||
|
||||
bool _modifiedAfterLastCache = true;
|
||||
|
||||
void _cacheFavoritedIds() {
|
||||
_modifiedAfterLastCache = false;
|
||||
_cachedFavoritedIds.clear();
|
||||
for (var folder in folderNames) {
|
||||
var res = _db.select("""
|
||||
select id from "$folder";
|
||||
""");
|
||||
for (var row in res) {
|
||||
_cachedFavoritedIds[row["id"]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void updateInfo(String folder, FavoriteItem comic) {
|
||||
_db.execute("""
|
||||
update "$folder"
|
||||
set name = ?, author = ?, cover_path = ?, tags = ?
|
||||
where id == ? and type == ?;
|
||||
""", [comic.name, comic.author, comic.coverPath, comic.tags.join(","), comic.id, comic.type.value]);
|
||||
}
|
||||
}
|
319
lib/foundation/history.dart
Normal file
319
lib/foundation/history.dart
Normal file
@@ -0,0 +1,319 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'log.dart';
|
||||
|
||||
typedef HistoryType = ComicType;
|
||||
|
||||
abstract mixin class HistoryMixin {
|
||||
String get title;
|
||||
|
||||
String? get subTitle;
|
||||
|
||||
String get cover;
|
||||
|
||||
String get id;
|
||||
|
||||
int? get maxPage => null;
|
||||
|
||||
HistoryType get historyType;
|
||||
}
|
||||
|
||||
class History {
|
||||
HistoryType type;
|
||||
|
||||
DateTime time;
|
||||
|
||||
String title;
|
||||
|
||||
String subtitle;
|
||||
|
||||
String cover;
|
||||
|
||||
int ep;
|
||||
|
||||
int page;
|
||||
|
||||
String id;
|
||||
|
||||
Set<int> readEpisode;
|
||||
|
||||
int? maxPage;
|
||||
|
||||
History(this.type, this.time, this.title, this.subtitle, this.cover, this.ep,
|
||||
this.page, this.id,
|
||||
[this.readEpisode = const <int>{}, this.maxPage]);
|
||||
|
||||
History.fromModel(
|
||||
{required HistoryMixin model,
|
||||
required this.ep,
|
||||
required this.page,
|
||||
this.readEpisode = const <int>{},
|
||||
DateTime? time})
|
||||
: type = model.historyType,
|
||||
title = model.title,
|
||||
subtitle = model.subTitle ?? '',
|
||||
cover = model.cover,
|
||||
id = model.id,
|
||||
time = time ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
"type": type.value,
|
||||
"time": time.millisecondsSinceEpoch,
|
||||
"title": title,
|
||||
"subtitle": subtitle,
|
||||
"cover": cover,
|
||||
"ep": ep,
|
||||
"page": page,
|
||||
"id": id,
|
||||
"readEpisode": readEpisode.toList(),
|
||||
"max_page": maxPage
|
||||
};
|
||||
|
||||
History.fromMap(Map<String, dynamic> map)
|
||||
: type = HistoryType(map["type"]),
|
||||
time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
|
||||
title = map["title"],
|
||||
subtitle = map["subtitle"],
|
||||
cover = map["cover"],
|
||||
ep = map["ep"],
|
||||
page = map["page"],
|
||||
id = map["id"],
|
||||
readEpisode = Set<int>.from(
|
||||
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}),
|
||||
maxPage = map["max_page"];
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'History{type: $type, time: $time, title: $title, subtitle: $subtitle, cover: $cover, ep: $ep, page: $page, id: $id}';
|
||||
}
|
||||
|
||||
History.fromRow(Row row)
|
||||
: type = HistoryType(row["type"]),
|
||||
time = DateTime.fromMillisecondsSinceEpoch(row["time"]),
|
||||
title = row["title"],
|
||||
subtitle = row["subtitle"],
|
||||
cover = row["cover"],
|
||||
ep = row["ep"],
|
||||
page = row["page"],
|
||||
id = row["id"],
|
||||
readEpisode = Set<int>.from((row["readEpisode"] as String)
|
||||
.split(',')
|
||||
.where((element) => element != "")
|
||||
.map((e) => int.parse(e))),
|
||||
maxPage = row["max_page"];
|
||||
|
||||
static Future<History> findOrCreate(
|
||||
HistoryMixin model, {
|
||||
int ep = 0,
|
||||
int page = 0,
|
||||
}) async {
|
||||
var history = await HistoryManager().find(model.id);
|
||||
if (history != null) {
|
||||
return history;
|
||||
}
|
||||
history = History.fromModel(model: model, ep: ep, page: page);
|
||||
HistoryManager().addHistory(history);
|
||||
return history;
|
||||
}
|
||||
|
||||
static Future<History> createIfNull(
|
||||
History? history, HistoryMixin model) async {
|
||||
if (history != null) {
|
||||
return history;
|
||||
}
|
||||
history = History.fromModel(model: model, ep: 0, page: 0);
|
||||
HistoryManager().addHistory(history);
|
||||
return history;
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryManager with ChangeNotifier {
|
||||
static HistoryManager? cache;
|
||||
|
||||
HistoryManager.create();
|
||||
|
||||
factory HistoryManager() =>
|
||||
cache == null ? (cache = HistoryManager.create()) : cache!;
|
||||
|
||||
late Database _db;
|
||||
|
||||
int get length => _db.select("select count(*) from history;").first[0] as int;
|
||||
|
||||
Map<String, bool>? _cachedHistory;
|
||||
|
||||
Future<void> tryUpdateDb() async {
|
||||
var file = File("${App.dataPath}/history_temp.db");
|
||||
if (!file.existsSync()) {
|
||||
Log.info("HistoryManager.tryUpdateDb", "db file not exist");
|
||||
return;
|
||||
}
|
||||
var db = sqlite3.open(file.path);
|
||||
var newHistory0 = db.select("""
|
||||
select * from history
|
||||
order by time DESC;
|
||||
""");
|
||||
var newHistory =
|
||||
newHistory0.map((element) => History.fromRow(element)).toList();
|
||||
if (file.existsSync()) {
|
||||
var skips = 0;
|
||||
for (var history in newHistory) {
|
||||
if (findSync(history.id) == null) {
|
||||
addHistory(history);
|
||||
Log.info("HistoryManager", "merge history ${history.id}");
|
||||
} else {
|
||||
skips++;
|
||||
}
|
||||
}
|
||||
Log.info("HistoryManager",
|
||||
"merge history, skipped $skips, added ${newHistory.length - skips}");
|
||||
}
|
||||
db.dispose();
|
||||
file.deleteSync();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open("${App.dataPath}/history.db");
|
||||
|
||||
_db.execute("""
|
||||
create table if not exists history (
|
||||
id text primary key,
|
||||
title text,
|
||||
subtitle text,
|
||||
cover text,
|
||||
time int,
|
||||
type int,
|
||||
ep int,
|
||||
page int,
|
||||
readEpisode text,
|
||||
max_page int
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
/// add history. if exists, update time.
|
||||
///
|
||||
/// This function would be called when user start reading.
|
||||
Future<void> addHistory(History newItem) async {
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
where id == ?;
|
||||
""", [newItem.id]);
|
||||
if (res.isEmpty) {
|
||||
_db.execute("""
|
||||
insert into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [
|
||||
newItem.id,
|
||||
newItem.title,
|
||||
newItem.subtitle,
|
||||
newItem.cover,
|
||||
newItem.time.millisecondsSinceEpoch,
|
||||
newItem.type.value,
|
||||
newItem.ep,
|
||||
newItem.page,
|
||||
newItem.readEpisode.join(','),
|
||||
newItem.maxPage
|
||||
]);
|
||||
} else {
|
||||
_db.execute("""
|
||||
update history
|
||||
set time = ${DateTime.now().millisecondsSinceEpoch}
|
||||
where id == ?;
|
||||
""", [newItem.id]);
|
||||
}
|
||||
updateCache();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> saveReadHistory(History history) async {
|
||||
_db.execute("""
|
||||
update history
|
||||
set time = ${DateTime.now().millisecondsSinceEpoch}, ep = ?, page = ?, readEpisode = ?, max_page = ?
|
||||
where id == ?;
|
||||
""", [
|
||||
history.ep,
|
||||
history.page,
|
||||
history.readEpisode.join(','),
|
||||
history.maxPage,
|
||||
history.id
|
||||
]);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
_db.execute("delete from history;");
|
||||
updateCache();
|
||||
}
|
||||
|
||||
void remove(String id) async {
|
||||
_db.execute("""
|
||||
delete from history
|
||||
where id == '$id';
|
||||
""");
|
||||
updateCache();
|
||||
}
|
||||
|
||||
Future<History?> find(String id) async {
|
||||
return findSync(id);
|
||||
}
|
||||
|
||||
void updateCache() {
|
||||
_cachedHistory = {};
|
||||
var res = _db.select("""
|
||||
select * from history;
|
||||
""");
|
||||
for (var element in res) {
|
||||
_cachedHistory![element["id"] as String] = true;
|
||||
}
|
||||
}
|
||||
|
||||
History? findSync(String id) {
|
||||
if(_cachedHistory == null) {
|
||||
updateCache();
|
||||
}
|
||||
if (!_cachedHistory!.containsKey(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
where id == ?;
|
||||
""", [id]);
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return History.fromRow(res.first);
|
||||
}
|
||||
|
||||
List<History> getAll() {
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
order by time DESC;
|
||||
""");
|
||||
return res.map((element) => History.fromRow(element)).toList();
|
||||
}
|
||||
|
||||
/// 获取最近阅读的漫画
|
||||
List<History> getRecent() {
|
||||
var res = _db.select("""
|
||||
select * from history
|
||||
order by time DESC
|
||||
limit 20;
|
||||
""");
|
||||
return res.map((element) => History.fromRow(element)).toList();
|
||||
}
|
||||
|
||||
/// 获取历史记录的数量
|
||||
int count() {
|
||||
var res = _db.select("""
|
||||
select count(*) from history;
|
||||
""");
|
||||
return res.first[0] as int;
|
||||
}
|
||||
}
|
148
lib/foundation/image_provider/base_image_provider.dart
Normal file
148
lib/foundation/image_provider/base_image_provider.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:async' show Future, StreamController, scheduleMicrotask;
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui show Codec;
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
|
||||
abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
extends ImageProvider<T> {
|
||||
const BaseImageProvider();
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadBufferAsync(key, chunkEvents, decode),
|
||||
chunkEvents: chunkEvents.stream,
|
||||
scale: 1.0,
|
||||
informationCollector: () sync* {
|
||||
yield DiagnosticsProperty<ImageProvider>(
|
||||
'Image provider: $this \n Image key: $key',
|
||||
this,
|
||||
style: DiagnosticsTreeStyle.errorProperty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _loadBufferAsync(
|
||||
T key,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
try {
|
||||
int retryTime = 1;
|
||||
|
||||
bool stop = false;
|
||||
|
||||
chunkEvents.onCancel = () {
|
||||
stop = true;
|
||||
};
|
||||
|
||||
Uint8List? data;
|
||||
|
||||
while (data == null && !stop) {
|
||||
try {
|
||||
if(_cache.containsKey(key.key)){
|
||||
data = _cache[key.key];
|
||||
} else {
|
||||
data = await load(chunkEvents);
|
||||
_checkCacheSize();
|
||||
_cache[key.key] = data;
|
||||
_cacheSize += data.length;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.toString().contains("handshake")) {
|
||||
if (retryTime < 5) {
|
||||
retryTime = 5;
|
||||
}
|
||||
}
|
||||
retryTime <<= 1;
|
||||
if (retryTime > (1 << 3) || stop) {
|
||||
rethrow;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: retryTime));
|
||||
}
|
||||
}
|
||||
|
||||
if(stop) {
|
||||
throw Exception("Image loading is stopped");
|
||||
}
|
||||
|
||||
if(data!.isEmpty) {
|
||||
throw Exception("Empty image data");
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromUint8List(data);
|
||||
return await decode(buffer);
|
||||
} catch (e) {
|
||||
await CacheManager().delete(this.key);
|
||||
Object error = e;
|
||||
if (data.length < 2 * 1024) {
|
||||
// data is too short, it's likely that the data is text, not image
|
||||
try {
|
||||
var text = const Utf8Codec(allowMalformed: false).decoder.convert(data);
|
||||
error = Exception("Expected image data, but got text: $text");
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (e) {
|
||||
scheduleMicrotask(() {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
});
|
||||
rethrow;
|
||||
} finally {
|
||||
chunkEvents.close();
|
||||
}
|
||||
}
|
||||
|
||||
static final _cache = LinkedHashMap<String, Uint8List>();
|
||||
|
||||
static var _cacheSize = 0;
|
||||
|
||||
static var _cacheSizeLimit = 50 * 1024 * 1024;
|
||||
|
||||
static void _checkCacheSize(){
|
||||
while (_cacheSize > _cacheSizeLimit){
|
||||
var firstKey = _cache.keys.first;
|
||||
_cacheSize -= _cache[firstKey]!.length;
|
||||
_cache.remove(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCache(){
|
||||
_cache.clear();
|
||||
_cacheSize = 0;
|
||||
}
|
||||
|
||||
static void setCacheSizeLimit(int size){
|
||||
_cacheSizeLimit = size;
|
||||
_checkCacheSize();
|
||||
}
|
||||
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents);
|
||||
|
||||
String get key;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BaseImageProvider<T> && key == other.key;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$runtimeType($key)";
|
||||
}
|
||||
}
|
||||
|
||||
typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List);
|
80
lib/foundation/image_provider/cached_image.dart
Normal file
80
lib/foundation/image_provider/cached_image.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dart:async' show Future, StreamController;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/foundation/cache_manager.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'base_image_provider.dart';
|
||||
import 'cached_image.dart' as image_provider;
|
||||
|
||||
class CachedImageProvider
|
||||
extends BaseImageProvider<image_provider.CachedImageProvider> {
|
||||
/// Image provider for normal image.
|
||||
const CachedImageProvider(this.url, {this.headers, this.sourceKey});
|
||||
|
||||
final String url;
|
||||
|
||||
final Map<String, String>? headers;
|
||||
|
||||
final String? sourceKey;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||
final cacheKey = "$url@$sourceKey";
|
||||
final cache = await CacheManager().findCache(cacheKey);
|
||||
|
||||
if (cache != null) {
|
||||
return await cache.readAsBytes();
|
||||
}
|
||||
|
||||
var configs = <String, dynamic>{};
|
||||
if (sourceKey != null) {
|
||||
var comicSource = ComicSource.find(sourceKey!);
|
||||
configs = comicSource!.getThumbnailLoadingConfig?.call(url) ?? {};
|
||||
}
|
||||
configs['headers'] ??= {
|
||||
'user-agent': webUA,
|
||||
};
|
||||
|
||||
var dio = AppDio(BaseOptions(
|
||||
headers: configs['headers'],
|
||||
method: configs['method'] ?? 'GET',
|
||||
responseType: ResponseType.stream,
|
||||
));
|
||||
|
||||
var req = await dio.request<ResponseBody>(configs['url'] ?? url,
|
||||
data: configs['data']);
|
||||
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
||||
int? expectedBytes = req.data!.contentLength;
|
||||
if (expectedBytes == -1) {
|
||||
expectedBytes = null;
|
||||
}
|
||||
var buffer = <int>[];
|
||||
await for (var data in stream) {
|
||||
buffer.addAll(data);
|
||||
if (expectedBytes != null) {
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: buffer.length,
|
||||
expectedTotalBytes: expectedBytes,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if(configs['onResponse'] != null) {
|
||||
buffer = configs['onResponse'](buffer);
|
||||
}
|
||||
|
||||
await CacheManager().writeCache(cacheKey, buffer);
|
||||
return Uint8List.fromList(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CachedImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => url;
|
||||
}
|
425
lib/foundation/js_engine.dart
Normal file
425
lib/foundation/js_engine.dart
Normal file
@@ -0,0 +1,425 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:html/parser.dart' as html;
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:flutter_qjs/flutter_qjs.dart';
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/asn1/asn1_parser.dart';
|
||||
import 'package:pointycastle/asn1/primitives/asn1_integer.dart';
|
||||
import 'package:pointycastle/asn1/primitives/asn1_sequence.dart';
|
||||
import 'package:pointycastle/asymmetric/api.dart';
|
||||
import 'package:pointycastle/asymmetric/pkcs1.dart';
|
||||
import 'package:pointycastle/asymmetric/rsa.dart';
|
||||
import 'package:pointycastle/block/aes.dart';
|
||||
import 'package:pointycastle/block/modes/cbc.dart';
|
||||
import 'package:pointycastle/block/modes/cfb.dart';
|
||||
import 'package:pointycastle/block/modes/ecb.dart';
|
||||
import 'package:pointycastle/block/modes/ofb.dart';
|
||||
import 'package:venera/network/app_dio.dart';
|
||||
import 'package:venera/network/cookie_jar.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
import 'comic_source/comic_source.dart';
|
||||
import 'consts.dart';
|
||||
import 'log.dart';
|
||||
|
||||
|
||||
class JavaScriptRuntimeException implements Exception {
|
||||
final String message;
|
||||
|
||||
JavaScriptRuntimeException(this.message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "JSException: $message";
|
||||
}
|
||||
}
|
||||
|
||||
class JsEngine with _JSEngineApi{
|
||||
factory JsEngine() => _cache ?? (_cache = JsEngine._create());
|
||||
|
||||
static JsEngine? _cache;
|
||||
|
||||
JsEngine._create();
|
||||
|
||||
FlutterQjs? _engine;
|
||||
|
||||
bool _closed = true;
|
||||
|
||||
Dio? _dio;
|
||||
|
||||
static void reset(){
|
||||
_cache = null;
|
||||
_cache?.dispose();
|
||||
JsEngine().init();
|
||||
}
|
||||
|
||||
Future<void> init() async{
|
||||
if (!_closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_dio ??= AppDio(BaseOptions(
|
||||
responseType: ResponseType.plain, validateStatus: (status) => true));
|
||||
_cookieJar ??= SingleInstanceCookieJar.instance!;
|
||||
_dio!.interceptors.add(CookieManagerSql(_cookieJar!));
|
||||
// TODO: Cloudflare Interceptor
|
||||
// _dio!.interceptors.add(CloudflareInterceptor());
|
||||
_closed = false;
|
||||
_engine = FlutterQjs();
|
||||
_engine!.dispatch();
|
||||
var setGlobalFunc = _engine!.evaluate(
|
||||
"(key, value) => { this[key] = value; }");
|
||||
(setGlobalFunc as JSInvokable)(["sendMessage", _messageReceiver]);
|
||||
setGlobalFunc.free();
|
||||
var jsInit = await rootBundle.load("assets/init.js");
|
||||
_engine!.evaluate(utf8.decode(jsInit.buffer.asUint8List()), name: "<init>");
|
||||
}
|
||||
catch(e, s){
|
||||
Log.error('JS Engine', 'JS Engine Init Error:\n$e\n$s');
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _messageReceiver(dynamic message) {
|
||||
try {
|
||||
if (message is Map<dynamic, dynamic>) {
|
||||
String method = message["method"] as String;
|
||||
switch (method) {
|
||||
case "log":
|
||||
{
|
||||
String level = message["level"];
|
||||
Log.addLog(
|
||||
switch (level) {
|
||||
"error" => LogLevel.error,
|
||||
"warning" => LogLevel.warning,
|
||||
"info" => LogLevel.info,
|
||||
_ => LogLevel.warning
|
||||
},
|
||||
message["title"],
|
||||
message["content"].toString());
|
||||
}
|
||||
case 'load_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
return ComicSource.sources
|
||||
.firstWhereOrNull((element) => element.key == key)
|
||||
?.data[dataKey];
|
||||
}
|
||||
case 'save_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
var data = message["data"];
|
||||
var source = ComicSource.sources
|
||||
.firstWhere((element) => element.key == key);
|
||||
source.data[dataKey] = data;
|
||||
source.saveData();
|
||||
}
|
||||
case 'delete_data':
|
||||
{
|
||||
String key = message["key"];
|
||||
String dataKey = message["data_key"];
|
||||
var source = ComicSource.sources
|
||||
.firstWhereOrNull((element) => element.key == key);
|
||||
source?.data.remove(dataKey);
|
||||
source?.saveData();
|
||||
}
|
||||
case 'http':
|
||||
{
|
||||
return _http(Map.from(message));
|
||||
}
|
||||
case 'html':
|
||||
{
|
||||
return handleHtmlCallback(Map.from(message));
|
||||
}
|
||||
case 'convert':
|
||||
{
|
||||
return _convert(Map.from(message));
|
||||
}
|
||||
case "random":
|
||||
{
|
||||
return _randomInt(message["min"], message["max"]);
|
||||
}
|
||||
case "cookie":
|
||||
{
|
||||
return handleCookieCallback(Map.from(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(e, s){
|
||||
Log.error("Failed to handle message: $message\n$e\n$s", "JsEngine");
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _http(Map<String, dynamic> req) async{
|
||||
Response? response;
|
||||
String? error;
|
||||
|
||||
try {
|
||||
var headers = Map<String, dynamic>.from(req["headers"] ?? {});
|
||||
if(headers["user-agent"] == null && headers["User-Agent"] == null){
|
||||
headers["User-Agent"] = webUA;
|
||||
}
|
||||
response = await _dio!.request(req["url"], data: req["data"], options: Options(
|
||||
method: req['http_method'],
|
||||
responseType: req["bytes"] == true ? ResponseType.bytes : ResponseType.plain,
|
||||
headers: headers
|
||||
));
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
}
|
||||
|
||||
Map<String, String> headers = {};
|
||||
|
||||
response?.headers.forEach((name, values) => headers[name] = values.join(','));
|
||||
|
||||
dynamic body = response?.data;
|
||||
if(body is! Uint8List && body is List<int>) {
|
||||
body = Uint8List.fromList(body);
|
||||
}
|
||||
|
||||
return {
|
||||
"status": response?.statusCode,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
"error": error,
|
||||
};
|
||||
}
|
||||
|
||||
dynamic runCode(String js, [String? name]) {
|
||||
return _engine!.evaluate(js, name: name);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_cache = null;
|
||||
_closed = true;
|
||||
_engine?.close();
|
||||
_engine?.port.close();
|
||||
}
|
||||
}
|
||||
|
||||
mixin class _JSEngineApi{
|
||||
final Map<int, dom.Document> _documents = {};
|
||||
final Map<int, dom.Element> _elements = {};
|
||||
CookieJarSql? _cookieJar;
|
||||
|
||||
dynamic handleHtmlCallback(Map<String, dynamic> data) {
|
||||
switch (data["function"]) {
|
||||
case "parse":
|
||||
_documents[data["key"]] = html.parse(data["data"]);
|
||||
return null;
|
||||
case "querySelector":
|
||||
var res = _documents[data["key"]]!.querySelector(data["query"]);
|
||||
if(res == null) return null;
|
||||
_elements[_elements.length] = res;
|
||||
return _elements.length - 1;
|
||||
case "querySelectorAll":
|
||||
var res = _documents[data["key"]]!.querySelectorAll(data["query"]);
|
||||
var keys = <int>[];
|
||||
for(var element in res){
|
||||
_elements[_elements.length] = element;
|
||||
keys.add(_elements.length - 1);
|
||||
}
|
||||
return keys;
|
||||
case "getText":
|
||||
return _elements[data["key"]]!.text;
|
||||
case "getAttributes":
|
||||
return _elements[data["key"]]!.attributes;
|
||||
case "dom_querySelector":
|
||||
var res = _elements[data["key"]]!.querySelector(data["query"]);
|
||||
if(res == null) return null;
|
||||
_elements[_elements.length] = res;
|
||||
return _elements.length - 1;
|
||||
case "dom_querySelectorAll":
|
||||
var res = _elements[data["key"]]!.querySelectorAll(data["query"]);
|
||||
var keys = <int>[];
|
||||
for(var element in res){
|
||||
_elements[_elements.length] = element;
|
||||
keys.add(_elements.length - 1);
|
||||
}
|
||||
return keys;
|
||||
case "getChildren":
|
||||
var res = _elements[data["key"]]!.children;
|
||||
var keys = <int>[];
|
||||
for (var element in res) {
|
||||
_elements[_elements.length] = element;
|
||||
keys.add(_elements.length - 1);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
dynamic handleCookieCallback(Map<String, dynamic> data) {
|
||||
switch (data["function"]) {
|
||||
case "set":
|
||||
_cookieJar!.saveFromResponse(
|
||||
Uri.parse(data["url"]),
|
||||
(data["cookies"] as List).map((e) {
|
||||
var c = Cookie(e["name"], e["value"]);
|
||||
if(e['domain'] != null){
|
||||
c.domain = e['domain'];
|
||||
}
|
||||
return c;
|
||||
}).toList());
|
||||
return null;
|
||||
case "get":
|
||||
var cookies = _cookieJar!.loadForRequest(Uri.parse(data["url"]));
|
||||
return cookies.map((e) => {
|
||||
"name": e.name,
|
||||
"value": e.value,
|
||||
"domain": e.domain,
|
||||
"path": e.path,
|
||||
"expires": e.expires,
|
||||
"max-age": e.maxAge,
|
||||
"secure": e.secure,
|
||||
"httpOnly": e.httpOnly,
|
||||
"session": e.expires == null,
|
||||
}).toList();
|
||||
case "delete":
|
||||
clearCookies([data["url"]]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void clear(){
|
||||
_documents.clear();
|
||||
_elements.clear();
|
||||
}
|
||||
|
||||
void clearCookies(List<String> domains) async{
|
||||
for(var domain in domains){
|
||||
var uri = Uri.tryParse(domain);
|
||||
if(uri == null) continue;
|
||||
_cookieJar!.deleteUri(uri);
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _convert(Map<String, dynamic> data) {
|
||||
String type = data["type"];
|
||||
var value = data["value"];
|
||||
bool isEncode = data["isEncode"];
|
||||
try {
|
||||
switch (type) {
|
||||
case "base64":
|
||||
if(value is String){
|
||||
value = utf8.encode(value);
|
||||
}
|
||||
return isEncode
|
||||
? base64Encode(value)
|
||||
: base64Decode(value);
|
||||
case "md5":
|
||||
return Uint8List.fromList(md5.convert(value).bytes);
|
||||
case "sha1":
|
||||
return Uint8List.fromList(sha1.convert(value).bytes);
|
||||
case "sha256":
|
||||
return Uint8List.fromList(sha256.convert(value).bytes);
|
||||
case "sha512":
|
||||
return Uint8List.fromList(sha512.convert(value).bytes);
|
||||
case "aes-ecb":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
var cipher = ECBBlockCipher(AESEngine());
|
||||
cipher.init(false, KeyParameter(key));
|
||||
return cipher.process(value);
|
||||
}
|
||||
return null;
|
||||
case "aes-cbc":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
var iv = data["iv"];
|
||||
var cipher = CBCBlockCipher(AESEngine());
|
||||
cipher.init(false, ParametersWithIV(KeyParameter(key), iv));
|
||||
return cipher.process(value);
|
||||
}
|
||||
return null;
|
||||
case "aes-cfb":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
var blockSize = data["blockSize"];
|
||||
var cipher = CFBBlockCipher(AESEngine(), blockSize);
|
||||
cipher.init(false, KeyParameter(key));
|
||||
return cipher.process(value);
|
||||
}
|
||||
return null;
|
||||
case "aes-ofb":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
var blockSize = data["blockSize"];
|
||||
var cipher = OFBBlockCipher(AESEngine(), blockSize);
|
||||
cipher.init(false, KeyParameter(key));
|
||||
return cipher.process(value);
|
||||
}
|
||||
return null;
|
||||
case "rsa":
|
||||
if(!isEncode){
|
||||
var key = data["key"];
|
||||
final cipher = PKCS1Encoding(RSAEngine());
|
||||
cipher.init(
|
||||
false, PrivateKeyParameter<RSAPrivateKey>(_parsePrivateKey(key)));
|
||||
return _processInBlocks(cipher, value);
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
catch(e) {
|
||||
Log.error("JS Engine", "Failed to convert $type: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
RSAPrivateKey _parsePrivateKey(String privateKeyString) {
|
||||
List<int> privateKeyDER = base64Decode(privateKeyString);
|
||||
var asn1Parser = ASN1Parser(privateKeyDER as Uint8List);
|
||||
final topLevelSeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||
final privateKey = topLevelSeq.elements![2];
|
||||
|
||||
asn1Parser = ASN1Parser(privateKey.valueBytes!);
|
||||
final pkSeq = asn1Parser.nextObject() as ASN1Sequence;
|
||||
|
||||
final modulus = pkSeq.elements![1] as ASN1Integer;
|
||||
final privateExponent = pkSeq.elements![3] as ASN1Integer;
|
||||
final p = pkSeq.elements![4] as ASN1Integer;
|
||||
final q = pkSeq.elements![5] as ASN1Integer;
|
||||
|
||||
return RSAPrivateKey(modulus.integer!, privateExponent.integer!, p.integer!, q.integer!);
|
||||
}
|
||||
|
||||
Uint8List _processInBlocks(
|
||||
AsymmetricBlockCipher engine, Uint8List input) {
|
||||
final numBlocks = input.length ~/ engine.inputBlockSize +
|
||||
((input.length % engine.inputBlockSize != 0) ? 1 : 0);
|
||||
|
||||
final output = Uint8List(numBlocks * engine.outputBlockSize);
|
||||
|
||||
var inputOffset = 0;
|
||||
var outputOffset = 0;
|
||||
while (inputOffset < input.length) {
|
||||
final chunkSize = (inputOffset + engine.inputBlockSize <= input.length)
|
||||
? engine.inputBlockSize
|
||||
: input.length - inputOffset;
|
||||
|
||||
outputOffset += engine.processBlock(
|
||||
input, inputOffset, chunkSize, output, outputOffset);
|
||||
|
||||
inputOffset += chunkSize;
|
||||
}
|
||||
|
||||
return (output.length == outputOffset)
|
||||
? output
|
||||
: output.sublist(0, outputOffset);
|
||||
}
|
||||
|
||||
int _randomInt(int min, int max) {
|
||||
return (min + (max - min) * math.Random().nextDouble()).toInt();
|
||||
}
|
||||
}
|
201
lib/foundation/local.dart
Normal file
201
lib/foundation/local.dart
Normal file
@@ -0,0 +1,201 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:venera/foundation/comic_type.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
class LocalComic {
|
||||
final int id;
|
||||
|
||||
final String title;
|
||||
|
||||
final String subtitle;
|
||||
|
||||
final List<String> tags;
|
||||
|
||||
/// name of the directory, which is in `LocalManager.path`
|
||||
final String directory;
|
||||
|
||||
/// key: chapter id, value: chapter title
|
||||
///
|
||||
/// chapter id is the name of the directory in `LocalManager.path/$directory`
|
||||
final Map<String, String>? chapters;
|
||||
|
||||
/// relative path to the cover image
|
||||
final String cover;
|
||||
|
||||
final ComicType comicType;
|
||||
|
||||
final DateTime createdAt;
|
||||
|
||||
const LocalComic({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.tags,
|
||||
required this.directory,
|
||||
required this.chapters,
|
||||
required this.cover,
|
||||
required this.comicType,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
LocalComic.fromRow(Row row)
|
||||
: id = row[0] as int,
|
||||
title = row[1] as String,
|
||||
subtitle = row[2] as String,
|
||||
tags = List.from(jsonDecode(row[3] as String)),
|
||||
directory = row[4] as String,
|
||||
chapters = Map.from(jsonDecode(row[5] as String)),
|
||||
cover = row[6] as String,
|
||||
comicType = ComicType(row[7] as int),
|
||||
createdAt = DateTime.fromMillisecondsSinceEpoch(row[8] as int);
|
||||
|
||||
File get coverFile => File('${LocalManager().path}/$directory/$cover');
|
||||
}
|
||||
|
||||
class LocalManager with ChangeNotifier {
|
||||
static LocalManager? _instance;
|
||||
|
||||
LocalManager._();
|
||||
|
||||
factory LocalManager() {
|
||||
return _instance ??= LocalManager._();
|
||||
}
|
||||
|
||||
late Database _db;
|
||||
|
||||
late String path;
|
||||
|
||||
Future<void> init() async {
|
||||
_db = sqlite3.open(
|
||||
'${App.dataPath}/local.db',
|
||||
);
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS comics (
|
||||
id INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
directory TEXT NOT NULL,
|
||||
chapters TEXT NOT NULL,
|
||||
cover TEXT NOT NULL,
|
||||
comic_type INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
PRIMARY KEY (id, comic_type)
|
||||
);
|
||||
''');
|
||||
if(File('${App.dataPath}/local_path').existsSync()){
|
||||
path = File('${App.dataPath}/local_path').readAsStringSync();
|
||||
} else {
|
||||
if(App.isAndroid) {
|
||||
var external = await getExternalStorageDirectories();
|
||||
if(external != null && external.isNotEmpty){
|
||||
path = '${external.first.path}/local';
|
||||
} else {
|
||||
path = '${App.dataPath}/local';
|
||||
}
|
||||
} else {
|
||||
path = '${App.dataPath}/local';
|
||||
}
|
||||
}
|
||||
if(!Directory(path).existsSync()) {
|
||||
await Directory(path).create();
|
||||
}
|
||||
}
|
||||
|
||||
int findValidId(ComicType type) {
|
||||
final res = _db.select(
|
||||
'SELECT id FROM comics WHERE comic_type = ? ORDER BY id DESC LIMIT 1;',
|
||||
[type.value],
|
||||
);
|
||||
if (res.isEmpty) {
|
||||
return 1;
|
||||
}
|
||||
return (res.first[0] as int) + 1;
|
||||
}
|
||||
|
||||
Future<void> add(LocalComic comic, [int? id]) async {
|
||||
_db.execute(
|
||||
'INSERT INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||
[
|
||||
id ?? comic.id,
|
||||
comic.title,
|
||||
comic.subtitle,
|
||||
jsonEncode(comic.tags),
|
||||
comic.directory,
|
||||
jsonEncode(comic.chapters),
|
||||
comic.cover,
|
||||
comic.comicType.value,
|
||||
comic.createdAt.millisecondsSinceEpoch,
|
||||
],
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void remove(int id, ComicType comicType) async {
|
||||
_db.execute(
|
||||
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[id, comicType.value],
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void removeComic(LocalComic comic) {
|
||||
remove(comic.id, comic.comicType);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
List<LocalComic> getComics() {
|
||||
final res = _db.select('SELECT * FROM comics;');
|
||||
return res.map((row) => LocalComic.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
LocalComic? find(int id, ComicType comicType) {
|
||||
final res = _db.select(
|
||||
'SELECT * FROM comics WHERE id = ? AND comic_type = ?;',
|
||||
[id, comicType.value],
|
||||
);
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return LocalComic.fromRow(res.first);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_db.dispose();
|
||||
}
|
||||
|
||||
List<LocalComic> getRecent() {
|
||||
final res = _db.select('''
|
||||
SELECT * FROM comics
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
''');
|
||||
return res.map((row) => LocalComic.fromRow(row)).toList();
|
||||
}
|
||||
|
||||
int get count {
|
||||
final res = _db.select('''
|
||||
SELECT COUNT(*) FROM comics;
|
||||
''');
|
||||
return res.first[0] as int;
|
||||
}
|
||||
|
||||
LocalComic? findByName(String name) {
|
||||
final res = _db.select('''
|
||||
SELECT * FROM comics
|
||||
WHERE title = ? OR directory = ?;
|
||||
''', [name, name]);
|
||||
if (res.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return LocalComic.fromRow(res.first);
|
||||
}
|
||||
}
|
99
lib/foundation/log.dart
Normal file
99
lib/foundation/log.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
class LogItem {
|
||||
final LogLevel level;
|
||||
final String title;
|
||||
final String content;
|
||||
final DateTime time = DateTime.now();
|
||||
|
||||
@override
|
||||
toString() => "${level.name} $title $time \n$content\n\n";
|
||||
|
||||
LogItem(this.level, this.title, this.content);
|
||||
}
|
||||
|
||||
enum LogLevel { error, warning, info }
|
||||
|
||||
class Log {
|
||||
static final List<LogItem> _logs = <LogItem>[];
|
||||
|
||||
static List<LogItem> get logs => _logs;
|
||||
|
||||
static const maxLogLength = 3000;
|
||||
|
||||
static const maxLogNumber = 500;
|
||||
|
||||
static bool ignoreLimitation = false;
|
||||
|
||||
/// only for debug
|
||||
static const String? logFile = null;
|
||||
|
||||
static void printWarning(String text) {
|
||||
print('\x1B[33m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static void printError(String text) {
|
||||
print('\x1B[31m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static void addLog(LogLevel level, String title, String content) {
|
||||
if (!ignoreLimitation && content.length > maxLogLength) {
|
||||
content = "${content.substring(0, maxLogLength)}...";
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
switch (level) {
|
||||
case LogLevel.error:
|
||||
printError(content);
|
||||
case LogLevel.warning:
|
||||
printWarning(content);
|
||||
case LogLevel.info:
|
||||
print(content);
|
||||
}
|
||||
}
|
||||
|
||||
var newLog = LogItem(level, title, content);
|
||||
|
||||
if (newLog == _logs.lastOrNull) {
|
||||
return;
|
||||
}
|
||||
|
||||
_logs.add(newLog);
|
||||
if(logFile != null) {
|
||||
File(logFile!).writeAsString(newLog.toString(), mode: FileMode.append);
|
||||
}
|
||||
if (_logs.length > maxLogNumber) {
|
||||
var res = _logs.remove(
|
||||
_logs.firstWhereOrNull((element) => element.level == LogLevel.info));
|
||||
if (!res) {
|
||||
_logs.removeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static info(String title, String content) {
|
||||
addLog(LogLevel.info, title, content);
|
||||
}
|
||||
|
||||
static warning(String title, String content) {
|
||||
addLog(LogLevel.warning, title, content);
|
||||
}
|
||||
|
||||
static error(String title, String content) {
|
||||
addLog(LogLevel.error, title, content);
|
||||
}
|
||||
|
||||
static void clear() => _logs.clear();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
var res = "Logs\n\n";
|
||||
for (var log in _logs) {
|
||||
res += log.toString();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
36
lib/foundation/res.dart
Normal file
36
lib/foundation/res.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
class Res<T> {
|
||||
/// error info
|
||||
final String? errorMessage;
|
||||
|
||||
/// data
|
||||
final T? _data;
|
||||
|
||||
/// is there an error
|
||||
bool get error => errorMessage != null;
|
||||
|
||||
/// whether succeed
|
||||
bool get success => !error;
|
||||
|
||||
/// data
|
||||
T get data => _data ?? (throw Exception(errorMessage));
|
||||
|
||||
/// get data, or null if there is an error
|
||||
T? get dataOrNull => _data;
|
||||
|
||||
final dynamic subData;
|
||||
|
||||
@override
|
||||
String toString() => _data.toString();
|
||||
|
||||
Res.fromErrorRes(Res another, {this.subData})
|
||||
: _data = null,
|
||||
errorMessage = another.errorMessage;
|
||||
|
||||
/// network result
|
||||
const Res(this._data, {this.errorMessage, this.subData});
|
||||
|
||||
const Res.error(String err)
|
||||
: _data = null,
|
||||
subData = null,
|
||||
errorMessage = err;
|
||||
}
|
231
lib/foundation/state_controller.dart
Normal file
231
lib/foundation/state_controller.dart
Normal file
@@ -0,0 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SimpleController extends StateController {
|
||||
final void Function()? refresh_;
|
||||
|
||||
SimpleController({this.refresh_});
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
(refresh_ ?? super.refresh)();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class StateController {
|
||||
static final _controllers = <StateControllerWrapped>[];
|
||||
|
||||
static T put<T extends StateController>(T controller,
|
||||
{Object? tag, bool autoRemove = false}) {
|
||||
_controllers.add(StateControllerWrapped(controller, autoRemove, tag));
|
||||
return controller;
|
||||
}
|
||||
|
||||
static T putIfNotExists<T extends StateController>(T controller,
|
||||
{Object? tag, bool autoRemove = false}) {
|
||||
return findOrNull<T>(tag: tag) ??
|
||||
put(controller, tag: tag, autoRemove: autoRemove);
|
||||
}
|
||||
|
||||
static T find<T extends StateController>({Object? tag}) {
|
||||
try {
|
||||
return _controllers
|
||||
.lastWhere((element) =>
|
||||
element.controller is T && (tag == null || tag == element.tag))
|
||||
.controller as T;
|
||||
} catch (e) {
|
||||
throw StateError("$T with tag $tag Not Found");
|
||||
}
|
||||
}
|
||||
|
||||
static List<T> findAll<T extends StateController>({Object? tag}) {
|
||||
return _controllers
|
||||
.where((element) =>
|
||||
element.controller is T && (tag == null || tag == element.tag))
|
||||
.map((e) => e.controller as T)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static T? findOrNull<T extends StateController>({Object? tag}) {
|
||||
try {
|
||||
return _controllers
|
||||
.lastWhere((element) =>
|
||||
element.controller is T && (tag == null || tag == element.tag))
|
||||
.controller as T;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void remove<T>([Object? tag, bool check = false]) {
|
||||
for (int i = _controllers.length - 1; i >= 0; i--) {
|
||||
var element = _controllers[i];
|
||||
if (element.controller is T && (tag == null || tag == element.tag)) {
|
||||
if (check && !element.autoRemove) {
|
||||
continue;
|
||||
}
|
||||
_controllers.removeAt(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static SimpleController putSimpleController(
|
||||
void Function() onUpdate, Object? tag,
|
||||
{void Function()? refresh}) {
|
||||
var controller = SimpleController(refresh_: refresh);
|
||||
controller.stateUpdaters.add(Pair(null, onUpdate));
|
||||
_controllers.add(StateControllerWrapped(controller, false, tag));
|
||||
return controller;
|
||||
}
|
||||
|
||||
List<Pair<Object?, void Function()>> stateUpdaters = [];
|
||||
|
||||
void update([List<Object>? ids]) {
|
||||
if (ids == null) {
|
||||
for (var element in stateUpdaters) {
|
||||
element.right();
|
||||
}
|
||||
} else {
|
||||
for (var element in stateUpdaters) {
|
||||
if (ids.contains(element.left)) {
|
||||
element.right();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_controllers.removeWhere((element) => element.controller == this);
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
class StateControllerWrapped {
|
||||
StateController controller;
|
||||
bool autoRemove;
|
||||
Object? tag;
|
||||
|
||||
StateControllerWrapped(this.controller, this.autoRemove, this.tag);
|
||||
}
|
||||
|
||||
class StateBuilder<T extends StateController> extends StatefulWidget {
|
||||
const StateBuilder({
|
||||
super.key,
|
||||
this.init,
|
||||
this.dispose,
|
||||
this.initState,
|
||||
this.tag,
|
||||
required this.builder,
|
||||
this.id,
|
||||
});
|
||||
|
||||
final T? init;
|
||||
|
||||
final void Function(T controller)? dispose;
|
||||
|
||||
final void Function(T controller)? initState;
|
||||
|
||||
final Object? tag;
|
||||
|
||||
final Widget Function(T controller) builder;
|
||||
|
||||
Widget builderWrapped(StateController controller) {
|
||||
return builder(controller as T);
|
||||
}
|
||||
|
||||
void initStateWrapped(StateController controller) {
|
||||
return initState?.call(controller as T);
|
||||
}
|
||||
|
||||
void disposeWrapped(StateController controller) {
|
||||
return dispose?.call(controller as T);
|
||||
}
|
||||
|
||||
final Object? id;
|
||||
|
||||
@override
|
||||
State<StateBuilder> createState() => _StateBuilderState<T>();
|
||||
}
|
||||
|
||||
class _StateBuilderState<T extends StateController>
|
||||
extends State<StateBuilder> {
|
||||
late T controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.init != null) {
|
||||
StateController.put(widget.init!, tag: widget.tag, autoRemove: true);
|
||||
}
|
||||
try {
|
||||
controller = StateController.find<T>(tag: widget.tag);
|
||||
} catch (e) {
|
||||
throw "Controller Not Found";
|
||||
}
|
||||
controller.stateUpdaters.add(Pair(widget.id, () {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}));
|
||||
widget.initStateWrapped(controller);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.disposeWrapped(controller);
|
||||
StateController.remove<T>(widget.tag, true);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.builderWrapped(controller);
|
||||
}
|
||||
|
||||
abstract class StateWithController<T extends StatefulWidget> extends State<T> {
|
||||
late final SimpleController _controller;
|
||||
|
||||
void refresh() {
|
||||
_controller.update();
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void initState() {
|
||||
_controller = StateController.putSimpleController(
|
||||
() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
tag,
|
||||
refresh: refresh,
|
||||
);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void update() {
|
||||
_controller.update();
|
||||
}
|
||||
|
||||
Object? get tag;
|
||||
}
|
||||
|
||||
class Pair<M, V>{
|
||||
M left;
|
||||
V right;
|
||||
|
||||
Pair(this.left, this.right);
|
||||
|
||||
Pair.fromMap(Map<M, V> map, M key): left = key, right = map[key]
|
||||
?? (throw Exception("Pair not found"));
|
||||
}
|
110
lib/foundation/widget_utils.dart
Normal file
110
lib/foundation/widget_utils.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension WidgetExtension on Widget{
|
||||
Widget padding(EdgeInsetsGeometry padding){
|
||||
return Padding(padding: padding, child: this);
|
||||
}
|
||||
|
||||
Widget paddingLeft(double padding){
|
||||
return Padding(padding: EdgeInsets.only(left: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingRight(double padding){
|
||||
return Padding(padding: EdgeInsets.only(right: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingTop(double padding){
|
||||
return Padding(padding: EdgeInsets.only(top: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingBottom(double padding){
|
||||
return Padding(padding: EdgeInsets.only(bottom: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingVertical(double padding){
|
||||
return Padding(padding: EdgeInsets.symmetric(vertical: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingHorizontal(double padding){
|
||||
return Padding(padding: EdgeInsets.symmetric(horizontal: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingAll(double padding){
|
||||
return Padding(padding: EdgeInsets.all(padding), child: this);
|
||||
}
|
||||
|
||||
Widget toCenter(){
|
||||
return Center(child: this);
|
||||
}
|
||||
|
||||
Widget toAlign(AlignmentGeometry alignment){
|
||||
return Align(alignment: alignment, child: this);
|
||||
}
|
||||
|
||||
Widget sliverPadding(EdgeInsetsGeometry padding){
|
||||
return SliverPadding(padding: padding, sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingAll(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.all(padding), sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingVertical(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.symmetric(vertical: padding), sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingHorizontal(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.symmetric(horizontal: padding), sliver: this);
|
||||
}
|
||||
|
||||
Widget fixWidth(double width){
|
||||
return SizedBox(width: width, child: this);
|
||||
}
|
||||
|
||||
Widget fixHeight(double height){
|
||||
return SizedBox(height: height, child: this);
|
||||
}
|
||||
}
|
||||
|
||||
/// create default text style
|
||||
TextStyle get ts => const TextStyle();
|
||||
|
||||
extension StyledText on TextStyle {
|
||||
TextStyle get bold => copyWith(fontWeight: FontWeight.bold);
|
||||
|
||||
TextStyle get light => copyWith(fontWeight: FontWeight.w300);
|
||||
|
||||
TextStyle get italic => copyWith(fontStyle: FontStyle.italic);
|
||||
|
||||
TextStyle get underline => copyWith(decoration: TextDecoration.underline);
|
||||
|
||||
TextStyle get lineThrough => copyWith(decoration: TextDecoration.lineThrough);
|
||||
|
||||
TextStyle get overline => copyWith(decoration: TextDecoration.overline);
|
||||
|
||||
TextStyle get s8 => copyWith(fontSize: 8);
|
||||
|
||||
TextStyle get s10 => copyWith(fontSize: 10);
|
||||
|
||||
TextStyle get s12 => copyWith(fontSize: 12);
|
||||
|
||||
TextStyle get s14 => copyWith(fontSize: 14);
|
||||
|
||||
TextStyle get s16 => copyWith(fontSize: 16);
|
||||
|
||||
TextStyle get s18 => copyWith(fontSize: 18);
|
||||
|
||||
TextStyle get s20 => copyWith(fontSize: 20);
|
||||
|
||||
TextStyle get s24 => copyWith(fontSize: 24);
|
||||
|
||||
TextStyle get s28 => copyWith(fontSize: 28);
|
||||
|
||||
TextStyle get s32 => copyWith(fontSize: 32);
|
||||
|
||||
TextStyle get s36 => copyWith(fontSize: 36);
|
||||
|
||||
TextStyle get s40 => copyWith(fontSize: 40);
|
||||
|
||||
TextStyle withColor(Color? color) => copyWith(color: color);
|
||||
}
|
Reference in New Issue
Block a user