settings page

This commit is contained in:
nyne
2024-10-11 21:47:50 +08:00
parent f228c7ee17
commit a26e5e20de
21 changed files with 1515 additions and 26 deletions

BIN
assets/app_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -83,7 +83,7 @@ class _AppbarState extends State<Appbar> {
message: "Back".tl, message: "Back".tl,
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.maybePop(context),
), ),
), ),
const SizedBox( const SizedBox(
@@ -187,7 +187,7 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
message: "Back".tl, message: "Back".tl,
child: IconButton( child: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.maybePop(context),
), ),
) )
: const SizedBox()), : const SizedBox()),
@@ -322,7 +322,9 @@ class _FilledTabBarState extends State<FilledTabBar> {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
controller: controller, controller: controller,
physics: physics, physics: physics is BouncingScrollPhysics
? const ClampingScrollPhysics()
: physics,
child: CustomPaint( child: CustomPaint(
painter: painter, painter: painter,
child: _TabRow( child: _TabRow(

View File

@@ -60,7 +60,7 @@ class Select extends StatelessWidget {
children: [ children: [
Text(current, style: ts.s14), Text(current, style: ts.s14),
const SizedBox(width: 8), const SizedBox(width: 8),
const Icon(Icons.arrow_drop_down), Icon(Icons.arrow_drop_down, color: context.colorScheme.primary),
], ],
).padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 4)), ).padding(const EdgeInsets.symmetric(horizontal: 12, vertical: 4)),
), ),

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:path_provider/path_provider.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
@@ -11,7 +12,7 @@ class _Appdata {
bool _isSavingData = false; bool _isSavingData = false;
Future<void> saveData() async { Future<void> saveData() async {
if(_isSavingData) { if (_isSavingData) {
await Future.doWhile(() async { await Future.doWhile(() async {
await Future.delayed(const Duration(milliseconds: 20)); await Future.delayed(const Duration(milliseconds: 20));
return _isSavingData; return _isSavingData;
@@ -25,11 +26,11 @@ class _Appdata {
} }
void addSearchHistory(String keyword) { void addSearchHistory(String keyword) {
if(searchHistory.contains(keyword)) { if (searchHistory.contains(keyword)) {
searchHistory.remove(keyword); searchHistory.remove(keyword);
} }
searchHistory.insert(0, keyword); searchHistory.insert(0, keyword);
if(searchHistory.length > 50) { if (searchHistory.length > 50) {
searchHistory.removeLast(); searchHistory.removeLast();
} }
saveData(); saveData();
@@ -46,13 +47,16 @@ class _Appdata {
} }
Future<void> init() async { Future<void> init() async {
var file = File(FilePath.join(App.dataPath, 'appdata.json')); var file = File(FilePath.join(
if(!await file.exists()) { (await getApplicationSupportDirectory()).path,
'appdata.json',
));
if (!await file.exists()) {
return; return;
} }
var json = jsonDecode(await file.readAsString()); var json = jsonDecode(await file.readAsString());
for(var key in (json['settings'] as Map<String, dynamic>).keys) { for (var key in (json['settings'] as Map<String, dynamic>).keys) {
if(json['settings'][key] != null) { if (json['settings'][key] != null) {
settings[key] = json['settings'][key]; settings[key] = json['settings'][key];
} }
} }
@@ -74,7 +78,7 @@ class _Settings {
final _data = <String, dynamic>{ final _data = <String, dynamic>{
'comicDisplayMode': 'detailed', // detailed, brief 'comicDisplayMode': 'detailed', // detailed, brief
'comicTileScale': 1.0, // 0.8-1.2 'comicTileScale': 1.00, // 0.75-1.25
'color': 'blue', // red, pink, purple, green, orange, blue 'color': 'blue', // red, pink, purple, green, orange, blue
'theme_mode': 'system', // light, dark, system 'theme_mode': 'system', // light, dark, system
'newFavoriteAddTo': 'end', // start, end 'newFavoriteAddTo': 'end', // start, end
@@ -91,13 +95,14 @@ class _Settings {
'readerMode': 'galleryLeftToRight', // values of [ReaderMode] 'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
'enableTapToTurnPages': true, 'enableTapToTurnPages': true,
'enablePageAnimation': true, 'enablePageAnimation': true,
'language': 'system', // system, zh-CN, zh-TW, en-US
}; };
operator[](String key) { operator [](String key) {
return _data[key]; return _data[key];
} }
operator[]=(String key, dynamic value) { operator []=(String key, dynamic value) {
_data[key] = value; _data[key] = value;
} }

View File

@@ -100,6 +100,29 @@ class LocalManager with ChangeNotifier {
late String path; late String path;
// return error message if failed
Future<String?> setNewPath(String newPath) async {
var newDir = Directory(newPath);
if(!await newDir.exists()) {
return "Directory does not exist";
}
if(!await newDir.list().isEmpty) {
return "Directory is not empty";
}
try {
await copyDirectory(
Directory(path),
newDir,
);
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(path);
} catch (e) {
return e.toString();
}
await Directory(path).deleteIgnoreError();
path = newPath;
return null;
}
Future<void> init() async { Future<void> init() async {
_db = sqlite3.open( _db = sqlite3.open(
'${App.dataPath}/local.db', '${App.dataPath}/local.db',
@@ -118,18 +141,18 @@ class LocalManager with ChangeNotifier {
PRIMARY KEY (id, comic_type) PRIMARY KEY (id, comic_type)
); );
'''); ''');
if (File('${App.dataPath}/local_path').existsSync()) { if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
path = File('${App.dataPath}/local_path').readAsStringSync(); path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
} else { } else {
if (App.isAndroid) { if (App.isAndroid) {
var external = await getExternalStorageDirectories(); var external = await getExternalStorageDirectories();
if (external != null && external.isNotEmpty) { if (external != null && external.isNotEmpty) {
path = '${external.first.path}/local'; path = FilePath.join(external.first.path, 'local_path');
} else { } else {
path = '${App.dataPath}/local'; path = FilePath.join(App.dataPath, 'local_path');
} }
} else { } else {
path = '${App.dataPath}/local'; path = FilePath.join(App.dataPath, 'local_path');
} }
} }
if (!Directory(path).existsSync()) { if (!Directory(path).existsSync()) {

View File

@@ -12,8 +12,8 @@ import 'foundation/appdata.dart';
Future<void> init() async { Future<void> init() async {
await AppTranslation.init(); await AppTranslation.init();
await App.init();
await appdata.init(); await appdata.init();
await App.init();
await HistoryManager().init(); await HistoryManager().init();
await LocalManager().init(); await LocalManager().init();
await LocalFavoritesManager().init(); await LocalFavoritesManager().init();

View File

@@ -104,6 +104,18 @@ class _MyAppState extends State<MyApp> {
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
locale: () {
var lang = appdata.settings['language'];
if(lang == 'system') {
return null;
}
return switch(lang) {
'zh-CN' => const Locale('zh', 'CN'),
'zh-TW' => const Locale('zh', 'TW'),
'en-US' => const Locale('en'),
_ => null
};
}(),
supportedLocales: const [ supportedLocales: const [
Locale('en'), Locale('en'),
Locale('zh', 'CN'), Locale('zh', 'CN'),

View File

@@ -112,7 +112,7 @@ class AppDio with DioMixin {
static HttpClient createHttpClient() { static HttpClient createHttpClient() {
final client = HttpClient(); final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 5); client.connectionTimeout = const Duration(seconds: 5);
client.findProxy = (uri) => proxy ?? "DIRECT"; client.findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
client.idleTimeout = const Duration(seconds: 100); client.idleTimeout = const Duration(seconds: 100);
client.badCertificateCallback = (X509Certificate cert, String host, int port) { client.badCertificateCallback = (X509Certificate cert, String host, int port) {
if (host.contains("cdn")) return true; if (host.contains("cdn")) return true;

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/pages/categories_page.dart'; import 'package:venera/pages/categories_page.dart';
import 'package:venera/pages/search_page.dart'; import 'package:venera/pages/search_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import '../components/components.dart'; import '../components/components.dart';
@@ -89,7 +90,9 @@ class _MainPageState extends State<MainPage> {
PaneActionEntry( PaneActionEntry(
icon: Icons.settings, icon: Icons.settings,
label: "Settings".tl, label: "Settings".tl,
onTap: () {}, onTap: () {
to(() => const SettingsPage());
},
) )
], ],
pageBuilder: (index) { pageBuilder: (index) {

View File

@@ -0,0 +1,121 @@
part of 'settings_page.dart';
class AboutSettings extends StatefulWidget {
const AboutSettings({super.key});
@override
State<AboutSettings> createState() => _AboutSettingsState();
}
class _AboutSettingsState extends State<AboutSettings> {
bool isCheckingUpdate = false;
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("About".tl)),
SizedBox(
height: 136,
width: double.infinity,
child: Center(
child: Container(
width: 136,
height: 136,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(136),
),
clipBehavior: Clip.antiAlias,
child: const Image(
image: AssetImage("assets/app_icon.png"),
filterQuality: FilterQuality.medium,
),
),
),
).paddingTop(16).toSliver(),
Column(
children: [
const SizedBox(height: 8),
Text(
"V${App.version}",
style: const TextStyle(fontSize: 16),
),
Text("Venera is a free and open-source app for comic reading.".tl),
const SizedBox(height: 8),
],
).toSliver(),
ListTile(
title: Text("Check for updates".tl),
trailing: Button.filled(
isLoading: isCheckingUpdate,
child: Text("Check".tl),
onPressed: () {
setState(() {
isCheckingUpdate = true;
});
checkUpdate().then((value) {
if (value) {
showDialog(
context: App.rootContext,
builder: (context) {
return ContentDialog(
title: "New version available".tl,
content: Text(
"A new version is available. Do you want to update now?"
.tl),
actions: [
Button.text(
onPressed: () {
Navigator.pop(context);
launchUrlString(
"https://github.com/venera-app/venera/releases");
},
child: Text("Update".tl),
),
]);
});
} else {
context.showMessage(message: "No new version available".tl);
}
setState(() {
isCheckingUpdate = false;
});
});
},
).fixHeight(32),
).toSliver(),
ListTile(
title: const Text("Github"),
trailing: const Icon(Icons.open_in_new),
onTap: () {
launchUrlString("https://github.com/venera-app/venera");
},
).toSliver(),
],
);
}
}
Future<bool> checkUpdate() async {
var res = await AppDio().get(
"https://raw.githubusercontent.com/venera-app/venera/refs/heads/master/pubspec.yaml");
if (res.statusCode == 200) {
var data = loadYaml(res.data);
if (data["version"] != null) {
return _compareVersion(data["version"].split("+")[0], App.version);
}
}
return false;
}
/// return true if version1 > version2
bool _compareVersion(String version1, String version2) {
var v1 = version1.split(".");
var v2 = version2.split(".");
for (var i = 0; i < v1.length; i++) {
if (int.parse(v1[i]) > int.parse(v2[i])) {
return true;
}
}
return false;
}

219
lib/pages/settings/app.dart Normal file
View File

@@ -0,0 +1,219 @@
part of 'settings_page.dart';
class AppSettings extends StatefulWidget {
const AppSettings({super.key});
@override
State<AppSettings> createState() => _AppSettingsState();
}
class _AppSettingsState extends State<AppSettings> {
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("App".tl)),
_SettingPartTitle(
title: "Data".tl,
icon: Icons.storage,
),
ListTile(
title: Text("Storage Path for local comics".tl),
subtitle: Text(LocalManager().path, softWrap: false),
).toSliver(),
_CallbackSetting(
title: "Set New Storage Path".tl,
actionTitle: "Set".tl,
callback: () async {
var picker = FilePicker.platform;
var result = await picker.getDirectoryPath();
if (result == null) return;
var loadingDialog = showLoadingDialog(
App.rootContext,
barrierDismissible: false,
allowCancel: false,
);
var res = await LocalManager().setNewPath(result);
loadingDialog.close();
if (res != null) {
context.showMessage(message: res);
} else {
context.showMessage(message: "Path set successfully".tl);
setState(() {});
}
},
).toSliver(),
ListTile(
title: Text("Cache Size".tl),
subtitle: Text(bytesToReadableString(CacheManager().currentSize)),
).toSliver(),
_CallbackSetting(
title: "Clear Cache".tl,
actionTitle: "Clear".tl,
callback: () async {
var loadingDialog = showLoadingDialog(
App.rootContext,
barrierDismissible: false,
allowCancel: false,
);
await CacheManager().clear();
loadingDialog.close();
context.showMessage(message: "Cache cleared".tl);
setState(() {});
},
).toSliver(),
_SettingPartTitle(
title: "Log".tl,
icon: Icons.error_outline,
),
_CallbackSetting(
title: "Open Log".tl,
callback: () {
context.to(() => const LogsPage());
},
actionTitle: 'Open'.tl,
).toSliver(),
_SettingPartTitle(
title: "User".tl,
icon: Icons.person_outline,
),
SelectSetting(
title: "Language".tl,
settingKey: "language",
optionTranslation: const {
"system": "System",
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"en-US": "English",
},
onChanged: () {
App.forceRebuild();
},
).toSliver(),
],
);
}
}
class LogsPage extends StatefulWidget {
const LogsPage({super.key});
@override
State<LogsPage> createState() => _LogsPageState();
}
class _LogsPageState extends State<LogsPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: const Text("Logs"),
actions: [
IconButton(
onPressed: () => setState(() {
final RelativeRect position = RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
MediaQuery.of(context).padding.top + kToolbarHeight,
0.0,
0.0,
);
showMenu(context: context, position: position, items: [
PopupMenuItem(
child: Text("Clear".tl),
onTap: () => setState(() => Log.clear()),
),
PopupMenuItem(
child: Text("Disable Length Limitation".tl),
onTap: () {
Log.ignoreLimitation = true;
context.showMessage(
message: "Only valid for this run");
},
),
PopupMenuItem(
child: Text("Export".tl),
onTap: () => saveLog(Log().toString()),
),
]);
}),
icon: const Icon(Icons.more_horiz))
],
),
body: ListView.builder(
reverse: true,
controller: ScrollController(),
itemCount: Log.logs.length,
itemBuilder: (context, index) {
index = Log.logs.length - index - 1;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: SelectionArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius:
const BorderRadius.all(Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(Log.logs[index].title),
),
),
const SizedBox(
width: 3,
),
Container(
decoration: BoxDecoration(
color: [
Theme.of(context).colorScheme.error,
Theme.of(context).colorScheme.errorContainer,
Theme.of(context).colorScheme.primaryContainer
][Log.logs[index].level.index],
borderRadius:
const BorderRadius.all(Radius.circular(16)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(5, 0, 5, 1),
child: Text(
Log.logs[index].level.name,
style: TextStyle(
color: Log.logs[index].level.index == 0
? Colors.white
: Colors.black),
),
),
),
],
),
Text(Log.logs[index].content),
Text(Log.logs[index].time
.toString()
.replaceAll(RegExp(r"\.\w+"), "")),
TextButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: Log.logs[index].content));
},
child: Text("Copy".tl),
),
const Divider(),
],
),
),
);
},
),
);
}
void saveLog(String log) async {
saveFile(data: utf8.encode(log), filename: 'log.txt');
}
}

View File

@@ -0,0 +1,44 @@
part of 'settings_page.dart';
class AppearanceSettings extends StatefulWidget {
const AppearanceSettings({super.key});
@override
State<AppearanceSettings> createState() => _AppearanceSettingsState();
}
class _AppearanceSettingsState extends State<AppearanceSettings> {
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Appearance".tl)),
SelectSetting(
title: "Theme Mode".tl,
settingKey: "theme_mode",
optionTranslation: {
"system": "System".tl,
"light": "Light".tl,
"dark": "Dark".tl,
},
).toSliver(),
SelectSetting(
title: "Theme Color".tl,
settingKey: "color",
optionTranslation: {
"red": "Red".tl,
"pink": "Pink".tl,
"purple": "Purple".tl,
"green": "Green".tl,
"orange": "Orange".tl,
"blue": "Blue".tl,
},
onChanged: () async {
await App.init();
App.forceRebuild();
},
).toSliver(),
],
);
}
}

View File

@@ -0,0 +1,182 @@
part of 'settings_page.dart';
class ExploreSettings extends StatefulWidget {
const ExploreSettings({super.key});
@override
State<ExploreSettings> createState() => _ExploreSettingsState();
}
class _ExploreSettingsState extends State<ExploreSettings> {
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Explore".tl)),
SelectSetting(
title: "Display mode of comic tile".tl,
settingKey: "comicDisplayMode",
optionTranslation: {
"detailed": "Detailed".tl,
"brief": "Brief".tl,
},
).toSliver(),
_SliderSetting(
title: "Size of comic tile".tl,
settingsIndex: "comicTileScale",
interval: 0.05,
min: 0.75,
max: 1.25,
).toSliver(),
_PopupWindowSetting(
title: "Explore Pages".tl,
builder: () {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
for (var page in c.explorePages) {
pages[page.title] = page.title;
}
}
return _MultiPagesFilter(
title: "Explore Pages".tl,
settingsIndex: "explore_pages",
pages: pages,
);
},
).toSliver(),
_PopupWindowSetting(
title: "Category Pages".tl,
builder: () {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.categoryData != null) {
pages[c.categoryData!.key] = c.categoryData!.title;
}
}
return _MultiPagesFilter(
title: "Category Pages".tl,
settingsIndex: "categories",
pages: pages,
);
},
).toSliver(),
_PopupWindowSetting(
title: "Explore Pages".tl,
builder: () {
var pages = <String, String>{};
for (var c in ComicSource.all()) {
if (c.favoriteData != null) {
pages[c.favoriteData!.key] = c.favoriteData!.title;
}
}
return _MultiPagesFilter(
title: "Network Favorite Pages".tl,
settingsIndex: "favorites",
pages: pages,
);
},
).toSliver(),
_SwitchSetting(
title: "Show favorite status on comic tile".tl,
settingKey: "showFavoriteStatusOnTile",
).toSliver(),
_SwitchSetting(
title: "Show history on comic tile".tl,
settingKey: "showHistoryStatusOnTile",
).toSliver(),
_PopupWindowSetting(
title: "Keyword blocking".tl,
builder: () => const _ManageBlockingWordView(),
).toSliver(),
],
);
}
}
class _ManageBlockingWordView extends StatefulWidget {
const _ManageBlockingWordView({super.key});
@override
State<_ManageBlockingWordView> createState() =>
_ManageBlockingWordViewState();
}
class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> {
@override
Widget build(BuildContext context) {
assert(appdata.settings["blockedWords"] is List);
return PopUpWidgetScaffold(
title: "Keyword blocking".tl,
tailing: [
IconButton(
icon: const Icon(Icons.add),
onPressed: add,
),
],
body: ListView.builder(
itemCount: appdata.settings["blockedWords"].length,
itemBuilder: (context, index) {
return ListTile(
title: Text(appdata.settings["blockedWords"][index]),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
appdata.settings["blockedWords"].removeAt(index);
appdata.saveData();
setState(() {});
},
),
);
},
),
);
}
void add() {
showDialog(
context: App.rootContext,
barrierColor: Colors.black.withOpacity(0.1),
builder: (context) {
var controller = TextEditingController();
String? error;
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Add keyword".tl,
content: TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
label: Text("Keyword".tl),
errorText: error,
),
onChanged: (s) {
if(error != null){
setState(() {
error = null;
});
}
},
).paddingHorizontal(12),
actions: [
Button.filled(
onPressed: () {
if(appdata.settings["blockedWords"].contains(controller.text)){
setState(() {
error = "Keyword already exists".tl;
});
return;
}
appdata.settings["blockedWords"].add(controller.text);
appdata.saveData();
this.setState(() {});
context.pop();
},
child: Text("Add".tl),
),
],
);
});
},
);
}
}

View File

@@ -0,0 +1,36 @@
part of 'settings_page.dart';
class LocalFavoritesSettings extends StatefulWidget {
const LocalFavoritesSettings({super.key});
@override
State<LocalFavoritesSettings> createState() => _LocalFavoritesSettingsState();
}
class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Local Favorites".tl)),
const SelectSetting(
title: "Add new favorite to",
settingKey: "newFavoriteAddTo",
optionTranslation: {
"start": "Start",
"end": "End",
},
).toSliver(),
const SelectSetting(
title: "Move favorite after read",
settingKey: "moveFavoriteAfterRead",
optionTranslation: {
"none": "None",
"end": "End",
"start": "Start",
},
).toSliver(),
],
);
}
}

View File

@@ -0,0 +1,236 @@
part of 'settings_page.dart';
class NetworkSettings extends StatefulWidget {
const NetworkSettings({super.key});
@override
State<NetworkSettings> createState() => _NetworkSettingsState();
}
class _NetworkSettingsState extends State<NetworkSettings> {
@override
Widget build(BuildContext context) {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Network".tl)),
_PopupWindowSetting(
title: "Proxy".tl,
builder: () => const _ProxySettingView(),
).toSliver(),
],
);
}
}
class _ProxySettingView extends StatefulWidget {
const _ProxySettingView();
@override
State<_ProxySettingView> createState() => _ProxySettingViewState();
}
class _ProxySettingViewState extends State<_ProxySettingView> {
String type = '';
String host = '';
String port = '';
String username = '';
String password = '';
// USERNAME:PASSWORD@HOST:PORT
String toProxyStr() {
if(type == 'direct') {
return 'direct';
} else if(type == 'system') {
return 'system';
}
var res = '';
if(username.isNotEmpty) {
res += username;
if(password.isNotEmpty) {
res += ':$password';
}
res += '@';
}
res += host;
if(port.isNotEmpty) {
res += ':$port';
}
return res;
}
void parseProxyString(String proxy) {
if(proxy == 'direct') {
type = 'direct';
return;
} else if(proxy == 'system') {
type = 'system';
return;
}
type = 'manual';
var parts = proxy.split('@');
if(parts.length == 2) {
var auth = parts[0].split(':');
if(auth.length == 2) {
username = auth[0];
password = auth[1];
}
parts = parts[1].split(':');
if(parts.length == 2) {
host = parts[0];
port = parts[1];
}
} else {
parts = proxy.split(':');
if(parts.length == 2) {
host = parts[0];
port = parts[1];
}
}
}
@override
void initState() {
var proxy = appdata.settings['proxy'];
parseProxyString(proxy);
super.initState();
}
@override
Widget build(BuildContext context) {
return PopUpWidgetScaffold(
title: "Proxy".tl,
body: SingleChildScrollView(
child: Column(
children: [
RadioListTile<String>(
title: Text("Direct".tl),
value: 'direct',
groupValue: type,
onChanged: (v) {
setState(() {
type = v!;
});
appdata.settings['proxy'] = toProxyStr();
appdata.saveData();
},
),
RadioListTile<String>(
title: Text("System".tl),
value: 'system',
groupValue: type,
onChanged: (v) {
setState(() {
type = v!;
});
appdata.settings['proxy'] = toProxyStr();
appdata.saveData();
},
),
RadioListTile(
title: Text("Manual".tl),
value: 'manual',
groupValue: type,
onChanged: (v) {
setState(() {
type = v!;
});
},
),
if(type == 'manual') buildManualProxy(),
],
),
),
);
}
var formKey = GlobalKey<FormState>();
Widget buildManualProxy() {
return Form(
key: formKey,
child: Column(
children: [
TextFormField(
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "Host".tl,
),
controller: TextEditingController(text: host),
onChanged: (v) {
host = v;
},
validator: (v) {
if(v?.isEmpty ?? false) {
return "Host cannot be empty".tl;
}
return null;
},
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "Port".tl,
),
controller: TextEditingController(text: port),
onChanged: (v) {
port = v;
},
validator: (v) {
if(v?.isEmpty ?? true) {
return null;
}
if(int.tryParse(v!) == null) {
return "Port must be a number".tl;
}
return null;
},
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "Username".tl,
),
controller: TextEditingController(text: username),
onChanged: (v) {
username = v;
},
validator: (v) {
if((v?.isEmpty ?? false) && password.isNotEmpty) {
return "Username cannot be empty".tl;
}
return null;
},
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: "Password".tl,
),
controller: TextEditingController(text: password),
onChanged: (v) {
password = v;
},
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
if(formKey.currentState?.validate() ?? false) {
appdata.settings['proxy'] = toProxyStr();
appdata.saveData();
App.rootContext.pop();
}
},
child: Text("Save".tl),
),
],
),
).paddingHorizontal(16).paddingTop(16);
}
}

View File

@@ -14,7 +14,7 @@ class _ReaderSettingsState extends State<ReaderSettings> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SmoothCustomScrollView( return SmoothCustomScrollView(
slivers: [ slivers: [
SliverAppbar(title: Text("Settings".tl)), SliverAppbar(title: Text("Reading".tl)),
_SwitchSetting( _SwitchSetting(
title: "Tap to turn Pages".tl, title: "Tap to turn Pages".tl,
settingKey: "enableTapToTurnPages", settingKey: "enableTapToTurnPages",

View File

@@ -251,3 +251,225 @@ class _SliderSettingState extends State<_SliderSetting> {
); );
} }
} }
class _PopupWindowSetting extends StatelessWidget {
const _PopupWindowSetting({required this.title, required this.builder});
final Widget Function() builder;
final String title;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
trailing: const Icon(Icons.arrow_right),
onTap: () {
showPopUpWidget(App.rootContext, builder());
},
);
}
}
class _MultiPagesFilter extends StatefulWidget {
const _MultiPagesFilter({
required this.title,
required this.settingsIndex,
required this.pages,
});
final String title;
final String settingsIndex;
// key - name
final Map<String, String> pages;
@override
State<_MultiPagesFilter> createState() => _MultiPagesFilterState();
}
class _MultiPagesFilterState extends State<_MultiPagesFilter> {
late List<String> keys;
@override
void initState() {
keys = List.from(appdata.settings[widget.settingsIndex]);
keys.remove("");
super.initState();
}
var reorderWidgetKey = UniqueKey();
var scrollController = ScrollController();
final _key = GlobalKey();
@override
Widget build(BuildContext context) {
var tiles = keys.map((e) => buildItem(e)).toList();
var view = ReorderableBuilder(
key: reorderWidgetKey,
scrollController: scrollController,
longPressDelay: App.isDesktop
? const Duration(milliseconds: 100)
: const Duration(milliseconds: 500),
dragChildBoxDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
boxShadow: const [
BoxShadow(
color: Colors.black12,
blurRadius: 5,
offset: Offset(0, 2),
spreadRadius: 2)
],
),
onReorder: (reorderFunc) {
setState(() {
keys = List.from(reorderFunc(keys));
});
updateSetting();
},
children: tiles,
builder: (children) {
return GridView(
key: _key,
controller: scrollController,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 1,
mainAxisExtent: 48,
),
children: children,
);
},
);
return PopUpWidgetScaffold(
title: widget.title,
tailing: [
if (keys.length < widget.pages.length)
IconButton(onPressed: showAddDialog, icon: const Icon(Icons.add))
],
body: view,
);
}
Widget buildItem(String key) {
Widget removeButton = Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
onPressed: () {
setState(() {
keys.remove(key);
});
updateSetting();
},
icon: const Icon(Icons.delete)),
);
return ListTile(
title: Text(widget.pages[key] ?? "(Invalid) $key"),
key: Key(key),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
removeButton,
const Icon(Icons.drag_handle),
],
),
);
}
void showAddDialog() {
var canAdd = <String, String>{};
widget.pages.forEach((key, value) {
if (!keys.contains(key)) {
canAdd[key] = value;
}
});
showDialog(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("Add"),
children: canAdd.entries
.map((e) => InkWell(
child: ListTile(title: Text(e.value), key: Key(e.key)),
onTap: () {
context.pop();
setState(() {
keys.add(e.key);
});
updateSetting();
},
))
.toList(),
);
});
}
void updateSetting() {
appdata.settings[widget.settingsIndex] = keys;
appdata.saveData();
}
}
class _CallbackSetting extends StatelessWidget {
const _CallbackSetting({
required this.title,
required this.callback,
required this.actionTitle,
this.subtitle,
});
final String title;
final String? subtitle;
final VoidCallback callback;
final String actionTitle;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
trailing: FilledButton(
onPressed: callback,
child: Text(actionTitle),
).fixHeight(28),
onTap: callback,
);
}
}
class _SettingPartTitle extends StatelessWidget {
const _SettingPartTitle({required this.title, required this.icon});
final String title;
final IconData icon;
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 8),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.onSurface.withOpacity(0.1),
),
),
),
child: Row(
children: [
Icon(icon, size: 24),
const SizedBox(width: 8),
Text(title, style: ts.s18),
],
),
),
);
}
}

View File

@@ -1,8 +1,361 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; 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/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/app_dio.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'package:yaml/yaml.dart';
part 'reader.dart'; part 'reader.dart';
part 'explore_settings.dart';
part 'setting_components.dart'; part 'setting_components.dart';
part 'appearance.dart';
part 'local_favorites.dart';
part 'app.dart';
part 'about.dart';
part 'network.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({this.initialPage = -1, super.key});
final int initialPage;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> implements PopEntry {
int currentPage = -1;
ColorScheme get colors => Theme.of(context).colorScheme;
bool get enableTwoViews => context.width > changePoint;
final categories = <String>[
"Explore",
"Reading",
"Appearance",
"Local Favorites",
"APP",
"Network",
"About",
];
final icons = <IconData>[
Icons.explore,
Icons.book,
Icons.color_lens,
Icons.collections_bookmark_rounded,
Icons.apps,
Icons.public,
Icons.info
];
double offset = 0;
late final HorizontalDragGestureRecognizer gestureRecognizer;
ModalRoute? _route;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final ModalRoute<dynamic>? nextRoute = ModalRoute.of(context);
if (nextRoute != _route) {
_route?.unregisterPopEntry(this);
_route = nextRoute;
_route?.registerPopEntry(this);
}
}
@override
void initState() {
currentPage = widget.initialPage;
gestureRecognizer = HorizontalDragGestureRecognizer(debugOwner: this)
..onUpdate = ((details) => setState(() => offset += details.delta.dx))
..onEnd = (details) async {
if (details.velocity.pixelsPerSecond.dx.abs() > 1 &&
details.velocity.pixelsPerSecond.dx >= 0) {
setState(() {
Future.delayed(const Duration(milliseconds: 300), () => offset = 0);
currentPage = -1;
});
} else if (offset > MediaQuery.of(context).size.width / 2) {
setState(() {
Future.delayed(const Duration(milliseconds: 300), () => offset = 0);
currentPage = -1;
});
} else {
int i = 10;
while (offset != 0) {
setState(() {
offset -= i;
i *= 10;
if (offset < 0) {
offset = 0;
}
});
await Future.delayed(const Duration(milliseconds: 10));
}
}
}
..onCancel = () async {
int i = 10;
while (offset != 0) {
setState(() {
offset -= i;
i *= 10;
if (offset < 0) {
offset = 0;
}
});
await Future.delayed(const Duration(milliseconds: 10));
}
};
super.initState();
}
@override
dispose() {
super.dispose();
gestureRecognizer.dispose();
_route?.unregisterPopEntry(this);
}
@override
Widget build(BuildContext context) {
if (currentPage != -1) {
canPop.value = false;
} else {
canPop.value = true;
}
return Material(
child: buildBody(),
);
}
Widget buildBody() {
if (enableTwoViews) {
return Row(
children: [
SizedBox(
width: 280,
height: double.infinity,
child: buildLeft(),
),
Container(
height: double.infinity,
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
),
Expanded(child: buildRight())
],
);
} else {
return Stack(
children: [
Positioned.fill(child: buildLeft()),
Positioned(
left: offset,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Listener(
onPointerDown: handlePointerDown,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
reverseDuration: const Duration(milliseconds: 300),
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
transitionBuilder: (child, animation) {
var tween = Tween<Offset>(
begin: const Offset(1, 0), end: const Offset(0, 0));
return SlideTransition(
position: tween.animate(animation),
child: child,
);
},
child: currentPage == -1
? const SizedBox(
key: Key("1"),
)
: buildRight(),
),
),
)
],
);
}
}
void handlePointerDown(PointerDownEvent event) {
if (event.position.dx < 20) {
gestureRecognizer.addPointer(event);
}
}
Widget buildLeft() {
return Material(
child: Column(
children: [
SizedBox(
height: MediaQuery.of(context).padding.top,
),
SizedBox(
height: 56,
child: Row(children: [
const SizedBox(
width: 8,
),
Tooltip(
message: "Back",
child: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: context.pop,
),
),
const SizedBox(
width: 24,
),
Text(
"Settings".tl,
style: ts.s20,
)
]),
),
const SizedBox(
height: 4,
),
Expanded(
child: buildCategories(),
)
],
),
);
}
Widget buildCategories() {
Widget buildItem(String name, int id) {
final bool selected = id == currentPage;
Widget content = AnimatedContainer(
key: ValueKey(id),
duration: const Duration(milliseconds: 200),
width: double.infinity,
height: 46,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 0),
decoration: BoxDecoration(
color: selected ? colors.primaryContainer.withOpacity(0.36) : null,
border: Border(
left: BorderSide(
color: selected ? colors.primary : Colors.transparent,
width: 2,
),
),
),
child: Row(children: [
Icon(icons[id]),
const SizedBox(width: 16),
Text(
name,
style: ts.s16,
),
const Spacer(),
if (selected) const Icon(Icons.arrow_right)
]),
);
return Padding(
padding: enableTwoViews
? const EdgeInsets.fromLTRB(8, 0, 8, 0)
: EdgeInsets.zero,
child: InkWell(
onTap: () => setState(() => currentPage = id),
child: content,
).paddingVertical(4),
);
}
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: categories.length,
itemBuilder: (context, index) => buildItem(categories[index].tl, index),
);
}
Widget buildRight() {
final Widget body = switch (currentPage) {
-1 => const SizedBox(),
0 => const ExploreSettings(),
1 => const ReaderSettings(),
2 => const AppearanceSettings(),
3 => const LocalFavoritesSettings(),
4 => const AppSettings(),
5 => const NetworkSettings(),
6 => const AboutSettings(),
_ => throw UnimplementedError()
};
return Material(
child: body,
);
}
var canPop = ValueNotifier(true);
@override
ValueListenable<bool> get canPopNotifier => canPop;
/*
flutter >=3.24.0 api
@override
void onPopInvokedWithResult(bool didPop, result) {
if (currentPage != -1) {
setState(() {
currentPage = -1;
});
}
}
@override
void onPopInvoked(bool didPop) {
if (currentPage != -1) {
setState(() {
currentPage = -1;
});
}
}
*/
// flutter <3.24.0 api
@override
PopInvokedCallback? get onPopInvoked => (bool didPop) {
if (currentPage != -1) {
setState(() {
currentPage = -1;
});
}
};
}

View File

@@ -186,3 +186,15 @@ class Share {
s.Share.share(text); s.Share.share(text);
} }
} }
String bytesToReadableString(int bytes) {
if (bytes < 1024) {
return "$bytes B";
} else if (bytes < 1024 * 1024) {
return "${(bytes / 1024).toStringAsFixed(2)} KB";
} else if (bytes < 1024 * 1024 * 1024) {
return "${(bytes / 1024 / 1024).toStringAsFixed(2)} MB";
} else {
return "${(bytes / 1024 / 1024 / 1024).toStringAsFixed(2)} GB";
}
}

View File

@@ -164,6 +164,14 @@ packages:
url: "https://github.com/wgh136/flutter_qjs" url: "https://github.com/wgh136/flutter_qjs"
source: git source: git
version: "0.3.7" version: "0.3.7"
flutter_reorderable_grid_view:
dependency: "direct main"
description:
name: flutter_reorderable_grid_view
sha256: "40abcc5bff228ebff119326502e7357ee6399956b60b80b17385e9770b7458c0"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@@ -597,6 +605,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.4"
yaml:
dependency: "direct main"
description:
name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
sdks: sdks:
dart: ">=3.4.4 <4.0.0" dart: ">=3.4.4 <4.0.0"
flutter: ">=3.22.3" flutter: ">=3.22.3"

View File

@@ -40,6 +40,8 @@ dependencies:
url: https://github.com/venera-app/flutter.widgets url: https://github.com/venera-app/flutter.widgets
ref: 09e756b1f1b04e6298318d99ec20a787fb360f59 ref: 09e756b1f1b04e6298318d99ec20a787fb360f59
path: packages/scrollable_positioned_list path: packages/scrollable_positioned_list
flutter_reorderable_grid_view: 5.0.1
yaml: any
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -51,4 +53,5 @@ flutter:
assets: assets:
- assets/translation.json - assets/translation.json
- assets/init.js - assets/init.js
- assets/app_icon.png