From 6644e788defe16e58e7aca697f2b3b802d25cc07 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Mon, 1 Jan 2024 16:55:11 +0800 Subject: [PATCH] Add server settings page --- lib/api/client.dart | 9 ++ lib/api/client.g.dart | 65 ++++++++++ lib/api/config.dart | 167 ++++++++++++++++++++++++ lib/api/config.g.dart | 154 ++++++++++++++++++++++ lib/auth.dart | 1 + lib/globals.dart | 9 ++ lib/l10n/app_en.arb | 8 +- lib/l10n/app_zh.arb | 8 +- lib/main.dart | 5 + lib/server_settings.dart | 271 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 695 insertions(+), 2 deletions(-) create mode 100644 lib/api/config.dart create mode 100644 lib/api/config.g.dart create mode 100644 lib/server_settings.dart diff --git a/lib/api/client.dart b/lib/api/client.dart index c61256b..587db5b 100644 --- a/lib/api/client.dart +++ b/lib/api/client.dart @@ -6,6 +6,7 @@ import 'package:eh_downloader_flutter/api/file.dart'; import 'package:retrofit/retrofit.dart'; import 'api_result.dart'; +import 'config.dart'; import 'gallery.dart'; import 'status.dart'; import 'tags.dart'; @@ -191,6 +192,14 @@ abstract class _EHApi { {@Part(name: "is_nsfw") bool? isNsfw, @Part(name: "is_ad") bool? isAd, @CancelRequest() CancelToken? cancel}); + + @GET('/config') + Future getConfig( + {@Query("current") bool? current, @CancelRequest() CancelToken? cancel}); + @POST('/config') + Future updateConfig( + @Body(nullToAbsent: false) ConfigOptional cfg, + {@CancelRequest() CancelToken? cancel}); } class EHApi extends __EHApi { diff --git a/lib/api/client.g.dart b/lib/api/client.g.dart index 0ef8cd5..43b77b9 100644 --- a/lib/api/client.g.dart +++ b/lib/api/client.g.dart @@ -857,6 +857,71 @@ class __EHApi implements _EHApi { return value; } + @override + Future getConfig({ + bool? current, + CancelToken? cancel, + }) async { + const _extra = {}; + final queryParameters = {r'current': current}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final Map? _data = null; + final _result = + await _dio.fetch>(_setStreamType(Options( + method: 'GET', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/config', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = Config.fromJson(_result.data!); + return value; + } + + @override + Future updateConfig( + ConfigOptional cfg, { + CancelToken? cancel, + }) async { + const _extra = {}; + final queryParameters = {}; + queryParameters.removeWhere((k, v) => v == null); + final _headers = {}; + final _data = {}; + _data.addAll(cfg.toJson()); + final _result = await _dio + .fetch>(_setStreamType(Options( + method: 'POST', + headers: _headers, + extra: _extra, + ) + .compose( + _dio.options, + '/config', + queryParameters: queryParameters, + data: _data, + cancelToken: cancel, + ) + .copyWith( + baseUrl: _combineBaseUrls( + _dio.options.baseUrl, + baseUrl, + )))); + final value = UpdateConfigResult.fromJson(_result.data!); + return value; + } + RequestOptions _setStreamType(RequestOptions requestOptions) { if (T != dynamic && !(requestOptions.responseType == ResponseType.bytes || diff --git a/lib/api/config.dart b/lib/api/config.dart new file mode 100644 index 0000000..f4b94ad --- /dev/null +++ b/lib/api/config.dart @@ -0,0 +1,167 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'config.g.dart'; + +enum ThumbnailMethod { + @JsonValue(0) + ffmpegBinary, + @JsonValue(1) + ffmpegApi, +} + +@JsonSerializable() +class Config { + Config({ + required this.cookies, + this.dbPath, + this.ua, + required this.ex, + required this.base, + required this.maxTaskCount, + required this.mpv, + required this.maxRetryCount, + required this.maxDownloadImgCount, + required this.downloadOriginalImg, + required this.port, + required this.exportZipJpnTitle, + required this.hostname, + this.meiliHost, + this.meiliSearchApiKey, + this.meiliUpdateApiKey, + required this.ffmpegPath, + required this.thumbnailMethod, + required this.thumbnailDir, + required this.removePreviousGallery, + this.imgVerifySecret, + this.meiliHosts, + required this.corsCredentialsHosts, + this.flutterFrontend, + required this.fetchTimeout, + required this.downloadTimeout, + required this.ffprobePath, + }); + bool cookies; + @JsonKey(name: 'db_path') + String? dbPath; + String? ua; + bool ex; + String base; + @JsonKey(name: 'max_task_count') + int maxTaskCount; + bool mpv; + @JsonKey(name: 'max_retry_count') + int maxRetryCount; + @JsonKey(name: 'max_download_img_count') + int maxDownloadImgCount; + @JsonKey(name: 'download_original_img') + bool downloadOriginalImg; + int port; + @JsonKey(name: 'export_zip_jpn_title') + bool exportZipJpnTitle; + String hostname; + @JsonKey(name: 'meili_host') + String? meiliHost; + @JsonKey(name: 'meili_search_api_key') + String? meiliSearchApiKey; + @JsonKey(name: 'meili_update_api_key') + String? meiliUpdateApiKey; + @JsonKey(name: 'ffmpeg_path') + String ffmpegPath; + @JsonKey(name: 'thumbnail_method') + ThumbnailMethod thumbnailMethod; + @JsonKey(name: 'thumbnail_dir') + String thumbnailDir; + @JsonKey(name: 'remove_previous_gallery') + bool removePreviousGallery; + @JsonKey(name: 'img_verify_secret') + String? imgVerifySecret; + @JsonKey(name: 'meili_hosts') + Map? meiliHosts; + @JsonKey(name: 'cors_credentials_hosts') + List corsCredentialsHosts; + @JsonKey(name: 'flutter_frontend') + String? flutterFrontend; + @JsonKey(name: 'fetch_timeout') + int fetchTimeout; + @JsonKey(name: 'download_timeout') + int downloadTimeout; + @JsonKey(name: 'ffprobe_path') + String ffprobePath; + factory Config.fromJson(Map json) => _$ConfigFromJson(json); + Map toJson() => _$ConfigToJson(this); +} + +@JsonSerializable() +class UpdateConfigResult { + const UpdateConfigResult({ + required this.isUnsafe, + }); + @JsonKey(name: 'is_unsafe') + final bool isUnsafe; + factory UpdateConfigResult.fromJson(Map json) => + _$UpdateConfigResultFromJson(json); + Map toJson() => _$UpdateConfigResultToJson(this); +} + +@JsonSerializable() +class ConfigOptional { + ConfigOptional({ + this.cookies, + this.dbPath, + this.ua, + this.ex, + this.base, + this.maxTaskCount, + this.mpv, + this.maxRetryCount, + this.maxDownloadImgCount, + this.downloadOriginalImg, + this.port, + this.exportZipJpnTitle, + this.hostname, + this.meiliHost, + this.meiliSearchApiKey, + this.meiliUpdateApiKey, + this.ffmpegPath, + this.thumbnailMethod, + this.thumbnailDir, + this.removePreviousGallery, + this.imgVerifySecret, + this.meiliHosts, + this.corsCredentialsHosts, + this.flutterFrontend, + this.fetchTimeout, + this.downloadTimeout, + this.ffprobePath, + }); + String? cookies; + String? dbPath; + String? ua; + bool? ex; + String? base; + int? maxTaskCount; + bool? mpv; + int? maxRetryCount; + int? maxDownloadImgCount; + bool? downloadOriginalImg; + int? port; + bool? exportZipJpnTitle; + String? hostname; + String? meiliHost; + String? meiliSearchApiKey; + String? meiliUpdateApiKey; + String? ffmpegPath; + ThumbnailMethod? thumbnailMethod; + String? thumbnailDir; + bool? removePreviousGallery; + String? imgVerifySecret; + Map? meiliHosts; + List? corsCredentialsHosts; + String? flutterFrontend; + int? fetchTimeout; + int? downloadTimeout; + String? ffprobePath; + factory ConfigOptional.fromJson(Map json) => + _$ConfigOptionalFromJson(json); + Map toJson() => _$ConfigOptionalToJson(this); +} diff --git a/lib/api/config.g.dart b/lib/api/config.g.dart new file mode 100644 index 0000000..08c0a76 --- /dev/null +++ b/lib/api/config.g.dart @@ -0,0 +1,154 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Config _$ConfigFromJson(Map json) => Config( + cookies: json['cookies'] as bool, + dbPath: json['db_path'] as String?, + ua: json['ua'] as String?, + ex: json['ex'] as bool, + base: json['base'] as String, + maxTaskCount: json['max_task_count'] as int, + mpv: json['mpv'] as bool, + maxRetryCount: json['max_retry_count'] as int, + maxDownloadImgCount: json['max_download_img_count'] as int, + downloadOriginalImg: json['download_original_img'] as bool, + port: json['port'] as int, + exportZipJpnTitle: json['export_zip_jpn_title'] as bool, + hostname: json['hostname'] as String, + meiliHost: json['meili_host'] as String?, + meiliSearchApiKey: json['meili_search_api_key'] as String?, + meiliUpdateApiKey: json['meili_update_api_key'] as String?, + ffmpegPath: json['ffmpeg_path'] as String, + thumbnailMethod: + $enumDecode(_$ThumbnailMethodEnumMap, json['thumbnail_method']), + thumbnailDir: json['thumbnail_dir'] as String, + removePreviousGallery: json['remove_previous_gallery'] as bool, + imgVerifySecret: json['img_verify_secret'] as String?, + meiliHosts: (json['meili_hosts'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + corsCredentialsHosts: (json['cors_credentials_hosts'] as List) + .map((e) => e as String) + .toList(), + flutterFrontend: json['flutter_frontend'] as String?, + fetchTimeout: json['fetch_timeout'] as int, + downloadTimeout: json['download_timeout'] as int, + ffprobePath: json['ffprobe_path'] as String, + ); + +Map _$ConfigToJson(Config instance) => { + 'cookies': instance.cookies, + 'db_path': instance.dbPath, + 'ua': instance.ua, + 'ex': instance.ex, + 'base': instance.base, + 'max_task_count': instance.maxTaskCount, + 'mpv': instance.mpv, + 'max_retry_count': instance.maxRetryCount, + 'max_download_img_count': instance.maxDownloadImgCount, + 'download_original_img': instance.downloadOriginalImg, + 'port': instance.port, + 'export_zip_jpn_title': instance.exportZipJpnTitle, + 'hostname': instance.hostname, + 'meili_host': instance.meiliHost, + 'meili_search_api_key': instance.meiliSearchApiKey, + 'meili_update_api_key': instance.meiliUpdateApiKey, + 'ffmpeg_path': instance.ffmpegPath, + 'thumbnail_method': _$ThumbnailMethodEnumMap[instance.thumbnailMethod]!, + 'thumbnail_dir': instance.thumbnailDir, + 'remove_previous_gallery': instance.removePreviousGallery, + 'img_verify_secret': instance.imgVerifySecret, + 'meili_hosts': instance.meiliHosts, + 'cors_credentials_hosts': instance.corsCredentialsHosts, + 'flutter_frontend': instance.flutterFrontend, + 'fetch_timeout': instance.fetchTimeout, + 'download_timeout': instance.downloadTimeout, + 'ffprobe_path': instance.ffprobePath, + }; + +const _$ThumbnailMethodEnumMap = { + ThumbnailMethod.ffmpegBinary: 0, + ThumbnailMethod.ffmpegApi: 1, +}; + +UpdateConfigResult _$UpdateConfigResultFromJson(Map json) => + UpdateConfigResult( + isUnsafe: json['is_unsafe'] as bool, + ); + +Map _$UpdateConfigResultToJson(UpdateConfigResult instance) => + { + 'is_unsafe': instance.isUnsafe, + }; + +ConfigOptional _$ConfigOptionalFromJson(Map json) => + ConfigOptional( + cookies: json['cookies'] as String?, + dbPath: json['dbPath'] as String?, + ua: json['ua'] as String?, + ex: json['ex'] as bool?, + base: json['base'] as String?, + maxTaskCount: json['maxTaskCount'] as int?, + mpv: json['mpv'] as bool?, + maxRetryCount: json['maxRetryCount'] as int?, + maxDownloadImgCount: json['maxDownloadImgCount'] as int?, + downloadOriginalImg: json['downloadOriginalImg'] as bool?, + port: json['port'] as int?, + exportZipJpnTitle: json['exportZipJpnTitle'] as bool?, + hostname: json['hostname'] as String?, + meiliHost: json['meiliHost'] as String?, + meiliSearchApiKey: json['meiliSearchApiKey'] as String?, + meiliUpdateApiKey: json['meiliUpdateApiKey'] as String?, + ffmpegPath: json['ffmpegPath'] as String?, + thumbnailMethod: $enumDecodeNullable( + _$ThumbnailMethodEnumMap, json['thumbnailMethod']), + thumbnailDir: json['thumbnailDir'] as String?, + removePreviousGallery: json['removePreviousGallery'] as bool?, + imgVerifySecret: json['imgVerifySecret'] as String?, + meiliHosts: (json['meiliHosts'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + corsCredentialsHosts: (json['corsCredentialsHosts'] as List?) + ?.map((e) => e as String) + .toList(), + flutterFrontend: json['flutterFrontend'] as String?, + fetchTimeout: json['fetchTimeout'] as int?, + downloadTimeout: json['downloadTimeout'] as int?, + ffprobePath: json['ffprobePath'] as String?, + ); + +Map _$ConfigOptionalToJson(ConfigOptional instance) => + { + 'cookies': instance.cookies, + 'dbPath': instance.dbPath, + 'ua': instance.ua, + 'ex': instance.ex, + 'base': instance.base, + 'maxTaskCount': instance.maxTaskCount, + 'mpv': instance.mpv, + 'maxRetryCount': instance.maxRetryCount, + 'maxDownloadImgCount': instance.maxDownloadImgCount, + 'downloadOriginalImg': instance.downloadOriginalImg, + 'port': instance.port, + 'exportZipJpnTitle': instance.exportZipJpnTitle, + 'hostname': instance.hostname, + 'meiliHost': instance.meiliHost, + 'meiliSearchApiKey': instance.meiliSearchApiKey, + 'meiliUpdateApiKey': instance.meiliUpdateApiKey, + 'ffmpegPath': instance.ffmpegPath, + 'thumbnailMethod': _$ThumbnailMethodEnumMap[instance.thumbnailMethod], + 'thumbnailDir': instance.thumbnailDir, + 'removePreviousGallery': instance.removePreviousGallery, + 'imgVerifySecret': instance.imgVerifySecret, + 'meiliHosts': instance.meiliHosts, + 'corsCredentialsHosts': instance.corsCredentialsHosts, + 'flutterFrontend': instance.flutterFrontend, + 'fetchTimeout': instance.fetchTimeout, + 'downloadTimeout': instance.downloadTimeout, + 'ffprobePath': instance.ffprobePath, + }; diff --git a/lib/auth.dart b/lib/auth.dart index 2f5c07d..f76e2c1 100644 --- a/lib/auth.dart +++ b/lib/auth.dart @@ -16,6 +16,7 @@ class AuthInfo { bool get checked => _checked; bool _isChecking = false; bool get isChecking => _isChecking; + bool? get isAdmin => _user?.isAdmin; void clear() { _user = null; diff --git a/lib/globals.dart b/lib/globals.dart index d417f6d..9c945dc 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -136,6 +136,7 @@ enum MoreVertSettings { settings, markAsNsfw, markAsSfw, + serverSettings, } void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) { @@ -155,6 +156,9 @@ void onMoreVertSettingsSelected(BuildContext context, MoreVertSettings value) { case MoreVertSettings.markAsSfw: GalleryPage.maybeOf(context)?.markGalleryAsNsfw(false); break; + case MoreVertSettings.serverSettings: + context.push("/server_settings"); + break; default: break; } @@ -183,6 +187,11 @@ List> buildMoreVertSettings( value: MoreVertSettings.settings, child: Text(AppLocalizations.of(context)!.settings))); } + if (path != "/server_settings" && auth.isAdmin == true) { + list.add(PopupMenuItem( + value: MoreVertSettings.serverSettings, + child: Text(AppLocalizations.of(context)!.serverSettings))); + } var showNsfw = prefs.getBool("showNsfw") ?? false; list.add(PopupMenuItem( child: StatefulBuilder( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d126b63..2dcb247 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -91,5 +91,11 @@ "markAsNsfw": "Mark as NSFW", "markAsSfw": "Mark as SFW", "markAsAd": "Mark as Ad", - "markAsNonAd": "Mark as non-Ad" + "markAsNonAd": "Mark as non-Ad", + "serverSettings": "Server Settings", + "useEx": "Use exhentai.org.", + "mpv": "Fetch page data from Multi-Page Viewer.", + "downloadOriginalImg": "Download original images.", + "exportZipJpnTitle": "Use japanese title first when exporting zip.", + "removePreviousGallery": "Remove old galleries which replaced by new ones." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e65416e..f166335 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -91,5 +91,11 @@ "markAsNsfw": "标记为NSFW", "markAsSfw": "标记为SFW", "markAsAd": "标记为广告", - "markAsNonAd": "标记为非广告" + "markAsNonAd": "标记为非广告", + "serverSettings": "服务器设置", + "useEx": "使用 exhentai.org。", + "mpv": "从 Multi-Page Viewer 获取页面数据。", + "downloadOriginalImg": "下载原始画质的图片。", + "exportZipJpnTitle": "导出Zip时优先使用日语标题。", + "removePreviousGallery": "移除被新画廊替代的旧画廊。" } diff --git a/lib/main.dart b/lib/main.dart index f09769f..68736a2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'globals.dart'; import 'home.dart'; import 'login.dart'; import 'logs/file.dart'; +import 'server_settings.dart'; import 'set_server.dart'; import 'settings.dart'; import 'utils.dart'; @@ -156,6 +157,10 @@ final _router = GoRouter( return "/"; } }), + GoRoute( + path: ServerSettingsPage.routeName, + builder: (context, state) => const ServerSettingsPage(), + ) ], ); diff --git a/lib/server_settings.dart b/lib/server_settings.dart new file mode 100644 index 0000000..1dd2061 --- /dev/null +++ b/lib/server_settings.dart @@ -0,0 +1,271 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; +import 'api/config.dart'; +import 'globals.dart'; + +final _log = Logger("ServerSettingsPage"); + +class ServerSettingsPage extends StatefulWidget { + const ServerSettingsPage({Key? key}) : super(key: key); + static get routeName => "/server_settings"; + + @override + State createState() => _ServerSettingsPage(); +} + +class _ServerSettingsPage extends State + with IsTopWidget2, ThemeModeWidget { + final _formKey = GlobalKey(); + late bool _isLoading; + late bool _isSaving; + late bool _changed; + late ScrollController _controller; + late ConfigOptional _now; + Config? _config; + Object? _error; + CancelToken? _cancel; + CancelToken? _saveCancel; + + Future _fetchData() async { + _cancel = CancelToken(); + try { + final config = await api.getConfig(cancel: _cancel); + if (!_cancel!.isCancelled) { + setState(() { + _config = config; + _error = null; + }); + } + } catch (e) { + if (!_cancel!.isCancelled) { + _log.warning("Error when fetching config:", e); + setState(() { + _error = e; + }); + } + } + } + + Future _saveConfig() async { + if (_isSaving) return; + try { + _saveCancel = CancelToken(); + setState(() { + _isSaving = true; + }); + await api.updateConfig(_now, cancel: _saveCancel); + if (!_saveCancel!.isCancelled) { + setState(() { + _isSaving = false; + _now = ConfigOptional(); + _changed = false; + _config = null; + }); + } + } catch (e) { + if (!_saveCancel!.isCancelled) { + _log.warning("Error when saving config:", e); + setState(() { + _isSaving = false; + }); + } + } + } + + @override + void initState() { + super.initState(); + _isLoading = false; + _isSaving = false; + _changed = false; + _controller = ScrollController(); + _now = ConfigOptional(); + } + + @override + void dispose() { + _cancel?.cancel(); + _formKey.currentState?.dispose(); + _controller.dispose(); + _saveCancel?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!tryInitApi(context)) { + return Container(); + } + final isLoading = _config == null && _error == null; + if (isLoading && !_isLoading) _fetchData(); + final i18n = AppLocalizations.of(context)!; + final cs = Theme.of(context).colorScheme; + if (isTop(context)) { + setCurrentTitle(i18n.serverSettings, cs.primary.value); + } + return Scaffold( + appBar: isLoading + ? AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(i18n.serverSettings), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ]) + : null, + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText("Error $_error"), + ElevatedButton.icon( + onPressed: () { + _fetchData(); + setState(() { + _error = null; + }); + }, + icon: const Icon(Icons.refresh), + label: Text(AppLocalizations.of(context)!.retry)) + ])) + : _buildForm(context)); + } + + Widget _buildForm(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return Form( + key: _formKey, + child: CustomScrollView( + controller: _controller, + slivers: [ + SliverAppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + context.canPop() ? context.pop() : context.go("/"); + }, + ), + title: Text(i18n.serverSettings), + actions: [ + buildThemeModeIcon(context), + buildMoreVertSettingsButon(context), + ]), + SliverList( + delegate: SliverChildListDelegate([ + _buildCheckBox(context), + _buildBottomBar(context), + ])), + ], + )); + } + + Widget _buildWithHorizontalPadding(BuildContext context, Widget child) { + return Container( + padding: MediaQuery.of(context).size.width > 810 + ? const EdgeInsets.symmetric(horizontal: 100) + : null, + child: child, + ); + } + + Widget _buildWithVecticalPadding(Widget child) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: child, + ); + } + + Widget _buildCheckBox(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return _buildWithHorizontalPadding( + context, + Column(mainAxisSize: MainAxisSize.min, children: [ + _buildWithVecticalPadding(CheckboxMenuButton( + value: _now.ex ?? _config!.ex, + onChanged: (b) { + if (b != null) { + setState(() { + _now.ex = b; + _changed = true; + }); + } + }, + child: Text(i18n.useEx))), + _buildWithVecticalPadding(CheckboxMenuButton( + value: _now.mpv ?? _config!.mpv, + onChanged: (b) { + if (b != null) { + setState(() { + _now.mpv = b; + _changed = true; + }); + } + }, + child: Text(i18n.mpv))), + _buildWithVecticalPadding(CheckboxMenuButton( + value: _now.downloadOriginalImg ?? _config!.downloadOriginalImg, + onChanged: (b) { + if (b != null) { + setState(() { + _now.downloadOriginalImg = b; + _changed = true; + }); + } + }, + child: Text(i18n.downloadOriginalImg))), + _buildWithVecticalPadding(CheckboxMenuButton( + value: _now.exportZipJpnTitle ?? _config!.exportZipJpnTitle, + onChanged: (b) { + if (b != null) { + setState(() { + _now.exportZipJpnTitle = b; + _changed = true; + }); + } + }, + child: Text(i18n.exportZipJpnTitle))), + _buildWithVecticalPadding(CheckboxMenuButton( + value: + _now.removePreviousGallery ?? _config!.removePreviousGallery, + onChanged: (b) { + if (b != null) { + setState(() { + _now.removePreviousGallery = b; + _changed = true; + }); + } + }, + child: Text(i18n.removePreviousGallery))), + ])); + } + + Widget _buildBottomBar(BuildContext context) { + final i18n = AppLocalizations.of(context)!; + return _buildWithHorizontalPadding( + context, + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _buildWithVecticalPadding(ElevatedButton.icon( + icon: const Icon(Icons.save), + label: Text(i18n.save), + onPressed: _changed + ? () { + _saveConfig(); + } + : null)), + ], + )); + } +}