Merge pull request #25 from venera-app/dev

v1.0.4
This commit is contained in:
nyne
2024-11-10 17:59:58 +08:00
committed by GitHub
29 changed files with 914 additions and 74 deletions

View File

@@ -1,5 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:label="venera"
android:name="${applicationName}"

View File

@@ -3,8 +3,16 @@ package com.github.wgh136.venera
import android.app.Activity
import android.content.ContentResolver
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.view.KeyEvent
import android.Manifest
import android.os.Environment
import android.provider.Settings
import androidx.annotation.RequiresApi
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
@@ -23,6 +31,9 @@ class MainActivity : FlutterActivity() {
private lateinit var result: MethodChannel.Result
private val storageRequestCode = 0x10
private var storagePermissionRequest: ((Boolean) -> Unit)? = null
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == pickDirectoryCode) {
@@ -43,6 +54,11 @@ class MainActivity : FlutterActivity() {
result.error("Failed to Copy Files", e.toString(), null)
}
}.start()
} else if (requestCode == storageRequestCode) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
storagePermissionRequest?.invoke(Environment.isExternalStorageManager())
}
storagePermissionRequest = null
}
}
@@ -89,6 +105,13 @@ class MainActivity : FlutterActivity() {
listening = false
}
})
val storageChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "venera/storage")
storageChannel.setMethodCallHandler { _, res ->
requestStoragePermission {result ->
res.success(result)
}
}
}
private fun getProxy(): String {
@@ -145,6 +168,61 @@ class MainActivity : FlutterActivity() {
}
}
}
private fun requestStoragePermission(result: (Boolean) -> Unit) {
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
val readPermission = ContextCompat.checkSelfPermission(
this,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
val writePermission = ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
if (!readPermission || !writePermission) {
storagePermissionRequest = result
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
storageRequestCode
)
} else {
result(true)
}
} else {
if (!Environment.isExternalStorageManager()) {
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.addCategory("android.intent.category.DEFAULT")
intent.data = Uri.parse("package:" + context.packageName)
startActivityForResult(intent, storageRequestCode)
} catch (e: Exception) {
result(false)
}
} else {
result(true)
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if(requestCode == storageRequestCode) {
storagePermissionRequest?.invoke(grantResults.all {
it == PackageManager.PERMISSION_GRANTED
})
storagePermissionRequest = null
}
}
}
class VolumeListen{

View File

@@ -179,7 +179,17 @@
"Move To First": "移动到最前",
"Cancel": "取消",
"Paused": "已暂停",
"Pause": "暂停"
"Pause": "暂停",
"Operation": "操作",
"Upload": "上传",
"Saved": "已保存",
"Sync Data": "同步数据",
"Syncing Data": "正在同步数据",
"Data Sync": "数据同步",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "长按收藏按钮快速添加到这个文件夹",
"Added": "已添加",
"Turn page by volume keys": "使用音量键翻页"
},
"zh_TW": {
"Home": "首頁",
@@ -361,6 +371,16 @@
"Move To First": "移動到最前",
"Cancel": "取消",
"Paused": "已暫停",
"Pause": "暫停"
"Pause": "暫停",
"Operation": "操作",
"Upload": "上傳",
"Saved": "已保存",
"Sync Data": "同步數據",
"Syncing Data": "正在同步數據",
"Data Sync": "數據同步",
"Quick Favorite": "快速收藏",
"Long press on the favorite button to quickly add to this folder": "長按收藏按鈕快速添加到這個文件夾",
"Added": "已添加",
"Turn page by volume keys": "使用音量鍵翻頁"
}
}

View File

@@ -10,7 +10,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.0.3";
final version = "1.0.4";
bool get isAndroid => Platform.isAndroid;

View File

@@ -114,6 +114,10 @@ class _Settings with ChangeNotifier {
'enableLongPressToZoom': true,
'checkUpdateOnStart': true,
'limitImageWidth': true,
'webdav': [], // empty means not configured
'dataVersion': 0,
'quickFavorite': null,
'enableTurnPageByVolumeKey': true,
};
operator [](String key) {

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/image_provider/local_favorite_image.dart';
@@ -148,7 +149,7 @@ class FavoriteItemWithFolderInfo extends FavoriteItem {
);
}
class LocalFavoritesManager {
class LocalFavoritesManager with ChangeNotifier {
factory LocalFavoritesManager() =>
cache ?? (cache = LocalFavoritesManager._create());
@@ -233,6 +234,7 @@ class LocalFavoritesManager {
values (?, ?);
""", [folder, order[folder]]);
}
notifyListeners();
}
int count(String folderName) {
@@ -272,6 +274,7 @@ class LocalFavoritesManager {
set tags = '$tag,' || tags
where id == ?
""", [id]);
notifyListeners();
}
List<FavoriteItemWithFolderInfo> allComics() {
@@ -324,6 +327,7 @@ class LocalFavoritesManager {
primary key (id, type)
);
""");
notifyListeners();
return name;
}
@@ -386,6 +390,7 @@ class LocalFavoritesManager {
values (?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, minValue(folder) - 1]);
}
notifyListeners();
}
/// delete a folder
@@ -394,6 +399,7 @@ class LocalFavoritesManager {
_db.execute("""
drop table "$name";
""");
notifyListeners();
}
void deleteComic(String folder, FavoriteItem comic) {
@@ -408,6 +414,7 @@ class LocalFavoritesManager {
delete from "$folder"
where id == ? and type == ?;
""", [id, type.value]);
notifyListeners();
}
Future<void> clearAll() async {
@@ -425,6 +432,7 @@ class LocalFavoritesManager {
for (int i = 0; i < newFolder.length; i++) {
addComic(folder, newFolder[i], i);
}
notifyListeners();
}
void rename(String before, String after) {
@@ -438,6 +446,7 @@ class LocalFavoritesManager {
ALTER TABLE "$before"
RENAME TO "$after";
""");
notifyListeners();
}
void onReadEnd(String id, ComicType type) async {
@@ -475,6 +484,7 @@ class LocalFavoritesManager {
""", [newTime, id]);
}
}
notifyListeners();
}
List<FavoriteItemWithFolderInfo> search(String keyword) {
@@ -521,6 +531,7 @@ class LocalFavoritesManager {
set tags = ?
where id == ?;
""", [tags.join(","), id]);
notifyListeners();
}
final _cachedFavoritedIds = <String, bool>{};
@@ -560,6 +571,7 @@ class LocalFavoritesManager {
comic.id,
comic.type.value
]);
notifyListeners();
}
String folderToJson(String folder) {

View File

@@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';
@@ -158,12 +159,13 @@ class LocalManager with ChangeNotifier {
return "Directory is not empty";
}
try {
await copyDirectory(
await copyDirectoryIsolate(
Directory(path),
newDir,
);
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(path);
} catch (e) {
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(newPath);
} catch (e, s) {
Log.error("IO", e, s);
return e.toString();
}
await Directory(path).deleteIgnoreError(recursive:true);

View File

@@ -82,11 +82,12 @@ class Log {
addLog(LogLevel.warning, title, content);
}
static error(String title, String content, [Object? stackTrace]) {
static error(String title, Object content, [Object? stackTrace]) {
var info = content.toString();
if(stackTrace != null) {
content += "\n${stackTrace.toString()}";
info += "\n${stackTrace.toString()}";
}
addLog(LogLevel.error, title, content);
addLog(LogLevel.error, title, info);
}
static void clear() => _logs.clear();

View File

@@ -108,11 +108,11 @@ class AppDio with DioMixin {
AppDio([BaseOptions? options]) {
this.options = options ?? BaseOptions();
interceptors.add(MyLogInterceptor());
httpClientAdapter = RHttpAdapter(const rhttp.ClientSettings());
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor());
interceptors.add(MyLogInterceptor());
}
static HttpClient createHttpClient() {
@@ -211,7 +211,7 @@ class AppDio with DioMixin {
class RHttpAdapter implements HttpClientAdapter {
rhttp.ClientSettings settings;
RHttpAdapter(this.settings) {
RHttpAdapter([this.settings = const rhttp.ClientSettings()]) {
settings = settings.copyWith(
redirectSettings: const rhttp.RedirectSettings.limited(5),
timeoutSettings: const rhttp.TimeoutSettings(
@@ -232,12 +232,6 @@ class RHttpAdapter implements HttpClientAdapter {
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
Log.info(
"Network",
"${options.method} ${options.uri}\n"
"Headers: ${options.headers}\n"
"Data: ${options.data}\n",
);
var res = await rhttp.Rhttp.request(
method: switch (options.method) {
'GET' => rhttp.HttpMethod.get,

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.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/comic_type.dart';
import 'package:venera/foundation/consts.dart';
@@ -282,6 +283,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
isActive: isFavorite || isAddToLocalFav,
text: 'Favorite'.tl,
onPressed: openFavPanel,
onLongPressed: quickFavorite,
iconColor: context.useTextColor(Colors.purple),
),
if (comicSource.commentsLoader != null)
@@ -538,12 +540,22 @@ abstract mixin class _ComicPageActions {
bool isFavorite = false;
void openFavPanel() {
FavoriteItem _toFavoriteItem() {
var tags = <String>[];
for (var e in comic.tags.entries) {
tags.addAll(e.value.map((tag) => '${e.key}:$tag'));
}
return FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
);
}
void openFavPanel() {
showSideBar(
App.rootContext,
_FavoritePanel(
@@ -555,18 +567,25 @@ abstract mixin class _ComicPageActions {
isAddToLocalFav = local ?? isAddToLocalFav;
update();
},
favoriteItem: FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
),
favoriteItem: _toFavoriteItem(),
),
);
}
void quickFavorite() {
var folder = appdata.settings['quickFavorite'];
if(folder is! String) {
return;
}
LocalFavoritesManager().addComic(
folder,
_toFavoriteItem(),
);
isAddToLocalFav = true;
update();
App.rootContext.showMessage(message: "Added".tl);
}
void share() {
var text = comic.title;
if (comic.url != null) {
@@ -800,6 +819,7 @@ class _ActionButton extends StatelessWidget {
required this.icon,
required this.text,
required this.onPressed,
this.onLongPressed,
this.activeIcon,
this.isActive,
this.isLoading,
@@ -820,6 +840,8 @@ class _ActionButton extends StatelessWidget {
final Color? iconColor;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
return Container(
@@ -837,6 +859,7 @@ class _ActionButton extends StatelessWidget {
onPressed();
}
},
onLongPress: onLongPressed,
borderRadius: BorderRadius.circular(18),
child: IconTheme.merge(
data: IconThemeData(size: 20, color: iconColor),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -17,6 +18,7 @@ import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/cbz.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
@@ -32,6 +34,7 @@ class HomePage extends StatelessWidget {
slivers: [
SliverPadding(padding: EdgeInsets.only(top: context.padding.top)),
const _SearchBar(),
const _SyncDataWidget(),
const _History(),
const _Local(),
const _ComicSourceWidget(),
@@ -77,6 +80,97 @@ class _SearchBar extends StatelessWidget {
}
}
class _SyncDataWidget extends StatefulWidget {
const _SyncDataWidget();
@override
State<_SyncDataWidget> createState() => _SyncDataWidgetState();
}
class _SyncDataWidgetState extends State<_SyncDataWidget> {
@override
void initState() {
super.initState();
DataSync().addListener(update);
}
void update() {
if(mounted) {
setState(() {});
}
}
@override
void dispose() {
super.dispose();
DataSync().removeListener(update);
}
@override
Widget build(BuildContext context) {
Widget child;
if(!DataSync().isEnabled) {
child = const SliverPadding(padding: EdgeInsets.zero);
} else if (DataSync().isUploading || DataSync().isDownloading) {
child = SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(Icons.sync),
title: Text('Syncing Data'.tl),
trailing: const CircularProgressIndicator(strokeWidth: 2)
.fixWidth(18)
.fixHeight(18),
),
),
);
} else {
child = SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: const Icon(Icons.sync),
title: Text('Sync Data'.tl),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}
),
IconButton(
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}
),
],
),
),
),
);
}
return SliverAnimatedPaintExtent(
duration: const Duration(milliseconds: 200),
child: child,
);
}
}
class _History extends StatefulWidget {
const _History();
@@ -529,7 +623,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
await xFile!.saveTo(cache);
var comic = await CBZ.import(File(cache));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(selectedFolder!, FavoriteItem(
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
@@ -610,7 +706,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
for (var comic in comics.values) {
LocalManager().add(comic, LocalManager().findValidId(ComicType.local));
if (selectedFolder != null) {
LocalFavoritesManager().addComic(selectedFolder!, FavoriteItem(
LocalFavoritesManager().addComic(
selectedFolder!,
FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,

View File

@@ -23,6 +23,7 @@ import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
import 'package:venera/utils/volume.dart';
import 'package:window_manager/window_manager.dart';
part 'scaffold.dart';
@@ -97,6 +98,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
var focusNode = FocusNode();
VolumeListener? volumeListener;
@override
void initState() {
page = widget.initialPage ?? 1;
@@ -107,6 +110,9 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
updateHistory();
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if(appdata.settings['enableTurnPageByVolumeKey']) {
handleVolumeEvent();
}
super.initState();
}
@@ -115,6 +121,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
autoPageTurningTimer?.cancel();
focusNode.dispose();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
stopVolumeEvent();
super.dispose();
}
@@ -152,6 +159,31 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
HistoryManager().addHistory(history!);
}
}
void handleVolumeEvent() {
if(!App.isAndroid) {
// Currently only support Android
return;
}
if(volumeListener != null) {
volumeListener?.cancel();
}
volumeListener = VolumeListener(
onDown: () {
toNextPage();
},
onUp: () {
toPrevPage();
},
)..listen();
}
void stopVolumeEvent() {
if(volumeListener != null) {
volumeListener?.cancel();
volumeListener = null;
}
}
}
abstract mixin class _ReaderLocation {

View File

@@ -470,6 +470,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
App.rootContext.pop();
}
if (key == "enableTurnPageByVolumeKey") {
if(appdata.settings[key]) {
context.reader.handleVolumeEvent();
} else {
context.reader.stopVolumeEvent();
}
}
context.reader.update();
},
),

View File

@@ -32,12 +32,28 @@ class _AppSettingsState extends State<AppSettings> {
title: "Set New Storage Path".tl,
actionTitle: "Set".tl,
callback: () async {
var result;
String? result;
if (App.isAndroid) {
context.showMessage(message: "Not supported".tl);
var channel = const MethodChannel("venera/storage");
var permission = await channel.invokeMethod('');
if(permission != true) {
context.showMessage(message: "Permission denied".tl);
return;
}
else if (App.isIOS) {
var path = await selectDirectory();
if(path != null) {
// check if the path is writable
var testFile = File(FilePath.join(path, "test"));
try {
await testFile.writeAsBytes([1]);
await testFile.delete();
} catch (e) {
context.showMessage(message: "Permission denied".tl);
return;
}
result = path;
}
} else if (App.isIOS) {
result = await selectDirectoryIOS();
} else {
result = await selectDirectory();
@@ -90,8 +106,7 @@ class _AppSettingsState extends State<AppSettings> {
appdata.settings['cacheSize'] = int.parse(value);
appdata.saveData();
setState(() {});
CacheManager()
.setLimitSize(appdata.settings['cacheSize']);
CacheManager().setLimitSize(appdata.settings['cacheSize']);
return null;
},
);
@@ -113,13 +128,12 @@ class _AppSettingsState extends State<AppSettings> {
callback: () async {
var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera']);
if(file != null) {
if (file != null) {
var cacheFile = File(FilePath.join(App.cachePath, "temp.venera"));
await file.saveTo(cacheFile.path);
try {
await importAppData(cacheFile);
}
catch(e, s) {
} catch (e, s) {
Log.error("Import data", e.toString(), s);
context.showMessage(message: "Failed to import data".tl);
}
@@ -128,6 +142,13 @@ class _AppSettingsState extends State<AppSettings> {
},
actionTitle: 'Import'.tl,
).toSliver(),
_CallbackSetting(
title: "Data Sync".tl,
callback: () async {
showPopUpWidget(context, const _WebdavSetting());
},
actionTitle: 'Set'.tl,
).toSliver(),
_SettingPartTitle(
title: "Log".tl,
icon: Icons.error_outline,
@@ -283,3 +304,129 @@ class _LogsPageState extends State<LogsPage> {
saveFile(data: utf8.encode(log), filename: 'log.txt');
}
}
class _WebdavSetting extends StatefulWidget {
const _WebdavSetting();
@override
State<_WebdavSetting> createState() => _WebdavSettingState();
}
class _WebdavSettingState extends State<_WebdavSetting> {
String url = "";
String user = "";
String pass = "";
bool isTesting = false;
bool upload = true;
@override
void initState() {
super.initState();
if (appdata.settings['webdav'] is! List) {
appdata.settings['webdav'] = [];
}
var configs = appdata.settings['webdav'] as List;
if (configs.whereType<String>().length != 3) {
return;
}
url = configs[0];
user = configs[1];
pass = configs[2];
}
@override
Widget build(BuildContext context) {
return PopUpWidgetScaffold(
title: "Webdav",
body: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 12),
TextField(
decoration: const InputDecoration(
labelText: "URL",
border: OutlineInputBorder(),
),
controller: TextEditingController(text: url),
onChanged: (value) => url = value,
),
const SizedBox(height: 12),
TextField(
decoration: InputDecoration(
labelText: "Username".tl,
border: const OutlineInputBorder(),
),
controller: TextEditingController(text: user),
onChanged: (value) => user = value,
),
const SizedBox(height: 12),
TextField(
decoration: InputDecoration(
labelText: "Password".tl,
border: const OutlineInputBorder(),
),
controller: TextEditingController(text: pass),
onChanged: (value) => pass = value,
),
const SizedBox(height: 12),
Row(
children: [
Text("Operation".tl),
Radio<bool>(
groupValue: upload,
value: true,
onChanged: (value) {
setState(() {
upload = value!;
});
},
),
Text("Upload".tl),
Radio<bool>(
groupValue: upload,
value: false,
onChanged: (value) {
setState(() {
upload = value!;
});
},
),
Text("Download".tl),
],
),
const SizedBox(height: 16),
Center(
child: Button.filled(
isLoading: isTesting,
onPressed: () async {
var oldConfig = appdata.settings['webdav'];
appdata.settings['webdav'] = [url, user, pass];
setState(() {
isTesting = true;
});
var testResult = upload
? await DataSync().uploadData()
: await DataSync().downloadData();
if (testResult.error) {
setState(() {
isTesting = false;
});
appdata.settings['webdav'] = oldConfig;
context.showMessage(message: testResult.errorMessage!);
return;
}
appdata.saveData();
context.showMessage(message: "Saved".tl);
App.rootPop();
},
child: Text("Continue".tl),
),
)
],
).paddingHorizontal(16),
),
);
}
}

View File

@@ -24,12 +24,20 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
SelectSetting(
title: "Move favorite after reading".tl,
settingKey: "moveFavoriteAfterRead",
optionTranslation: {
optionTranslation: const {
"none": "None",
"end": "End",
"start": "Start",
},
).toSliver(),
SelectSetting(
title: "Quick Favorite".tl,
settingKey: "quickFavorite",
help: "Long press on the favorite button to quickly add to this folder".tl,
optionTranslation: {
for (var e in LocalFavoritesManager().folderNames) e: e
},
).toSliver(),
],
);
}

View File

@@ -69,6 +69,14 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call('limitImageWidth');
},
).toSliver(),
if(App.isAndroid)
_SwitchSetting(
title: 'Turn page by volume key'.tl,
settingKey: 'enableTurnPageByVolumeKey',
onChanged: () {
widget.onChanged?.call('enableTurnPageByVolumeKey');
},
).toSliver(),
],
);
}

View File

@@ -49,6 +49,7 @@ class SelectSetting extends StatelessWidget {
required this.settingKey,
required this.optionTranslation,
this.onChanged,
this.help,
});
final String title;
@@ -59,6 +60,8 @@ class SelectSetting extends StatelessWidget {
final VoidCallback? onChanged;
final String? help;
@override
Widget build(BuildContext context) {
return SizedBox(
@@ -71,6 +74,7 @@ class SelectSetting extends StatelessWidget {
settingKey: settingKey,
optionTranslation: optionTranslation,
onChanged: onChanged,
help: help,
);
} else {
return _EndSelectorSelectSetting(
@@ -78,6 +82,7 @@ class SelectSetting extends StatelessWidget {
settingKey: settingKey,
optionTranslation: optionTranslation,
onChanged: onChanged,
help: help,
);
}
},
@@ -92,6 +97,7 @@ class _DoubleLineSelectSettings extends StatefulWidget {
required this.settingKey,
required this.optionTranslation,
this.onChanged,
this.help,
});
final String title;
@@ -102,6 +108,8 @@ class _DoubleLineSelectSettings extends StatefulWidget {
final VoidCallback? onChanged;
final String? help;
@override
State<_DoubleLineSelectSettings> createState() =>
_DoubleLineSelectSettingsState();
@@ -111,9 +119,37 @@ class _DoubleLineSelectSettingsState extends State<_DoubleLineSelectSettings> {
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(widget.title),
subtitle:
Text(widget.optionTranslation[appdata.settings[widget.settingKey]]!),
title: Row(
children: [
Text(widget.title),
const SizedBox(width: 4),
if (widget.help != null)
Button.icon(
size: 18,
icon: const Icon(Icons.help_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return ContentDialog(
title: "Help".tl,
content: Text(widget.help!).paddingHorizontal(16).fixWidth(double.infinity),
actions: [
Button.filled(
onPressed: context.pop,
child: Text("OK".tl),
),
],
);
},
);
},
),
],
),
subtitle: Text(
widget.optionTranslation[appdata.settings[widget.settingKey]] ??
"None"),
trailing: const Icon(Icons.arrow_drop_down),
onTap: () {
var renderBox = context.findRenderObject() as RenderBox;
@@ -156,6 +192,7 @@ class _EndSelectorSelectSetting extends StatefulWidget {
required this.settingKey,
required this.optionTranslation,
this.onChanged,
this.help,
});
final String title;
@@ -166,6 +203,8 @@ class _EndSelectorSelectSetting extends StatefulWidget {
final VoidCallback? onChanged;
final String? help;
@override
State<_EndSelectorSelectSetting> createState() =>
_EndSelectorSelectSettingState();
@@ -176,10 +215,38 @@ class _EndSelectorSelectSettingState extends State<_EndSelectorSelectSetting> {
Widget build(BuildContext context) {
var options = widget.optionTranslation;
return ListTile(
title: Text(widget.title),
title: Row(
children: [
Text(widget.title),
const SizedBox(width: 4),
if (widget.help != null)
Button.icon(
size: 18,
icon: const Icon(Icons.help_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return ContentDialog(
title: "Help".tl,
content: Text(widget.help!).paddingHorizontal(16).fixWidth(double.infinity),
actions: [
Button.filled(
onPressed: context.pop,
child: Text("OK".tl),
),
],
);
},
);
},
),
],
),
trailing: Select(
current: options[appdata.settings[widget.settingKey]]!,
current: options[appdata.settings[widget.settingKey]],
values: options.values.toList(),
minWidth: 64,
onTap: (index) {
setState(() {
appdata.settings[widget.settingKey] = options.keys.elementAt(index);

View File

@@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
@@ -11,10 +12,12 @@ import 'package:venera/foundation/appdata.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/foundation/favorites.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/data.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
import 'package:yaml/yaml.dart';

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:isolate';
import 'package:venera/foundation/app.dart';
@@ -35,7 +36,7 @@ Future<File> exportAppData() async {
return cacheFile;
}
Future<void> importAppData(File file) async {
Future<void> importAppData(File file, [bool checkVersion = false]) async {
var cacheDirPath = FilePath.join(App.cachePath, 'temp_data');
var cacheDir = Directory(cacheDirPath);
await Isolate.run(() {
@@ -44,14 +45,21 @@ Future<void> importAppData(File file) async {
var historyFile = cacheDir.joinFile("history.db");
var localFavoriteFile = cacheDir.joinFile("local_favorite.db");
var appdataFile = cacheDir.joinFile("appdata.json");
if(checkVersion && appdataFile.existsSync()) {
var data = jsonDecode(await appdataFile.readAsString());
var version = data["settings"]["dataVersion"];
if(version is int && version <= appdata.settings["dataVersion"]) {
return;
}
}
if(await historyFile.exists()) {
HistoryManager().close();
await historyFile.copy(FilePath.join(App.dataPath, "history.db"));
historyFile.copySync(FilePath.join(App.dataPath, "history.db"));
HistoryManager().init();
}
if(await localFavoriteFile.exists()) {
LocalFavoritesManager().close();
await localFavoriteFile.copy(FilePath.join(App.dataPath, "local_favorite.db"));
localFavoriteFile.copySync(FilePath.join(App.dataPath, "local_favorite.db"));
LocalFavoritesManager().init();
}
if(await appdataFile.exists()) {

205
lib/utils/data_sync.dart Normal file
View File

@@ -0,0 +1,205 @@
import 'package:dio/io.dart';
import 'package:flutter/foundation.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/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/data.dart';
import 'package:venera/utils/ext.dart';
import 'package:webdav_client/webdav_client.dart' hide File;
import 'io.dart';
class DataSync with ChangeNotifier {
DataSync._() {
if (isEnabled) {
downloadData();
}
HistoryManager().addListener(onDataChanged);
LocalFavoritesManager().addListener(onDataChanged);
ComicSource.addListener(onDataChanged);
}
void onDataChanged() {
if (isEnabled) {
uploadData();
}
}
static DataSync? instance;
factory DataSync() => instance ?? (instance = DataSync._());
bool isDownloading = false;
bool isUploading = false;
bool haveWaitingTask = false;
bool get isEnabled {
var config = appdata.settings['webdav'];
return config is List && config.isNotEmpty;
}
List<String>? _validateConfig() {
var config = appdata.settings['webdav'];
if (config is! List || (config.isNotEmpty && config.length != 3)) {
return null;
}
if (config.whereType<String>().length != 3) {
return null;
}
return List.from(config);
}
Future<Res<bool>> uploadData() async {
if (haveWaitingTask) return const Res(true);
while (isDownloading || isUploading) {
haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
isUploading = true;
notifyListeners();
try {
var config = _validateConfig();
if (config == null) {
return const Res.error('Invalid WebDAV configuration');
}
if (config.isEmpty) {
return const Res(true);
}
String url = config[0];
String user = config[1];
String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient(
url,
user: user,
password: pass,
adapter: IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
),
);
try {
await client.ping();
} catch (e) {
Log.error("Upload Data", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try {
appdata.settings['dataVersion']++;
await appdata.saveData();
var data = await exportAppData();
var time =
(DateTime.now().millisecondsSinceEpoch ~/ 86400000).toString();
var filename = time;
filename += '-';
filename += appdata.settings['dataVersion'].toString();
filename += '.venera';
var files = await client.readDir('/');
files = files.where((e) => e.name!.endsWith('.venera')).toList();
var old = files.firstWhereOrNull( (e) => e.name!.startsWith("$time-"));
if (old != null) {
await client.remove(old.name!);
}
if (files.length >= 10) {
files.sort((a, b) => a.name!.compareTo(b.name!));
await client.remove(files.first.name!);
}
await client.write(filename, await data.readAsBytes());
Log.info("Upload Data", "Data uploaded successfully");
return const Res(true);
} catch (e, s) {
Log.error("Upload Data", e, s);
return Res.error(e.toString());
}
} finally {
isUploading = false;
notifyListeners();
}
}
Future<Res<bool>> downloadData() async {
if (haveWaitingTask) return const Res(true);
while (isDownloading || isUploading) {
haveWaitingTask = true;
await Future.delayed(const Duration(milliseconds: 100));
}
haveWaitingTask = false;
isDownloading = true;
notifyListeners();
try {
var config = _validateConfig();
if (config == null) {
return const Res.error('Invalid WebDAV configuration');
}
if (config.isEmpty) {
return const Res(true);
}
String url = config[0];
String user = config[1];
String pass = config[2];
var proxy = await AppDio.getProxy();
var client = newClient(
url,
user: user,
password: pass,
adapter: IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()
..findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
},
),
);
try {
await client.ping();
} catch (e) {
Log.error("Data Sync", 'Failed to connect to WebDAV server');
return const Res.error('Failed to connect to WebDAV server');
}
try {
var files = await client.readDir('/');
files.sort((a, b) => b.name!.compareTo(a.name!));
var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera'));
var version =
file!.name!.split('-').elementAtOrNull(1)?.split('.').first;
if (version != null && int.tryParse(version) != null) {
var currentVersion = appdata.settings['dataVersion'];
if (currentVersion != null && int.parse(version) <= currentVersion) {
Log.info("Data Sync", 'No new data to download');
return const Res(true);
}
}
Log.info("Data Sync", "Downloading data from WebDAV server");
var localFile = File(FilePath.join(App.cachePath, file.name!));
await client.read2File(file.name!, localFile.path);
await importAppData(localFile, true);
await localFile.delete();
Log.info("Data Sync", "Data downloaded successfully");
return const Res(true);
} catch (e, s) {
Log.error("Data Sync", e, s);
return Res.error(e.toString());
}
} finally {
isDownloading = false;
notifyListeners();
}
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'package:flutter/services.dart';
import 'package:flutter_file_dialog/flutter_file_dialog.dart';
@@ -113,6 +114,12 @@ Future<void> copyDirectory(Directory source, Directory destination) async {
}
}
Future<void> copyDirectoryIsolate(Directory source, Directory destination) async {
await Isolate.run(() {
copyDirectory(source, destination);
});
}
String findValidDirectoryName(String path, String directory) {
var name = sanitizeFileName(directory);
var dir = Directory("$path/$name");

31
lib/utils/volume.dart Normal file
View File

@@ -0,0 +1,31 @@
import 'dart:async';
import 'package:flutter/services.dart';
class VolumeListener {
static const channel = EventChannel('venera/volume');
void Function()? onUp;
void Function()? onDown;
VolumeListener({this.onUp, this.onDown});
StreamSubscription? stream;
void listen() {
stream = channel.receiveBroadcastStream().listen(onEvent);
}
void onEvent(event) {
if (event == 1) {
onUp!();
} else if (event == 2) {
onDown!();
}
}
void cancel() {
stream?.cancel();
}
}

View File

@@ -10,7 +10,7 @@
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_qjs/flutter_qjs_plugin.h>
#include <gtk/gtk_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <screen_retriever_linux/screen_retriever_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
#include <window_manager/window_manager_plugin.h>
@@ -28,9 +28,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) gtk_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin");
gtk_plugin_register_with_registrar(gtk_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin");
screen_retriever_plugin_register_with_registrar(screen_retriever_registrar);
g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin");
screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar);
g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin");
sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar);

View File

@@ -7,7 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_qjs
gtk
screen_retriever
screen_retriever_linux
sqlite3_flutter_libs
url_launcher_linux
window_manager

View File

@@ -10,7 +10,7 @@ import desktop_webview_window
import file_selector_macos
import flutter_inappwebview_macos
import path_provider_foundation
import screen_retriever
import screen_retriever_macos
import share_plus
import sqlite3_flutter_libs
import url_launcher_macos
@@ -22,7 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View File

@@ -593,6 +593,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb"
url: "https://pub.dev"
source: hosted
version: "11.3.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1"
url: "https://pub.dev"
source: hosted
version: "12.0.13"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: e6f6d73b12438ef13e648c4ae56bd106ec60d17e90a59c4545db6781229082a0
url: "https://pub.dev"
source: hosted
version: "9.4.5"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851
url: "https://pub.dev"
source: hosted
version: "0.1.3+2"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9
url: "https://pub.dev"
source: hosted
version: "4.2.3"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "6.0.2"
photo_view:
dependency: "direct main"
description:
@@ -888,6 +944,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
webdav_client:
dependency: "direct main"
description:
path: "."
ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
resolved-ref: "285f87f15bccd2d5d5ff443761348c6ee47b98d1"
url: "https://github.com/wgh136/webdav_client"
source: git
version: "1.2.2"
win32:
dependency: transitive
description:
@@ -912,6 +977,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml:
dependency: "direct main"
description:

View File

@@ -2,7 +2,7 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.0.3+103
version: 1.0.4+104
environment:
sdk: '>=3.5.0 <4.0.0'
@@ -59,6 +59,11 @@ dependencies:
url: https://github.com/venera-app/lodepng_flutter
ref: d1c96cd6503103b3270dfe2f320d4a1c93780f53
rhttp: 0.9.1
webdav_client:
git:
url: https://github.com/wgh136/webdav_client
ref: 285f87f15bccd2d5d5ff443761348c6ee47b98d1
permission_handler: ^11.3.1
dev_dependencies:
flutter_test:

View File

@@ -11,7 +11,8 @@
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_qjs/flutter_qjs_plugin.h>
#include <screen_retriever/screen_retriever_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
@@ -28,8 +29,10 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
FlutterQjsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterQjsPlugin"));
ScreenRetrieverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(

View File

@@ -8,7 +8,8 @@ list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_inappwebview_windows
flutter_qjs
screen_retriever
permission_handler_windows
screen_retriever_windows
share_plus
sqlite3_flutter_libs
url_launcher_windows