Files
FlClash/lib/features/form/map_edit_view.dart
T
chen08209 5607f7047f fix(ui): stop non-secret fields from opening a password keyboard
Changelog: Fix pasting and Chinese input in text fields
2026-10-17 17:21:43 +08:00

318 lines
8.8 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'batch_section.dart';
import 'form_page.dart';
import 'form_row.dart';
typedef _Entry = MapEntry<String, String>;
class MapEditView extends ConsumerStatefulWidget {
final String title;
final Map<String, String> entries;
final Widget Function(MapEntry<String, String> item) titleBuilder;
final Widget Function(MapEntry<String, String> item)? subtitleBuilder;
final int? keyMaxLength;
final int? valueMaxLength;
const MapEditView({
super.key,
required this.title,
required this.entries,
required this.titleBuilder,
this.subtitleBuilder,
this.keyMaxLength,
this.valueMaxLength,
});
@override
ConsumerState<MapEditView> createState() => _MapEditViewState();
}
class _MapEditViewState extends EditViewState<MapEditView, _Entry> {
@override
String get title => widget.title;
@override
List<_Entry> get initialEntries => widget.entries.entries.toList();
@override
String idOf(_Entry entry) => entry.key;
@override
Widget buildTitle(_Entry entry) => widget.titleBuilder(entry);
@override
Widget? buildSubtitle(_Entry entry) => widget.subtitleBuilder?.call(entry);
@override
bool sameEntries(List<_Entry> a, List<_Entry> b) =>
stringAndStringMapEntryListEquality.equals(a, b);
@override
Object popResult(List<_Entry> entries) =>
Map<String, String>.fromEntries(entries);
@override
Future<List<_Entry>?> showEntryEditor(_Entry? entry) {
return showFormSheet<List<_Entry>>(
context: context,
builder: (_) => _MapEntryForm(
listTitle: widget.title,
entry: entry,
existingKeys: entries.map(idOf).toSet(),
keyMaxLength: widget.keyMaxLength,
valueMaxLength: widget.valueMaxLength,
),
);
}
}
enum _MapEntryField { key, value }
/// Adds when [entry] is null, and then a batch may add several entries.
class _MapEntryForm extends StatefulWidget {
final String listTitle;
final _Entry? entry;
final Set<String> existingKeys;
final int? keyMaxLength;
final int? valueMaxLength;
const _MapEntryForm({
required this.listTitle,
required this.entry,
required this.existingKeys,
required this.keyMaxLength,
required this.valueMaxLength,
});
@override
State<_MapEntryForm> createState() => _MapEntryFormState();
}
class _MapEntryFormState extends State<_MapEntryForm> {
final _valueFocusNode = FocusNode();
late final TextEditingController _keyController;
late final TextEditingController _valueController;
final _batchController = TextEditingController();
bool _isBatch = false;
bool _attempted = false;
ParsedInput<_Entry>? _parsed;
String get _keyLabel => context.appLocalizations.key;
String get _valueLabel => context.appLocalizations.value;
BatchInput<_Entry> get _batchInput {
return BatchInput(
label: widget.listTitle,
formatTip: context.appLocalizations.batchMapInputTip,
parse: (text) => parseMapInput(
text,
existingKeys: widget.existingKeys,
keyMaxLength: widget.keyMaxLength,
valueMaxLength: widget.valueMaxLength,
),
issueMessage: _batchIssueMessage,
);
}
@override
void initState() {
super.initState();
_keyController = TextEditingController(text: widget.entry?.key);
_valueController = TextEditingController(text: widget.entry?.value);
}
@override
void dispose() {
_valueFocusNode.dispose();
_keyController.dispose();
_valueController.dispose();
_batchController.dispose();
super.dispose();
}
String _batchIssueMessage(InputIssue issue) {
final appLocalizations = context.appLocalizations;
return switch (issue.kind) {
InputIssueKind.keyTooLong => appLocalizations.maxLengthTip(
_keyLabel,
widget.keyMaxLength!,
),
InputIssueKind.valueTooLong => appLocalizations.maxLengthTip(
_valueLabel,
widget.valueMaxLength!,
),
InputIssueKind.missingValue => appLocalizations.emptyTip(_valueLabel),
InputIssueKind.invalidUrl ||
InputIssueKind.invalidValue => appLocalizations.urlTip(_valueLabel),
};
}
({_MapEntryField field, String message})? _issue() {
final appLocalizations = context.appLocalizations;
final key = _keyController.text.trim();
if (key.isEmpty) {
return (
field: _MapEntryField.key,
message: appLocalizations.emptyTip(_keyLabel),
);
}
if (key != widget.entry?.key && widget.existingKeys.contains(key)) {
return (
field: _MapEntryField.key,
message: appLocalizations.existsTip(_keyLabel),
);
}
if (_valueController.text.trim().isEmpty) {
return (
field: _MapEntryField.value,
message: appLocalizations.emptyTip(_valueLabel),
);
}
return null;
}
void _toggleBatch() {
setState(() {
_isBatch = !_isBatch;
_attempted = false;
if (_isBatch && _batchController.text.isEmpty) {
_batchController.text = [
_keyController.text,
_valueController.text,
].map((text) => text.trim()).where((text) => text.isNotEmpty).join(' ');
}
if (_isBatch) {
_parsed = _batchInput.parse(_batchController.text);
}
});
}
void _handleChanged(String _) {
if (_attempted) {
setState(() {});
}
}
void _handleBatchChanged(String text) {
setState(() {
_parsed = _batchInput.parse(text);
});
}
void _handleSubmit() {
if (_isBatch) {
final parsed = _parsed;
if (parsed == null || !parsed.isValid || parsed.entries.isEmpty) {
setState(() {
_attempted = true;
});
return;
}
Navigator.of(context).pop<List<_Entry>>(parsed.entries);
return;
}
if (_issue() != null) {
setState(() {
_attempted = true;
});
return;
}
Navigator.of(context).pop<List<_Entry>>([
MapEntry(_keyController.text.trim(), _valueController.text.trim()),
]);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final issue = _attempted && !_isBatch ? _issue() : null;
return FormPage(
title: _isBatch
? appLocalizations.batchAdd
: widget.entry == null
? appLocalizations.add
: appLocalizations.edit,
saveLabel: appLocalizations.confirm,
onSave: _handleSubmit,
error: _isBatch
? _batchInput.errorOf(
appLocalizations,
_parsed,
attempted: _attempted,
)
: issue?.message,
actions: [
if (widget.entry == null)
IconButtonData(
glyph: _isBatch ? AppGlyphs.textShort : AppGlyphs.listAdd,
tooltip: _isBatch
? appLocalizations.singleAdd
: appLocalizations.batchAdd,
onPressed: _toggleBatch,
),
],
children: [
_isBatch
? BatchSection(
batch: _batchInput,
controller: _batchController,
parsed: _parsed,
onChanged: _handleBatchChanged,
)
: _buildFields(appLocalizations, issue?.field),
],
);
}
Widget _buildFields(
AppLocalizations appLocalizations,
_MapEntryField? invalid,
) {
final keyMaxLength = widget.keyMaxLength;
final valueMaxLength = widget.valueMaxLength;
return generateSection(
items: [
FormRow(
title: _keyLabel,
invalid: invalid == _MapEntryField.key,
trailing: FormRowTextField(
controller: _keyController,
autofocus: true,
hintText: appLocalizations.required,
textInputAction: TextInputAction.next,
autocorrect: false,
inputFormatters: keyMaxLength == null
? null
: TextInputLimits.limit(keyMaxLength),
onChanged: _handleChanged,
onFieldSubmitted: (_) => _valueFocusNode.requestFocus(),
),
),
FormRow(
title: _valueLabel,
invalid: invalid == _MapEntryField.value,
trailing: FormRowTextField(
controller: _valueController,
focusNode: _valueFocusNode,
hintText: appLocalizations.required,
textInputAction: TextInputAction.done,
autocorrect: false,
inputFormatters: valueMaxLength == null
? null
: TextInputLimits.limit(valueMaxLength),
onChanged: _handleChanged,
onFieldSubmitted: (_) => _handleSubmit(),
),
),
],
);
}
}