mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
settings page
This commit is contained in:
BIN
assets/app_icon.png
Normal file
BIN
assets/app_icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 62 KiB |
@@ -83,7 +83,7 @@ class _AppbarState extends State<Appbar> {
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
@@ -187,7 +187,7 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
message: "Back".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => Navigator.maybePop(context),
|
||||
),
|
||||
)
|
||||
: const SizedBox()),
|
||||
@@ -322,7 +322,9 @@ class _FilledTabBarState extends State<FilledTabBar> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: EdgeInsets.zero,
|
||||
controller: controller,
|
||||
physics: physics,
|
||||
physics: physics is BouncingScrollPhysics
|
||||
? const ClampingScrollPhysics()
|
||||
: physics,
|
||||
child: CustomPaint(
|
||||
painter: painter,
|
||||
child: _TabRow(
|
||||
|
@@ -60,7 +60,7 @@ class Select extends StatelessWidget {
|
||||
children: [
|
||||
Text(current, style: ts.s14),
|
||||
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)),
|
||||
),
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
|
||||
@@ -46,7 +47,10 @@ class _Appdata {
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
var file = File(FilePath.join(App.dataPath, 'appdata.json'));
|
||||
var file = File(FilePath.join(
|
||||
(await getApplicationSupportDirectory()).path,
|
||||
'appdata.json',
|
||||
));
|
||||
if (!await file.exists()) {
|
||||
return;
|
||||
}
|
||||
@@ -74,7 +78,7 @@ class _Settings {
|
||||
|
||||
final _data = <String, dynamic>{
|
||||
'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
|
||||
'theme_mode': 'system', // light, dark, system
|
||||
'newFavoriteAddTo': 'end', // start, end
|
||||
@@ -91,6 +95,7 @@ class _Settings {
|
||||
'readerMode': 'galleryLeftToRight', // values of [ReaderMode]
|
||||
'enableTapToTurnPages': true,
|
||||
'enablePageAnimation': true,
|
||||
'language': 'system', // system, zh-CN, zh-TW, en-US
|
||||
};
|
||||
|
||||
operator [](String key) {
|
||||
|
@@ -100,6 +100,29 @@ class LocalManager with ChangeNotifier {
|
||||
|
||||
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 {
|
||||
_db = sqlite3.open(
|
||||
'${App.dataPath}/local.db',
|
||||
@@ -118,18 +141,18 @@ class LocalManager with ChangeNotifier {
|
||||
PRIMARY KEY (id, comic_type)
|
||||
);
|
||||
''');
|
||||
if (File('${App.dataPath}/local_path').existsSync()) {
|
||||
path = File('${App.dataPath}/local_path').readAsStringSync();
|
||||
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
|
||||
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
|
||||
} else {
|
||||
if (App.isAndroid) {
|
||||
var external = await getExternalStorageDirectories();
|
||||
if (external != null && external.isNotEmpty) {
|
||||
path = '${external.first.path}/local';
|
||||
path = FilePath.join(external.first.path, 'local_path');
|
||||
} else {
|
||||
path = '${App.dataPath}/local';
|
||||
path = FilePath.join(App.dataPath, 'local_path');
|
||||
}
|
||||
} else {
|
||||
path = '${App.dataPath}/local';
|
||||
path = FilePath.join(App.dataPath, 'local_path');
|
||||
}
|
||||
}
|
||||
if (!Directory(path).existsSync()) {
|
||||
|
@@ -12,8 +12,8 @@ import 'foundation/appdata.dart';
|
||||
|
||||
Future<void> init() async {
|
||||
await AppTranslation.init();
|
||||
await App.init();
|
||||
await appdata.init();
|
||||
await App.init();
|
||||
await HistoryManager().init();
|
||||
await LocalManager().init();
|
||||
await LocalFavoritesManager().init();
|
||||
|
@@ -104,6 +104,18 @@ class _MyAppState extends State<MyApp> {
|
||||
GlobalWidgetsLocalizations.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 [
|
||||
Locale('en'),
|
||||
Locale('zh', 'CN'),
|
||||
|
@@ -112,7 +112,7 @@ class AppDio with DioMixin {
|
||||
static HttpClient createHttpClient() {
|
||||
final client = HttpClient();
|
||||
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.badCertificateCallback = (X509Certificate cert, String host, int port) {
|
||||
if (host.contains("cdn")) return true;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:venera/pages/categories_page.dart';
|
||||
import 'package:venera/pages/search_page.dart';
|
||||
import 'package:venera/pages/settings/settings_page.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
import '../components/components.dart';
|
||||
@@ -89,7 +90,9 @@ class _MainPageState extends State<MainPage> {
|
||||
PaneActionEntry(
|
||||
icon: Icons.settings,
|
||||
label: "Settings".tl,
|
||||
onTap: () {},
|
||||
onTap: () {
|
||||
to(() => const SettingsPage());
|
||||
},
|
||||
)
|
||||
],
|
||||
pageBuilder: (index) {
|
||||
|
121
lib/pages/settings/about.dart
Normal file
121
lib/pages/settings/about.dart
Normal 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
219
lib/pages/settings/app.dart
Normal 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');
|
||||
}
|
||||
}
|
44
lib/pages/settings/appearance.dart
Normal file
44
lib/pages/settings/appearance.dart
Normal 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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
182
lib/pages/settings/explore_settings.dart
Normal file
182
lib/pages/settings/explore_settings.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
36
lib/pages/settings/local_favorites.dart
Normal file
36
lib/pages/settings/local_favorites.dart
Normal 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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
236
lib/pages/settings/network.dart
Normal file
236
lib/pages/settings/network.dart
Normal 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);
|
||||
}
|
||||
}
|
@@ -14,7 +14,7 @@ class _ReaderSettingsState extends State<ReaderSettings> {
|
||||
Widget build(BuildContext context) {
|
||||
return SmoothCustomScrollView(
|
||||
slivers: [
|
||||
SliverAppbar(title: Text("Settings".tl)),
|
||||
SliverAppbar(title: Text("Reading".tl)),
|
||||
_SwitchSetting(
|
||||
title: "Tap to turn Pages".tl,
|
||||
settingKey: "enableTapToTurnPages",
|
||||
|
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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/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/foundation/app.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:yaml/yaml.dart';
|
||||
|
||||
part 'reader.dart';
|
||||
part 'explore_settings.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;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@@ -186,3 +186,15 @@ class Share {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
16
pubspec.lock
16
pubspec.lock
@@ -164,6 +164,14 @@ packages:
|
||||
url: "https://github.com/wgh136/flutter_qjs"
|
||||
source: git
|
||||
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:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -597,6 +605,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
yaml:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: yaml
|
||||
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
sdks:
|
||||
dart: ">=3.4.4 <4.0.0"
|
||||
flutter: ">=3.22.3"
|
||||
|
@@ -40,6 +40,8 @@ dependencies:
|
||||
url: https://github.com/venera-app/flutter.widgets
|
||||
ref: 09e756b1f1b04e6298318d99ec20a787fb360f59
|
||||
path: packages/scrollable_positioned_list
|
||||
flutter_reorderable_grid_view: 5.0.1
|
||||
yaml: any
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -51,4 +53,5 @@ flutter:
|
||||
assets:
|
||||
- assets/translation.json
|
||||
- assets/init.js
|
||||
- assets/app_icon.png
|
||||
|
||||
|
Reference in New Issue
Block a user