diff --git a/assets/app_icon.png b/assets/app_icon.png new file mode 100644 index 0000000..e64eb40 Binary files /dev/null and b/assets/app_icon.png differ diff --git a/lib/components/appbar.dart b/lib/components/appbar.dart index 13487ba..a3c9d3a 100644 --- a/lib/components/appbar.dart +++ b/lib/components/appbar.dart @@ -83,7 +83,7 @@ class _AppbarState extends State { 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 { scrollDirection: Axis.horizontal, padding: EdgeInsets.zero, controller: controller, - physics: physics, + physics: physics is BouncingScrollPhysics + ? const ClampingScrollPhysics() + : physics, child: CustomPaint( painter: painter, child: _TabRow( diff --git a/lib/components/select.dart b/lib/components/select.dart index 9e63467..3ac4852 100644 --- a/lib/components/select.dart +++ b/lib/components/select.dart @@ -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)), ), diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index 6be3432..d1d11fa 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -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'; @@ -9,9 +10,9 @@ class _Appdata { var searchHistory = []; bool _isSavingData = false; - + Future saveData() async { - if(_isSavingData) { + if (_isSavingData) { await Future.doWhile(() async { await Future.delayed(const Duration(milliseconds: 20)); return _isSavingData; @@ -25,11 +26,11 @@ class _Appdata { } void addSearchHistory(String keyword) { - if(searchHistory.contains(keyword)) { + if (searchHistory.contains(keyword)) { searchHistory.remove(keyword); } searchHistory.insert(0, keyword); - if(searchHistory.length > 50) { + if (searchHistory.length > 50) { searchHistory.removeLast(); } saveData(); @@ -46,13 +47,16 @@ class _Appdata { } Future init() async { - var file = File(FilePath.join(App.dataPath, 'appdata.json')); - if(!await file.exists()) { + var file = File(FilePath.join( + (await getApplicationSupportDirectory()).path, + 'appdata.json', + )); + if (!await file.exists()) { return; } var json = jsonDecode(await file.readAsString()); - for(var key in (json['settings'] as Map).keys) { - if(json['settings'][key] != null) { + for (var key in (json['settings'] as Map).keys) { + if (json['settings'][key] != null) { settings[key] = json['settings'][key]; } } @@ -74,7 +78,7 @@ class _Settings { final _data = { '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,13 +95,14 @@ class _Settings { 'readerMode': 'galleryLeftToRight', // values of [ReaderMode] 'enableTapToTurnPages': true, 'enablePageAnimation': true, + 'language': 'system', // system, zh-CN, zh-TW, en-US }; - operator[](String key) { + operator [](String key) { return _data[key]; } - operator[]=(String key, dynamic value) { + operator []=(String key, dynamic value) { _data[key] = value; } @@ -105,4 +110,4 @@ class _Settings { String toString() { return _data.toString(); } -} \ No newline at end of file +} diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 90de45e..5033e89 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -100,6 +100,29 @@ class LocalManager with ChangeNotifier { late String path; + // return error message if failed + Future 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 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()) { diff --git a/lib/init.dart b/lib/init.dart index 040b8e6..9ad25b9 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -12,8 +12,8 @@ import 'foundation/appdata.dart'; Future init() async { await AppTranslation.init(); - await App.init(); await appdata.init(); + await App.init(); await HistoryManager().init(); await LocalManager().init(); await LocalFavoritesManager().init(); diff --git a/lib/main.dart b/lib/main.dart index ae34ad4..b74278b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -104,6 +104,18 @@ class _MyAppState extends State { 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'), diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index e0e9d40..ba4b62e 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -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; diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index 750b888..f2f50e9 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -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 { PaneActionEntry( icon: Icons.settings, label: "Settings".tl, - onTap: () {}, + onTap: () { + to(() => const SettingsPage()); + }, ) ], pageBuilder: (index) { diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart new file mode 100644 index 0000000..f9a46da --- /dev/null +++ b/lib/pages/settings/about.dart @@ -0,0 +1,121 @@ +part of 'settings_page.dart'; + +class AboutSettings extends StatefulWidget { + const AboutSettings({super.key}); + + @override + State createState() => _AboutSettingsState(); +} + +class _AboutSettingsState extends State { + 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 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; +} diff --git a/lib/pages/settings/app.dart b/lib/pages/settings/app.dart new file mode 100644 index 0000000..30ff8ff --- /dev/null +++ b/lib/pages/settings/app.dart @@ -0,0 +1,219 @@ +part of 'settings_page.dart'; + +class AppSettings extends StatefulWidget { + const AppSettings({super.key}); + + @override + State createState() => _AppSettingsState(); +} + +class _AppSettingsState extends State { + @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 createState() => _LogsPageState(); +} + +class _LogsPageState extends State { + @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'); + } +} diff --git a/lib/pages/settings/appearance.dart b/lib/pages/settings/appearance.dart new file mode 100644 index 0000000..af3f013 --- /dev/null +++ b/lib/pages/settings/appearance.dart @@ -0,0 +1,44 @@ +part of 'settings_page.dart'; + +class AppearanceSettings extends StatefulWidget { + const AppearanceSettings({super.key}); + + @override + State createState() => _AppearanceSettingsState(); +} + +class _AppearanceSettingsState extends State { + @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(), + ], + ); + } +} diff --git a/lib/pages/settings/explore_settings.dart b/lib/pages/settings/explore_settings.dart new file mode 100644 index 0000000..00a979d --- /dev/null +++ b/lib/pages/settings/explore_settings.dart @@ -0,0 +1,182 @@ +part of 'settings_page.dart'; + +class ExploreSettings extends StatefulWidget { + const ExploreSettings({super.key}); + + @override + State createState() => _ExploreSettingsState(); +} + +class _ExploreSettingsState extends State { + @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 = {}; + 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 = {}; + 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 = {}; + 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), + ), + ], + ); + }); + }, + ); + } +} diff --git a/lib/pages/settings/local_favorites.dart b/lib/pages/settings/local_favorites.dart new file mode 100644 index 0000000..6169b4f --- /dev/null +++ b/lib/pages/settings/local_favorites.dart @@ -0,0 +1,36 @@ +part of 'settings_page.dart'; + +class LocalFavoritesSettings extends StatefulWidget { + const LocalFavoritesSettings({super.key}); + + @override + State createState() => _LocalFavoritesSettingsState(); +} + +class _LocalFavoritesSettingsState extends State { + @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(), + ], + ); + } +} diff --git a/lib/pages/settings/network.dart b/lib/pages/settings/network.dart new file mode 100644 index 0000000..0cc2545 --- /dev/null +++ b/lib/pages/settings/network.dart @@ -0,0 +1,236 @@ +part of 'settings_page.dart'; + +class NetworkSettings extends StatefulWidget { + const NetworkSettings({super.key}); + + @override + State createState() => _NetworkSettingsState(); +} + +class _NetworkSettingsState extends State { + @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( + title: Text("Direct".tl), + value: 'direct', + groupValue: type, + onChanged: (v) { + setState(() { + type = v!; + }); + appdata.settings['proxy'] = toProxyStr(); + appdata.saveData(); + }, + ), + RadioListTile( + 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(); + + 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); + } +} diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index 5185d4c..4704d24 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -14,7 +14,7 @@ class _ReaderSettingsState extends State { 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", diff --git a/lib/pages/settings/setting_components.dart b/lib/pages/settings/setting_components.dart index 02d4fc9..04ea79f 100644 --- a/lib/pages/settings/setting_components.dart +++ b/lib/pages/settings/setting_components.dart @@ -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 pages; + + @override + State<_MultiPagesFilter> createState() => _MultiPagesFilterState(); +} + +class _MultiPagesFilterState extends State<_MultiPagesFilter> { + late List 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 = {}; + 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), + ], + ), + ), + ); + } +} diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index ba2beca..3dbbc5b 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -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 'setting_components.dart'; \ No newline at end of file +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 createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State implements PopEntry { + int currentPage = -1; + + ColorScheme get colors => Theme.of(context).colorScheme; + + bool get enableTwoViews => context.width > changePoint; + + final categories = [ + "Explore", + "Reading", + "Appearance", + "Local Favorites", + "APP", + "Network", + "About", + ]; + + final icons = [ + 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? 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( + 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 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; + }); + } + }; +} diff --git a/lib/utils/io.dart b/lib/utils/io.dart index c99e98b..931bbe5 100644 --- a/lib/utils/io.dart +++ b/lib/utils/io.dart @@ -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"; + } +} diff --git a/pubspec.lock b/pubspec.lock index 6a98f95..541e5d8 100644 --- a/pubspec.lock +++ b/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" diff --git a/pubspec.yaml b/pubspec.yaml index 5c7fae0..d00547e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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