diff --git a/lib/components/string_map_field.dart b/lib/components/string_map_field.dart new file mode 100644 index 0000000..01ba811 --- /dev/null +++ b/lib/components/string_map_field.dart @@ -0,0 +1,307 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class StringMapFormField extends StatefulWidget { + const StringMapFormField( + {Key? key, + this.initialValue, + this.onChanged, + this.keyDecoration, + this.valueDecoration, + this.padding, + this.keyPadding, + this.valuePadding, + this.keyValidator, + this.valueValidator, + this.keyAutovalidateMode, + this.valueAutovalidateMode, + this.label, + this.helper, + this.constraints}) + : super(key: key); + final Map? initialValue; + final ValueChanged>? onChanged; + final InputDecoration? keyDecoration; + final InputDecoration? valueDecoration; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? keyPadding; + final EdgeInsetsGeometry? valuePadding; + final FormFieldValidator? keyValidator; + final FormFieldValidator? valueValidator; + final AutovalidateMode? keyAutovalidateMode; + final AutovalidateMode? valueAutovalidateMode; + final Widget? label; + final Widget? helper; + final BoxConstraints? constraints; + + @override + State createState() => _StringMapFormField(); +} + +class _StringMapFormField extends State { + late Map value; + late String parentKey; + late Key lastKey; + late List keys; + late int rebuildKeys; + late List keyList; + + @override + void initState() { + super.initState(); + value = widget.initialValue ?? {}; + parentKey = widget.key?.toString() ?? ""; + lastKey = ValueKey("${parentKey}_new"); + final len = value.length; + keys = List.generate(len, (index) => ValueKey("${parentKey}_$index")); + keyList = value.keys.toList(); + rebuildKeys = 0; + } + + void onReorder(int oldIndex, int newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final String item = keyList.removeAt(oldIndex); + keyList.insert(newIndex, item); + final key = keys.removeAt(oldIndex); + keys.insert(newIndex, key); + }); + widget.onChanged?.call(value); + } + + Widget _buildKeyItem(BuildContext context, int index, bool expanded) { + final i18n = AppLocalizations.of(context)!; + Widget item = TextFormField( + initialValue: keyList[index], + decoration: widget.keyDecoration, + onChanged: (String? value) { + if (widget.keyValidator != null) { + final re = widget.keyValidator?.call(value); + if (re != null) { + return; + } + } + final v = value ?? ""; + if (v.isEmpty || this.value.containsKey(v)) { + return; + } + final old = keyList[index]; + keyList[index] = v; + final va = this.value.remove(old); + this.value[v] = va ?? ""; + widget.onChanged?.call(this.value); + }, + validator: (s) { + final re = widget.keyValidator?.call(s); + if (re != null) { + return re; + } + final v = s ?? ""; + if (v.isEmpty) { + return i18n.keyIsEmpty; + } + if (value.containsKey(v)) { + return i18n.keyIsExists; + } + return null; + }, + autovalidateMode: widget.keyAutovalidateMode, + ); + if (expanded) { + if (widget.keyPadding != null) { + item = Padding( + padding: widget.keyPadding!, + child: item, + ); + } + item = Expanded(child: item); + } else if (widget.padding != null) { + item = Padding( + padding: widget.padding!, + child: item, + ); + } + return item; + } + + Widget _buildValueItem(BuildContext context, int index, bool expanded) { + Widget item = TextFormField( + initialValue: value[keyList[index]], + decoration: widget.keyDecoration, + onChanged: (String? value) { + if (widget.valueValidator != null) { + final re = widget.valueValidator?.call(value); + if (re != null) { + return; + } + } + this.value[keyList[index]] = value ?? ""; + widget.onChanged?.call(this.value); + }, + validator: widget.valueValidator, + autovalidateMode: widget.valueAutovalidateMode, + ); + if (expanded) { + if (widget.valuePadding != null) { + item = Padding( + padding: widget.valuePadding!, + child: item, + ); + } + item = Expanded(child: item); + } else if (widget.padding != null) { + item = Padding( + padding: widget.padding!, + child: item, + ); + } + return item; + } + + Widget _buildItem(BuildContext context, int index) { + final useMobile = MediaQuery.of(context).size.width <= 810; + Widget item = Row( + key: keys[index], + children: [ + useMobile + ? Expanded( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _buildKeyItem(context, index, false), + _buildValueItem(context, index, false), + ])) + : _buildKeyItem(context, index, true), + useMobile ? Container() : _buildValueItem(context, index, true), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + final key = keyList.removeAt(index); + value.remove(key); + }); + widget.onChanged?.call(value); + }, + ), + ReorderableDragStartListener( + index: index, child: const Icon(Icons.reorder)), + ], + ); + if (!useMobile && widget.padding != null) { + item = Padding( + key: item.key, + padding: widget.padding!, + child: item, + ); + } + return item; + } + + Widget _buildList(BuildContext context) { + Widget list = ReorderableList( + itemBuilder: _buildItem, + itemCount: value.length, + onReorder: onReorder, + proxyDecorator: proxyDecorator, + shrinkWrap: true); + if (widget.constraints != null) { + list = ConstrainedBox( + constraints: widget.constraints!, + child: list, + ); + } + return list; + } + + Widget proxyDecorator(Widget child, int index, Animation animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double animValue = Curves.easeInOut.transform(animation.value); + final double elevation = lerpDouble(0, 6, animValue)!; + return Material( + elevation: elevation, + child: child, + ); + }, + child: child, + ); + } + + Widget _buildLabel(BuildContext context) { + if (widget.label == null) { + return Container(); + } + if (widget.padding == null) { + return widget.label!; + } + return Padding( + padding: widget.padding!, + child: widget.label!, + ); + } + + Widget _buildHelper(BuildContext context) { + if (widget.helper == null) { + return Container(); + } + if (widget.padding == null) { + return widget.helper!; + } + return Padding( + padding: widget.padding!, + child: widget.helper!, + ); + } + + Widget _buildAddButton(BuildContext context) { + Widget button = LayoutBuilder(builder: (context, box) { + return SizedBox( + width: box.maxWidth, + child: IconButton( + key: lastKey, + onPressed: value.containsKey("") + ? null + : () { + if (value.containsKey("")) { + return; + } + setState(() { + value[""] = ""; + keyList.add(""); + keys.add(ValueKey("${parentKey}_${value.length}")); + }); + widget.onChanged?.call(value); + }, + icon: const Icon(Icons.add))); + }); + if (widget.padding != null) { + button = Padding( + padding: widget.padding!, + child: button, + ); + } + return button; + } + + @override + Widget build(BuildContext context) { + if (value.length != keys.length) { + final len = value.length; + keys = List.generate( + len, (index) => ValueKey("${parentKey}_${rebuildKeys}_$index")); + keyList = value.keys.toList(); + rebuildKeys++; + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel(context), + _buildList(context), + _buildAddButton(context), + _buildHelper(context), + ]); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5f0daea..2aa9e21 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -135,5 +135,9 @@ "corsCredentialsHostsHelp": "Add websites to this list can lead to security risks, make sure you trust these websites.", "galleryDetails": "Gallery Details", "title2": "Title", - "titleJpn": "Japanese Title" + "titleJpn": "Japanese Title", + "meiliHosts": "Meilisearch server hostname for specific domains", + "meiliHostsHelp": "Requests from these domains will receive the corresponding Meilisearch server hostname.", + "keyIsEmpty": "Key is empty.", + "keyIsExists": "Key is exists." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c4bc11c..27cc130 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -135,5 +135,9 @@ "corsCredentialsHostsHelp": "将网站加入这个列表会导致安全风险,请确保你信任这些网站。", "galleryDetails": "画廊详情", "title2": "标题", - "titleJpn": "日语标题" + "titleJpn": "日语标题", + "meiliHosts": "特定域名的 Meilisearch 服务器主机名", + "meiliHostsHelp": "来自这些域的请求将收到相应的 Meilisearch 服务器主机名。", + "keyIsEmpty": "键不能为空。", + "keyIsExists": "键已存在。" } diff --git a/lib/server_settings.dart b/lib/server_settings.dart index 8bff0bf..47c57ea 100644 --- a/lib/server_settings.dart +++ b/lib/server_settings.dart @@ -8,6 +8,7 @@ import 'api/config.dart'; import 'components/labeled_checkbox.dart'; import 'components/number_field.dart'; import 'components/string_list_field.dart'; +import 'components//string_map_field.dart'; import 'globals.dart'; import 'platform/ua.dart'; @@ -34,6 +35,7 @@ class _ServerSettingsPage extends State CancelToken? _cancel; CancelToken? _saveCancel; late TextEditingController _uaController; + late AppLocalizations i18n; Future _fetchData() async { _cancel = CancelToken(); @@ -59,6 +61,7 @@ class _ServerSettingsPage extends State if (_isSaving) return; try { _now.corsCredentialsHosts?.removeWhere((e) => e.isEmpty); + _now.meiliHosts?.removeWhere((k, v) => k.isEmpty || v.isEmpty); _saveCancel = CancelToken(); setState(() { _isSaving = true; @@ -112,6 +115,7 @@ class _ServerSettingsPage extends State if (!tryInitApi(context)) { return Container(); } + this.i18n = AppLocalizations.of(context)!; final isLoading = _config == null && _error == null; if (isLoading && !_isLoading) _fetchData(); final i18n = AppLocalizations.of(context)!; @@ -155,6 +159,24 @@ class _ServerSettingsPage extends State : _buildForm(context)); } + String? urlOriginValidator(String? s) { + if (s == null || s.isEmpty) return null; + try { + final u = Uri.parse(s); + if (u.hasQuery || + u.userInfo.isNotEmpty || + u.hasFragment || + !u.hasEmptyPath || + !u.hasScheme) return i18n.invalidURLOrigin; + if (u.scheme != "http" && u.scheme != "https") { + return i18n.httpHttpsNeeded; + } + return null; + } catch (e) { + return i18n.invalidURL; + } + } + Widget _buildForm(BuildContext context) { final i18n = AppLocalizations.of(context)!; return Form( @@ -522,6 +544,35 @@ class _ServerSettingsPage extends State }); }, )), + StringMapFormField( + key: const ValueKey("meiliHosts"), + initialValue: _now.meiliHosts ?? _config!.meiliHosts, + keyDecoration: const InputDecoration( + border: OutlineInputBorder(), + ), + valueDecoration: const InputDecoration( + border: OutlineInputBorder(), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + keyPadding: const EdgeInsets.only(right: 4), + valuePadding: const EdgeInsets.only(left: 4), + onChanged: (s) { + setState(() { + _now.meiliHosts = s; + _changed = true; + }); + }, + keyValidator: urlOriginValidator, + valueValidator: urlOriginValidator, + keyAutovalidateMode: AutovalidateMode.onUserInteraction, + valueAutovalidateMode: AutovalidateMode.onUserInteraction, + label: Text(i18n.meiliHosts), + constraints: const BoxConstraints( + maxHeight: 300, + ), + helper: Text(i18n.meiliHostsHelp, + style: Theme.of(context).textTheme.bodySmall), + ), StringListFormField( key: const ValueKey("corsCredentialsHosts"), initialValue: @@ -537,23 +588,7 @@ class _ServerSettingsPage extends State _changed = true; }); }, - validator: (s) { - if (s == null || s.isEmpty) return null; - try { - final u = Uri.parse(s); - if (u.hasQuery || - u.userInfo.isNotEmpty || - u.hasFragment || - !u.hasEmptyPath || - !u.hasScheme) return i18n.invalidURLOrigin; - if (u.scheme != "http" && u.scheme != "https") { - return i18n.httpHttpsNeeded; - } - return null; - } catch (e) { - return i18n.invalidURL; - } - }, + validator: urlOriginValidator, autovalidateMode: AutovalidateMode.onUserInteraction, label: Text(i18n.corsCredentialsHosts), constraints: const BoxConstraints(