initial commit

This commit is contained in:
nyne
2024-09-29 16:17:03 +08:00
commit f08c5cccb9
196 changed files with 16761 additions and 0 deletions

73
lib/foundation/app.dart Normal file
View 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();

View 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,),
),
)
);
}
}

View 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];
}
}

View 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();
}
}
}

View 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";
}

View 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);
}

View 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;
}

View 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>;
};
}
}

View 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);
}

View 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";

View 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
}
}

View 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
View 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;
}
}

View 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);

View 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;
}

View 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
View 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
View 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
View 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;
}

View 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"));
}

View 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);
}