comic source page

This commit is contained in:
nyne
2024-09-30 17:06:58 +08:00
parent a8782b5ce0
commit fdb3901fd1
20 changed files with 695 additions and 27 deletions

View File

@@ -1,5 +1,67 @@
part of "components.dart";
void showToast({required String message, required BuildContext context, Widget? icon, Widget? trailing,}) {
var newEntry = OverlayEntry(
builder: (context) => _ToastOverlay(
message: message,
icon: icon,
trailing: trailing,
));
var state = context.findAncestorStateOfType<OverlayWidgetState>();
state?.addOverlay(newEntry);
Timer(const Duration(seconds: 2), () => state?.remove(newEntry));
}
class _ToastOverlay extends StatelessWidget {
const _ToastOverlay({required this.message, this.icon, this.trailing});
final String message;
final Widget? icon;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 24 + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: Align(
alignment: Alignment.bottomCenter,
child: Material(
color: Theme.of(context).colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(8),
elevation: 2,
textStyle: ts.withColor(Theme.of(context).colorScheme.onInverseSurface),
child: IconTheme(
data: IconThemeData(color: Theme.of(context).colorScheme.onInverseSurface),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) icon!.paddingRight(8),
Text(
message,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500),
maxLines: 3,
),
if (trailing != null) trailing!.paddingLeft(8)
],
),
),
),
),
),
);
}
}
class OverlayWidget extends StatefulWidget {
const OverlayWidget(this.child, {super.key});
@@ -67,8 +129,7 @@ void showConfirmDialog(BuildContext context, String title, String content,
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: context.pop, child: Text("Cancel".tl)),
TextButton(onPressed: context.pop, child: Text("Cancel".tl)),
TextButton(
onPressed: () {
context.pop();
@@ -194,6 +255,9 @@ class ContentDialog extends StatelessWidget {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: context.brightness == Brightness.dark
? BorderSide(color: context.colorScheme.outlineVariant)
: BorderSide.none,
),
insetPadding: context.width < 400
? const EdgeInsets.symmetric(horizontal: 4)

View File

@@ -129,7 +129,7 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
icon: const Icon(Icons.arrow_back_sharp),
onPressed: () => context.canPop()
? context.pop()
: App.rootNavigatorKey.currentContext?.pop(),
: App.pop(),
),
),
const SizedBox(

View File

@@ -67,6 +67,16 @@ class _App {
_ => Colors.blue,
};
}
Function? _forceRebuildHandler;
void registerForceRebuild(Function handler) {
_forceRebuildHandler = handler;
}
void forceRebuild() {
_forceRebuildHandler?.call();
}
}
// ignore: non_constant_identifier_names

View File

@@ -1,5 +1,16 @@
import 'dart:convert';
import 'package:venera/foundation/app.dart';
import 'package:venera/utils/io.dart';
class _Appdata {
final _Settings settings = _Settings();
void saveSettings() async {
var data = jsonEncode(settings._data);
var file = File(FilePath.join(App.dataPath, 'settings.json'));
await file.writeAsString(data);
}
}
final appdata = _Appdata();
@@ -15,9 +26,16 @@ class _Settings {
'newFavoriteAddTo': 'end', // start, end
'moveFavoriteAfterRead': 'none', // none, end, start
'proxy': 'direct', // direct, system, proxy string
'explore_pages': [],
'categories': [],
'favorites': [],
};
operator[](String key) {
return _data[key];
}
operator[]=(String key, dynamic value) {
_data[key] = value;
}
}

View File

@@ -11,6 +11,7 @@ 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 'package:venera/utils/io.dart';
import '../js_engine.dart';
import '../log.dart';

View File

@@ -59,11 +59,11 @@ class ComicSourceParser {
if(!fileName.endsWith("js")){
fileName = "$fileName.js";
}
var file = File("${App.dataPath}/comic_source/$fileName");
var file = File(FilePath.join(App.dataPath, "comic_source", fileName));
if(file.existsSync()){
int i = 0;
while(file.existsSync()){
file = File("${App.dataPath}/comic_source/$fileName($i).js");
file = File(FilePath.join(App.dataPath, "comic_source", "${fileName.split('.').first}($i).js"));
i++;
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'app_page_route.dart';
@@ -31,6 +32,6 @@ extension Navigation on BuildContext {
Brightness get brightness => Theme.of(this).brightness;
void showMessage({required String message}) {
// TODO: show message
showToast(message: message, context: this);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/js_engine.dart';
@@ -15,5 +16,6 @@ Future<void> init() async {
await LocalFavoritesManager().init();
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
await JsEngine().init();
await ComicSource.init();
CacheManager();
}

View File

@@ -53,6 +53,21 @@ class MyApp extends StatefulWidget {
}
class _MyAppState extends State<MyApp> {
@override
void initState() {
App.registerForceRebuild(forceRebuild);
super.initState();
}
void forceRebuild() {
void rebuild(Element el) {
el.markNeedsBuild();
el.visitChildren(rebuild);
}
(context as Element).visitChildren(rebuild);
setState(() {});
}
@override
Widget build(BuildContext context) {
return MaterialApp(

View File

@@ -10,6 +10,8 @@ import 'package:venera/utils/ext.dart';
import '../foundation/app.dart';
export 'package:dio/dio.dart';
class MyLogInterceptor implements Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
@@ -101,8 +103,8 @@ class MyLogInterceptor implements Interceptor {
class AppDio with DioMixin {
String? _proxy = proxy;
AppDio(BaseOptions options) {
this.options = options;
AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions();
interceptors.add(MyLogInterceptor());
httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient);
}
@@ -127,7 +129,7 @@ class AppDio with DioMixin {
static String? proxy;
static Future<String?> getProxy() async {
if (appdata.settings['proxy'].removeAllBlank == "direct") return null;
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") return null;
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
String res;

View File

@@ -0,0 +1,461 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
class ComicSourcePage extends StatefulWidget {
const ComicSourcePage({super.key});
static void checkComicSourceUpdate([bool showLoading = false]) async {
if (ComicSource.all().isEmpty) {
return;
}
var controller = showLoading ? showLoadingDialog(App.rootContext) : null;
var dio = AppDio();
var res = await dio.get<String>(
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
if (res.statusCode != 200) {
App.rootContext.showMessage(message: "Network error".tl);
return;
}
var list = jsonDecode(res.data!) as List;
var versions = <String, String>{};
for (var source in list) {
versions[source['key']] = source['version'];
}
var shouldUpdate = <String>[];
for (var source in ComicSource.all()) {
if (versions.containsKey(source.key) &&
versions[source.key] != source.version) {
shouldUpdate.add(source.key);
}
}
controller?.close();
if (shouldUpdate.isEmpty) {
return;
}
var msg = "";
for (var key in shouldUpdate) {
msg += "${ComicSource.find(key)?.name}: v${versions[key]}\n";
}
msg = msg.trim();
showConfirmDialog(App.rootContext, "Updates Available".tl, msg, () {
for (var key in shouldUpdate) {
var source = ComicSource.find(key);
_BodyState.update(source!);
}
});
}
@override
State<ComicSourcePage> createState() => _ComicSourcePageState();
}
class _ComicSourcePageState extends State<ComicSourcePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text('Comic Source'.tl),
),
body: const _Body(),
);
}
}
class _Body extends StatefulWidget {
const _Body();
@override
State<_Body> createState() => _BodyState();
}
class _BodyState extends State<_Body> {
var url = "";
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
buildCard(context),
buildSettings(),
for (var source in ComicSource.all()) buildSource(context, source),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
],
);
}
Widget buildSettings() {
return SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.update_outlined),
title: Text("Check updates".tl),
onTap: () => ComicSourcePage.checkComicSourceUpdate(true),
trailing: const Icon(Icons.arrow_right),
),
);
}
Widget buildSource(BuildContext context, ComicSource source) {
return SliverToBoxAdapter(
child: Column(
children: [
const Divider(),
ListTile(
title: Text(source.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (App.isDesktop)
Tooltip(
message: "Edit".tl,
child: IconButton(
onPressed: () => edit(source),
icon: const Icon(Icons.edit_note)),
),
Tooltip(
message: "Update".tl,
child: IconButton(
onPressed: () => update(source),
icon: const Icon(Icons.update)),
),
Tooltip(
message: "Delete".tl,
child: IconButton(
onPressed: () => delete(source),
icon: const Icon(Icons.delete)),
),
],
),
),
ListTile(
title: const Text("Version"),
subtitle: Text(source.version),
)
],
),
);
}
void delete(ComicSource source) {
showConfirmDialog(
App.rootContext,
"Delete".tl,
"Are you sure you want to delete it?".tl,
() {
var file = File(source.filePath);
file.delete();
ComicSource.all().remove(source);
_validatePages();
App.forceRebuild();
},
);
}
void edit(ComicSource source) async {
try {
await Process.run("code", [source.filePath], runInShell: true);
await showDialog(
context: App.rootContext,
builder: (context) => AlertDialog(
title: const Text("Reload Configs"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("cancel")),
TextButton(
onPressed: () async {
await ComicSource.reload();
App.forceRebuild();
},
child: const Text("continue")),
],
));
} catch (e) {
context.showMessage(message: "Failed to launch vscode");
}
}
static void update(ComicSource source) async {
if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config");
return;
}
ComicSource.remove(source.key);
bool cancel = false;
var controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
try {
var res = await AppDio().get<String>(source.url,
options: Options(responseType: ResponseType.plain));
if (cancel) return;
controller.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!);
} catch (e) {
if (cancel) return;
App.rootContext.showMessage(message: e.toString());
}
await ComicSource.reload();
App.forceRebuild();
}
Widget buildCard(BuildContext context) {
return SliverToBoxAdapter(
child: Card.outlined(
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text("Add comic source".tl),
leading: const Icon(Icons.dashboard_customize),
),
TextField(
decoration: InputDecoration(
hintText: "URL",
border: const UnderlineInputBorder(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12),
suffix: IconButton(
onPressed: () => handleAddSource(url),
icon: const Icon(Icons.check))),
onChanged: (value) {
url = value;
},
onSubmitted: handleAddSource)
.paddingHorizontal(16)
.paddingBottom(32),
Row(
children: [
TextButton(onPressed: chooseFile, child: Text("Choose file".tl))
.paddingLeft(8),
const Spacer(),
TextButton(
onPressed: () {
showPopUpWidget(
App.rootContext, _ComicSourceList(handleAddSource));
},
child: Text("View list".tl)),
const Spacer(),
TextButton(onPressed: help, child: Text("Open help".tl))
.paddingRight(8),
],
),
const SizedBox(height: 8),
],
),
),
).paddingHorizontal(12),
);
}
void chooseFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['js'],
);
final file = result?.files.first;
if (file == null) return;
try {
var fileName = file.name;
var bytes = file.bytes!;
var content = utf8.decode(bytes);
await addSource(content, fileName);
} catch (e) {
App.rootContext.showMessage(message: e.toString());
}
}
void help() {
launchUrlString(
"https://github.com/venera-app/venera/blob/master/doc/comic_source.md");
}
Future<void> handleAddSource(String url) async {
if (url.isEmpty) {
return;
}
var splits = url.split("/");
splits.removeWhere((element) => element == "");
var fileName = splits.last;
bool cancel = false;
var controller = showLoadingDialog(App.rootContext,
onCancel: () => cancel = true, barrierDismissible: false);
try {
var res = await AppDio()
.get<String>(url, options: Options(responseType: ResponseType.plain));
if (cancel) return;
controller.close();
await addSource(res.data!, fileName);
} catch (e, s) {
if (cancel) return;
context.showMessage(message: e.toString());
Log.error("Add comic source", "$e\n$s");
}
}
Future<void> addSource(String js, String fileName) async {
var comicSource = await ComicSourceParser().createAndParse(js, fileName);
ComicSource.add(comicSource);
_addAllPagesWithComicSource(comicSource);
appdata.saveSettings();
App.forceRebuild();
}
}
class _ComicSourceList extends StatefulWidget {
const _ComicSourceList(this.onAdd);
final Future<void> Function(String) onAdd;
@override
State<_ComicSourceList> createState() => _ComicSourceListState();
}
class _ComicSourceListState extends State<_ComicSourceList> {
bool loading = true;
List? json;
void load() async {
var dio = AppDio();
var res = await dio.get<String>(
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
if (res.statusCode != 200) {
context.showMessage(message: "Network error".tl);
return;
}
setState(() {
json = jsonDecode(res.data!);
loading = false;
});
}
@override
Widget build(BuildContext context) {
return PopUpWidgetScaffold(
title: "Comic Source".tl,
body: buildBody(),
);
}
Widget buildBody() {
if (loading) {
load();
return const Center(child: CircularProgressIndicator());
} else {
var currentKey = ComicSource.all().map((e) => e.key).toList();
return ListView.builder(
itemCount: json!.length,
itemBuilder: (context, index) {
var key = json![index]["key"];
var action = currentKey.contains(key)
? const Icon(Icons.check)
: Tooltip(
message: "Add",
child: IconButton(
icon: const Icon(Icons.add),
onPressed: () async {
await widget.onAdd(
"https://raw.githubusercontent.com/venera-app/venera-configs/master/${json![index]["fileName"]}");
setState(() {});
},
),
);
return ListTile(
title: Text(json![index]["name"]),
subtitle: Text(json![index]["version"]),
trailing: action,
);
},
);
}
}
}
void _validatePages() {
List explorePages = appdata.settings['explore_pages'];
List categoryPages = appdata.settings['categories'];
List networkFavorites = appdata.settings['favorites'];
var totalExplorePages = ComicSource.all()
.map((e) => e.explorePages.map((e) => e.title))
.expand((element) => element)
.toList();
var totalCategoryPages = ComicSource.all()
.map((e) => e.categoryData?.key)
.where((element) => element != null)
.map((e) => e!)
.toList();
var totalNetworkFavorites = ComicSource.all()
.map((e) => e.favoriteData?.key)
.where((element) => element != null)
.map((e) => e!)
.toList();
for (var page in List.from(explorePages)) {
if (!totalExplorePages.contains(page)) {
explorePages.remove(page);
}
}
for (var page in List.from(categoryPages)) {
if (!totalCategoryPages.contains(page)) {
categoryPages.remove(page);
}
}
for (var page in List.from(networkFavorites)) {
if (!totalNetworkFavorites.contains(page)) {
networkFavorites.remove(page);
}
}
appdata.settings['explore_pages'] = explorePages.toSet().toList();
appdata.settings['categories'] = categoryPages.toSet().toList();
appdata.settings['favorites'] = networkFavorites.toSet().toList();
appdata.saveSettings();
}
void _addAllPagesWithComicSource(ComicSource source) {
var explorePages = appdata.settings['explore_pages'];
var categoryPages = appdata.settings['categories'];
var networkFavorites = appdata.settings['favorites'];
if (source.explorePages.isNotEmpty) {
for (var page in source.explorePages) {
if (!explorePages.contains(page.title)) {
explorePages.add(page.title);
}
}
}
if (source.categoryData != null &&
!categoryPages.contains(source.categoryData!.key)) {
categoryPages.add(source.categoryData!.key);
}
if (source.favoriteData != null &&
!networkFavorites.contains(source.favoriteData!.key)) {
networkFavorites.add(source.favoriteData!.key);
}
appdata.settings['explore_pages'] = explorePages.toSet().toList();
appdata.settings['categories'] = categoryPages.toSet().toList();
appdata.settings['favorites'] = networkFavorites.toSet().toList();
appdata.saveSettings();
}

View File

@@ -10,6 +10,7 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
@@ -584,7 +585,9 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: InkWell(
onTap: () {},
onTap: () {
context.to(() => const ComicSourcePage());
},
child: Container(
decoration: BoxDecoration(
border: Border(
@@ -619,23 +622,25 @@ class _ComicSourceWidgetState extends State<_ComicSourceWidget> {
],
),
).paddingHorizontal(16),
SizedBox(
width: double.infinity,
child: Wrap(
children: comicSources.map((e) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(e),
);
}).toList(),
if(comicSources.isNotEmpty)
SizedBox(
width: double.infinity,
child: Wrap(
runSpacing: 8,
spacing: 8,
children: comicSources.map((e) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(e),
);
}).toList(),
).paddingHorizontal(16).paddingBottom(16),
),
),
],
),
),

View File

@@ -5,6 +5,18 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/utils/ext.dart';
import 'package:path/path.dart' as p;
export 'dart:io';
class FilePath {
const FilePath._();
static String join(String path1, String path2, [String? path3, String? path4, String? path5]) {
return p.join(path1, path2, path3, path4, path5);
}
}
extension FileSystemEntityExt on FileSystemEntity {
String get name {