import 'dart:io'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/services.dart'; import 'package:pixes/appdata.dart'; import 'package:pixes/components/keyboard.dart'; import 'package:pixes/components/md.dart'; import 'package:pixes/components/message.dart'; import 'package:pixes/components/page_route.dart'; import 'package:pixes/components/title_bar.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/pages/main_page.dart'; import 'package:pixes/utils/io.dart'; import 'package:pixes/utils/translation.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'logs.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @override State createState() => _SettingsPageState(); } class _SettingsPageState extends State { @override Widget build(BuildContext context) { return ScaffoldPage( padding: EdgeInsets.zero, content: CustomScrollView( slivers: [ SliverTitleBar(title: "Settings".tl), buildHeader("Account".tl), buildAccount(), buildHeader("Browse".tl), buildBrowse(), buildHeader("Download".tl), buildDownload(), buildHeader("Appearance".tl), buildAppearance(), buildHeader("About".tl), buildAbout(), SliverPadding( padding: EdgeInsets.only(bottom: context.padding.bottom)), ], ), ); } Widget buildHeader(String text) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Text(text, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), ), ); } Widget buildItem({required String title, String? subtitle, Widget? action}) { return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: EdgeInsets.zero, child: ListTile( title: Text(title), subtitle: subtitle == null ? null : Text(subtitle), trailing: action, ), ); } Widget buildAccount() { return SliverToBoxAdapter( child: Column( children: [ buildItem( title: "Logout".tl, action: Button( onPressed: () { showDialog( context: App.rootNavigatorKey.currentContext!, builder: (context) => ContentDialog( title: Text('Logout'.tl), content: Text('Are you sure you want to logout?'.tl), actions: [ Button( child: Text('Continue'.tl), onPressed: () { appdata.account = null; appdata.writeData(); App.rootNavigatorKey.currentState!.pushAndRemoveUntil( AppPageRoute( builder: (context) => const MainPage()), (route) => false); }, ), FilledButton( child: Text('Cancel'.tl), onPressed: () => context.pop(), ), ], ), ); }, child: Text("Continue".tl).fixWidth(64), ), ), buildItem( title: "Account Settings".tl, action: Button( child: Text("Edit".tl).fixWidth(64), onPressed: () { launchUrlString("https://www.pixiv.net/setting_user.php"); }, )), ], ), ); } Widget buildDownload() { return SliverToBoxAdapter( child: Column( children: [ buildItem( title: "Download Path".tl, subtitle: appdata.settings["downloadPath"], action: Button( child: Text("Manage".tl).fixWidth(64), onPressed: () { if (Platform.isIOS) { showToast(context, message: "Unsupported platform".tl); return; } context.to(() => _SetSingleFieldPage( "Download Path".tl, "downloadPath", check: (text) { if (!Directory(text).havePermission()) { return "No permission".tl; } else { return null; } }, )); }), ), buildItem( title: "Subpath".tl, subtitle: appdata.settings["downloadSubPath"], action: Button( child: Text("Manage".tl).fixWidth(64), onPressed: () { context.to(() => const _SetDownloadSubPathPage()); }), ), buildItem( title: "Max parallels".tl, action: SizedBox( width: 64, height: 32, child: NumberBox( value: appdata.settings["maxParallels"], autofocus: false, onChanged: (value) { appdata.settings["maxParallels"] = value; appdata.writeSettings(); }, clearButton: false, mode: SpinButtonPlacementMode.none, ), ), ), ], ), ); } Widget buildAbout() { return SliverToBoxAdapter( child: Column( children: [ buildItem(title: "Version", subtitle: App.version), buildItem( title: "Check for updates on startup".tl, action: ToggleSwitch( checked: appdata.settings["checkUpdate"], onChanged: (value) { setState(() { appdata.settings["checkUpdate"] = value; }); appdata.writeData(); })), buildItem( title: "Github", action: IconButton( icon: const Icon( MdIcons.open_in_new, size: 18, ), onPressed: () => launchUrlString("https://github.com/wgh136/pixes"), )), buildItem( title: "Telegram", action: IconButton( icon: const Icon( MdIcons.open_in_new, size: 18, ), onPressed: () => launchUrlString("https://t.me/venera_dev"), )), buildItem( title: "Logs", action: IconButton( icon: const Icon( MdIcons.open_in_new, size: 18, ), onPressed: () => context.to(() => const LogsPage()))), ], ), ); } Widget buildBrowse() { return SliverToBoxAdapter( child: Column( children: [ buildItem( title: "Initial Page".tl, action: Button( child: Text("Edit".tl).fixWidth(64), onPressed: () { context.to(() => const _SetInitialPageWidget()); }, )), buildItem( title: "Proxy".tl, action: Button( child: Text("Edit".tl).fixWidth(64), onPressed: () { context.to(() => _SetSingleFieldPage( "Http ${"Proxy".tl}", "proxy", )); }, )), buildItem( title: "Block(Account)".tl, action: Button( child: Text("Edit".tl).fixWidth(64), onPressed: () { launchUrlString("https://www.pixiv.net/setting_mute.php"); }, )), buildItem( title: "Block(Local)".tl, action: Button( child: Text("Edit".tl).fixWidth(64), onPressed: () { context.to(() => const _BlockTagsPage()); }, )), buildItem( title: "Shortcuts".tl, action: Button( child: Text("Edit".tl).fixWidth(64), onPressed: () { context.to(() => const ShortcutsSettings()); }, )), buildItem( title: "Display the original image on the details page".tl, action: ToggleSwitch( checked: appdata.settings['showOriginalImage'], onChanged: (value) { setState(() { appdata.settings['showOriginalImage'] = value; }); appdata.writeData(); })), buildItem( title: "Emphasize artworks from following artists".tl, subtitle: "The border of the artworks will be darker".tl, action: ToggleSwitch( checked: appdata.settings['emphasizeArtworksFromFollowingArtists'], onChanged: (value) { setState(() { appdata.settings[ 'emphasizeArtworksFromFollowingArtists'] = value; }); appdata.writeData(); })), ], ), ); } Widget buildAppearance() { return SliverToBoxAdapter( child: Column( children: [ buildItem( title: "Theme".tl, action: DropDownButton( title: Text(appdata.settings["theme"] ?? "System".tl), items: [ MenuFlyoutItem( text: Text("System".tl), onPressed: () { setState(() { appdata.settings["theme"] = "System"; }); appdata.writeData(); StateController.findOrNull(tag: "MyApp")?.update(); }), MenuFlyoutItem( text: Text("light".tl), onPressed: () { setState(() { appdata.settings["theme"] = "Light"; }); appdata.writeData(); StateController.findOrNull(tag: "MyApp")?.update(); }), MenuFlyoutItem( text: Text("dark".tl), onPressed: () { setState(() { appdata.settings["theme"] = "Dark"; }); appdata.writeData(); StateController.findOrNull(tag: "MyApp")?.update(); }), ])), buildItem( title: "Language".tl, action: DropDownButton( title: Text(appdata.settings["language"] ?? "System"), items: [ MenuFlyoutItem( text: const Text("System"), onPressed: () { setState(() { appdata.settings["language"] = "System"; }); appdata.writeData(); StateController.findOrNull(tag: "MyApp")?.update(); }), MenuFlyoutItem( text: const Text("English"), onPressed: () { setState(() { appdata.settings["language"] = "English"; }); appdata.writeData(); StateController.findOrNull(tag: "MyApp")?.update(); }), MenuFlyoutItem( text: const Text("简体中文"), onPressed: () { setState(() { appdata.settings["language"] = "简体中文"; }); appdata.writeData(); StateController.findOrNull(tag: "MyApp")?.update(); }), MenuFlyoutItem( text: const Text("繁體中文"), onPressed: () { setState(() { appdata.settings["language"] = "繁體中文"; }); appdata.writeData(); StateController.findOrNull(tag: "MyApp")?.update(); }), ])), ], ), ); } } class _SetSingleFieldPage extends StatefulWidget { const _SetSingleFieldPage(this.title, this.field, {this.check}); final String title; final String field; final String? Function(String)? check; @override State<_SetSingleFieldPage> createState() => _SetSingleFieldPageState(); } class _SetSingleFieldPageState extends State<_SetSingleFieldPage> { late final controller = TextEditingController(text: appdata.settings[widget.field]); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TitleBar(title: widget.title), TextBox( controller: controller, ).paddingHorizontal(16), const SizedBox( height: 8, ), Button( child: Text("Confirm".tl), onPressed: () { var text = controller.text; var checkRes = widget.check?.call(text); if (checkRes == null) { appdata.settings[widget.field] = text; appdata.writeData(); context.pop(); } else { showToast(context, message: checkRes); } }, ).toAlign(Alignment.centerRight).paddingRight(16), ], ); } } class _SetDownloadSubPathPage extends StatefulWidget { const _SetDownloadSubPathPage(); @override State<_SetDownloadSubPathPage> createState() => __SetDownloadSubPathPageState(); } class __SetDownloadSubPathPageState extends State<_SetDownloadSubPathPage> { final controller = TextEditingController(text: appdata.settings["downloadSubPath"]); @override Widget build(BuildContext context) { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TitleBar(title: "Download subpath".tl), Text("Rule".tl) .padding(const EdgeInsets.symmetric(vertical: 8, horizontal: 16)), TextBox( controller: controller, ).paddingHorizontal(16), const SizedBox( height: 8, ), Button( child: Text("Confirm".tl), onPressed: () { var text = controller.text; if (check(text)) { appdata.settings["downloadSubPath"] = text; appdata.writeData(); context.pop(); } else { showToast(context, message: "Invalid".tl); } }, ).toAlign(Alignment.centerRight).paddingRight(16), const SizedBox( height: 16, ), SelectableText(_instruction).paddingHorizontal(16) ], ), ); } bool check(String text) { if (text.startsWith('/') || text.startsWith('\\')) { return true; } return false; } String get _instruction => """ ${"Edit the rule for where to save an image.".tl} ${"Note: The rule should include the filename.".tl} ${"Some keywords will be replaced by the following rule:".tl} \${title} -> ${"Title of the work".tl} \${author} -> ${"Name of the author".tl} \${id} -> ${"Artwork ID".tl} \${index} -> ${"Index of the image in the artwork".tl} \${page} -> ${"Replace with '-p\${index}' if the work have more than one images, otherwise replace with blank.".tl} \${ext} -> ${"File extension".tl} \${AI} -> ${"Replace with 'AI' if the work was generated by AI, otherwise replace with blank".tl} \${tag(*)} -> ${"Replace with * if the work have tag *, otherwise replace with blank.".tl} ${"Multiple path separators will be automatically replaced with a single".tl} """; } class _BlockTagsPage extends StatefulWidget { const _BlockTagsPage(); @override State<_BlockTagsPage> createState() => __BlockTagsPageState(); } class __BlockTagsPageState extends State<_BlockTagsPage> { @override Widget build(BuildContext context) { return Column( children: [ TitleBar( title: "Block".tl, action: FilledButton( child: Text("Add".tl), onPressed: () { var controller = TextEditingController(); void finish(BuildContext context) { var text = controller.text; if (text.isNotEmpty && !(appdata.settings["blockTags"] as List).contains(text)) { setState(() { appdata.settings["blockTags"].add(text); }); appdata.writeSettings(); } context.pop(); } showDialog( context: context, barrierDismissible: true, builder: (context) { return ContentDialog( title: Text("Add".tl), content: SizedBox( width: 300, height: 32, child: TextBox( controller: controller, onSubmitted: (v) => finish(context), ), ), actions: [ FilledButton( child: Text("Submit".tl), onPressed: () { finish(context); }) ], ); }); }, ), ), Expanded( child: ListView.builder( itemCount: appdata.settings["blockTags"].length, itemBuilder: (context, index) { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: EdgeInsets.zero, child: ListTile( title: Text(appdata.settings["blockTags"][index]), trailing: Button( child: Text("Delete".tl), onPressed: () { setState(() { (appdata.settings["blockTags"] as List).removeAt(index); }); appdata.writeSettings(); }, ), ), ); }, ), ) ], ); } } class ShortcutsSettings extends StatefulWidget { const ShortcutsSettings({super.key}); @override State createState() => _ShortcutsSettingsState(); } class _ShortcutsSettingsState extends State { int listening = -1; KeyEventListenerState? listener; @override void initState() { listener = KeyEventListener.of(context); super.initState(); } @override void dispose() { listener?.removeAll(); super.dispose(); } final settings = [ "Page down", "Page up", "Next work", "Previous work", "Add to favorites", "Download", "Follow the artist", "Show comments", "Show original image" ]; @override Widget build(BuildContext context) { return SingleChildScrollView( child: Column(children: [ TitleBar(title: "Shortcuts".tl), ...settings.map((e) => buildItem(e, settings.indexOf(e))) ]), ); } Widget buildItem(String text, int index) { var keyText = listening == index ? "Waiting..." : LogicalKeyboardKey(appdata.settings['shortcuts'][index]).keyLabel; return Card( padding: EdgeInsets.zero, margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), child: ListTile( title: Text(text.tl), trailing: Button( child: Text(keyText), onPressed: () { if (listening != -1) { listener?.removeAll(); } setState(() { listening = index; }); listener?.addHandler((key) { if (key == LogicalKeyboardKey.escape) return; setState(() { appdata.settings['shortcuts'][index] = key.keyId; listening = -1; appdata.writeData(); }); Future.microtask(() => listener?.removeAll()); }); }, ), ), ); } } class _SetInitialPageWidget extends StatefulWidget { const _SetInitialPageWidget(); @override State<_SetInitialPageWidget> createState() => _SetInitialPageWidgetState(); } class _SetInitialPageWidgetState extends State<_SetInitialPageWidget> { int index = appdata.settings["initialPage"] ?? 4; static const pageNames = [ "Search", "Downloading", "Downloaded", "Explore", "Bookmarks", "Following", "History", "Ranking", "Recommendation", "Bookmarks", "Following", "Ranking", ]; @override Widget build(BuildContext context) { return ScaffoldPage( header: TitleBar(title: "Initial Page".tl), content: ListView.builder( itemCount: pageNames.length + 2, itemBuilder: (context, index) { if (index == 3) { return Text('${"Illustrations".tl}/${"Manga".tl}').paddingHorizontal(16).paddingVertical(8); } else if (index > 3) { index--; } if (index == 8) { return Text("Novel".tl).paddingHorizontal(16).paddingVertical(8); } else if (index > 8) { index--; } return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: EdgeInsets.zero, child: ListTile( title: Text(pageNames[index].tl), trailing: RadioButton( checked: this.index - 1 == index, onChanged: (value) { setState(() { this.index = index + 1; appdata.settings["initialPage"] = index + 1; appdata.writeData(); }); }, ), ), ); }, ), ); } }