feat(ui): present dialogs as iOS alerts and forms as sheets

Changelog: iOS-style alerts and form sheets
This commit is contained in:
chen08209
2026-10-16 14:55:07 +08:00
parent f5a5f0074b
commit 7fe3f21aa4
54 changed files with 3172 additions and 1718 deletions
+22 -49
View File
@@ -10,7 +10,24 @@ import 'package:url_launcher/url_launcher.dart';
class Dialogs {
Dialogs._();
BuildContext get _context => rootNavigatorKey.currentContext!;
// Focus sits in the route the user acts in, so an alert raised from a form
// sheet, or a page pushed inside one, opens over it, beside the navigation
// as the sheet sits.
BuildContext get _context {
var context = FocusManager.instance.primaryFocus?.context;
while (context != null) {
final route = ModalRoute.of(context);
if (route is AlertHostRoute) {
final host = route.navigator;
if (host != null && host.mounted) {
return host.context;
}
break;
}
context = route?.navigator?.context;
}
return rootNavigatorKey.currentContext!;
}
Future<T?> showCommonDialog<T>({
required Widget child,
@@ -32,6 +49,7 @@ class Dialogs {
String? cancelText,
bool cancelable = true,
bool? dismissible,
bool destructive = false,
}) async {
return showCommonDialog<bool>(
context: context,
@@ -41,6 +59,7 @@ class Dialogs {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: title ?? appLocalizations.tip,
destructive: destructive,
actions: [
if (cancelable)
TextButton(
@@ -57,10 +76,7 @@ class Dialogs {
),
],
child: SelectableText.rich(
TextSpan(
style: AppTextStyle.subheadline.of(context),
children: [message],
),
TextSpan(children: [message]),
style: const TextStyle(overflow: TextOverflow.visible),
),
);
@@ -123,49 +139,6 @@ class Dialogs {
);
}
Future<NamedUrl?> showNamedUrlInput({
required String title,
String label = '',
String url = '',
FormFieldValidator<String>? labelValidator,
FormFieldValidator<String>? urlValidator,
}) async {
final res = await showCommonDialog<List<NamedUrl>>(
child: NamedUrlDialog(
title: title,
label: label,
url: url,
labelValidator: labelValidator,
urlValidator: urlValidator,
),
);
return res?.single;
}
/// A batch skips the urls in [existingUrls].
Future<List<NamedUrl>?> showNamedUrlsInput({
required String title,
String? urlLabel,
String? batchTip,
FormFieldValidator<String>? labelValidator,
FormFieldValidator<String>? urlValidator,
Set<String> existingUrls = const {},
bool withAgeSecretKey = false,
}) {
return showCommonDialog<List<NamedUrl>>(
child: NamedUrlDialog(
title: title,
urlLabel: urlLabel,
batchTip: batchTip,
labelValidator: labelValidator,
urlValidator: urlValidator,
batch: true,
existingUrls: existingUrls,
withAgeSecretKey: withAgeSecretKey,
),
);
}
void showNotifier(
String text, {
MessageLevel level = MessageLevel.info,
@@ -235,7 +208,7 @@ class _FailureDetailsSheet extends StatelessWidget {
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 20),
itemCount: groups.length,
separatorBuilder: (_, _) => SizedBox(height: 24.mAp),
separatorBuilder: (_, _) => SizedBox(height: 24.mAp(context)),
itemBuilder: (_, index) {
final MapEntry(key: message, value: labels) = groups[index];
return generateSection(
+50
View File
@@ -0,0 +1,50 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
/// A form's batch mode: one field of lines, and how many of them it adds.
class BatchSection<T> extends StatelessWidget {
final BatchInput<T> batch;
final TextEditingController controller;
final ParsedInput<T>? parsed;
final ValueChanged<String> onChanged;
const BatchSection({
super.key,
required this.batch,
required this.controller,
required this.parsed,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final parsed = this.parsed;
return generateSection(
footer: parsed == null || controller.text.trim().isEmpty
? null
: context.appLocalizations.batchPreviewTip(
parsed.entries.length,
parsed.skippedExisting,
),
items: [
DecorationListItem(
title: TextField(
controller: controller,
autofocus: true,
minLines: 4,
maxLines: null,
keyboardType: TextInputType.multiline,
autocorrect: false,
enableSuggestions: false,
onChanged: onChanged,
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: batch.formatTip,
),
),
),
],
);
}
}
+4
View File
@@ -1,5 +1,9 @@
export 'list_editor_page.dart';
export 'form_row.dart';
export 'form_page.dart';
export 'named_url_form.dart';
export 'map_edit_view.dart';
export 'batch_section.dart';
export 'custom_issue.dart';
export 'nested_form_sheet.dart';
export 'selection_sheet.dart';
+53
View File
@@ -0,0 +1,53 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:material_ui/material_ui.dart';
import 'custom_issue.dart';
/// A form [showFormSheet] opens: grouped rows under a bar whose check saves,
/// with why the last save was refused above them.
class FormPage extends StatelessWidget {
final String title;
final VoidCallback onSave;
final String? saveLabel;
final List<IconButtonData> actions;
final String? error;
final bool saving;
final List<Widget> children;
const FormPage({
super.key,
required this.title,
required this.onSave,
this.saveLabel,
this.actions = const [],
this.error,
this.saving = false,
required this.children,
});
@override
Widget build(BuildContext context) {
return CommonScaffold(
title: title,
iconActions: actions,
confirmationAction: IconButtonData(
glyph: AppGlyphs.check,
tooltip: saveLabel ?? context.appLocalizations.save,
isLoading: saving,
onPressed: saving ? null : onSave,
),
body: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 20),
children: [
ErrorBanner(message: error),
...children,
],
),
);
}
}
+164 -2
View File
@@ -2,6 +2,7 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
class InfoMessageButton extends StatelessWidget {
@@ -20,7 +21,7 @@ class InfoMessageButton extends StatelessWidget {
icon: GlyphIcon(
AppGlyphs.info,
fill: 1,
size: 20.ap,
size: 20.ap(context),
color: context.colorScheme.error,
),
),
@@ -112,7 +113,7 @@ class FormRow extends StatelessWidget {
Flexible(
child: IconTheme(
data: IconThemeData(
size: 16.ap,
size: 16.ap(context),
color: context.colorScheme.onSurface.opacity60,
),
child: Container(
@@ -133,3 +134,164 @@ class FormRow extends StatelessWidget {
);
}
}
/// A bare text field set against the end of a [FormRow], as iOS and macOS set
/// one in a grouped form; the row's focus ring is the only outline it gets.
class FormRowTextField extends StatelessWidget {
static const _cursorWidth = 2.0;
// RenderEditable's caret gap plus cursor, which it keeps clear past the text.
static const _caretMargin = 1 + _cursorWidth;
final TextEditingController? controller;
final String? initialValue;
final FocusNode? focusNode;
final String? hintText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final List<TextInputFormatter>? inputFormatters;
final bool obscureText;
final bool autofocus;
final bool autocorrect;
final bool enableSuggestions;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onFieldSubmitted;
const FormRowTextField({
super.key,
this.controller,
this.initialValue,
this.focusNode,
this.hintText,
this.keyboardType,
this.textInputAction,
this.inputFormatters,
this.obscureText = false,
this.autofocus = false,
this.autocorrect = true,
this.enableSuggestions = true,
this.onChanged,
this.onFieldSubmitted,
});
@override
Widget build(BuildContext context) {
final towardEnd = switch (Directionality.of(context)) {
TextDirection.ltr => 1.0,
TextDirection.rtl => -1.0,
};
return InputDecorationTheme(
data: InputDecorationTheme.of(
context,
).copyWith(floatingLabelBehavior: FloatingLabelBehavior.never),
child: Transform.translate(
offset: Offset(_caretMargin * towardEnd, 0),
child: _buildField(context),
),
);
}
Widget _buildField(BuildContext context) {
final hintText = this.hintText;
return TextFormField(
controller: controller,
initialValue: initialValue,
focusNode: focusNode,
keyboardType: keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
obscureText: obscureText,
autofocus: autofocus,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
textAlign: TextAlign.end,
cursorWidth: _cursorWidth,
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: null,
hint: hintText == null
? null
: Padding(
padding: const EdgeInsetsDirectional.only(end: _caretMargin),
child: Text(
hintText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: AppTextStyle.body
.of(context)
.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
),
);
}
}
/// A [FormRowTextField] for a secret, with the button that shows it.
class FormRowSecretTextField extends StatefulWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final String? hintText;
final TextInputAction? textInputAction;
final List<TextInputFormatter>? inputFormatters;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onFieldSubmitted;
const FormRowSecretTextField({
super.key,
required this.controller,
this.focusNode,
this.hintText,
this.textInputAction,
this.inputFormatters,
this.onChanged,
this.onFieldSubmitted,
});
@override
State<FormRowSecretTextField> createState() => _FormRowSecretTextFieldState();
}
class _FormRowSecretTextFieldState extends State<FormRowSecretTextField> {
bool _obscure = true;
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: FormRowTextField(
controller: widget.controller,
focusNode: widget.focusNode,
hintText: widget.hintText,
obscureText: _obscure,
textInputAction: widget.textInputAction,
autocorrect: false,
enableSuggestions: false,
inputFormatters: widget.inputFormatters,
onChanged: widget.onChanged,
onFieldSubmitted: widget.onFieldSubmitted,
),
),
CommonMinIconButtonTheme(
child: IconButton(
tooltip: _obscure
? appLocalizations.showPassword
: appLocalizations.hidePassword,
icon: GlyphIcon(_obscure ? AppGlyphs.eye : AppGlyphs.eyeOff),
onPressed: () {
setState(() {
_obscure = !_obscure;
});
},
),
),
],
);
}
}
+319
View File
@@ -0,0 +1,319 @@
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,
enableSuggestions: 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,
enableSuggestions: false,
inputFormatters: valueMaxLength == null
? null
: TextInputLimits.limit(valueMaxLength),
onChanged: _handleChanged,
onFieldSubmitted: (_) => _handleSubmit(),
),
),
],
);
}
}
+325
View File
@@ -0,0 +1,325 @@
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 'batch_section.dart';
import 'form_page.dart';
import 'form_row.dart';
typedef NamedUrl = ({String label, String url, String ageSecretKey});
Future<NamedUrl?> showNamedUrlForm(
BuildContext context, {
required String title,
String label = '',
String url = '',
FormFieldValidator<String>? labelValidator,
FormFieldValidator<String>? urlValidator,
}) async {
final res = await showFormSheet<List<NamedUrl>>(
context: context,
builder: (_) => NamedUrlForm(
title: title,
label: label,
url: url,
labelValidator: labelValidator,
urlValidator: urlValidator,
),
);
return res?.single;
}
/// A batch skips the urls in [existingUrls].
Future<List<NamedUrl>?> showNamedUrlsForm(
BuildContext context, {
required String title,
String? urlLabel,
String? batchTip,
FormFieldValidator<String>? labelValidator,
FormFieldValidator<String>? urlValidator,
Set<String> existingUrls = const {},
bool withAgeSecretKey = false,
}) {
return showFormSheet<List<NamedUrl>>(
context: context,
builder: (_) => NamedUrlForm(
title: title,
urlLabel: urlLabel,
batchTip: batchTip,
labelValidator: labelValidator,
urlValidator: urlValidator,
batch: true,
existingUrls: existingUrls,
withAgeSecretKey: withAgeSecretKey,
),
);
}
enum _NamedUrlField { label, url }
/// The url below the name holds the focus, as the one field that must be filled.
/// Pops a list so a single url and a batch return through the same path.
class NamedUrlForm extends StatefulWidget {
final String title;
final String label;
final String url;
final String? urlLabel;
final String? batchTip;
final FormFieldValidator<String>? labelValidator;
final FormFieldValidator<String>? urlValidator;
final bool batch;
final Set<String> existingUrls;
final bool withAgeSecretKey;
const NamedUrlForm({
super.key,
required this.title,
this.label = '',
this.url = '',
this.urlLabel,
this.batchTip,
this.labelValidator,
this.urlValidator,
this.batch = false,
this.existingUrls = const {},
this.withAgeSecretKey = false,
});
@override
State<NamedUrlForm> createState() => _NamedUrlFormState();
}
class _NamedUrlFormState extends State<NamedUrlForm> {
final _urlFocusNode = FocusNode();
final _ageSecretKeyFocusNode = FocusNode();
late final TextEditingController _labelController;
late final TextEditingController _urlController;
final _ageSecretKeyController = TextEditingController();
final _batchController = TextEditingController();
bool _isBatch = false;
bool _attempted = false;
ParsedInput<String>? _parsed;
String get _urlLabel => widget.urlLabel ?? context.appLocalizations.url;
BatchInput<String> get _batchInput {
final appLocalizations = context.appLocalizations;
return BatchInput(
label: _urlLabel,
formatTip: widget.batchTip ?? appLocalizations.batchUrlInputTip,
parse: (text) => parseUrlInput(
text,
isValid: (url) => _validateBatchUrl(url) == null,
existing: widget.existingUrls,
),
issueMessage: (issue) => _validateBatchUrl(issue.raw)!,
);
}
@override
void initState() {
super.initState();
_labelController = TextEditingController(text: widget.label);
_urlController = TextEditingController(text: widget.url);
}
@override
void dispose() {
_urlFocusNode.dispose();
_ageSecretKeyFocusNode.dispose();
_labelController.dispose();
_urlController.dispose();
_ageSecretKeyController.dispose();
_batchController.dispose();
super.dispose();
}
String? _validateUrl(String? value) {
final validator = widget.urlValidator;
if (validator != null) {
return validator(value);
}
final appLocalizations = context.appLocalizations;
final url = value?.trim() ?? '';
if (url.isEmpty) {
return appLocalizations.emptyTip(_urlLabel);
}
if (!url.isUrl) {
return appLocalizations.urlTip(_urlLabel);
}
return null;
}
String? _validateBatchUrl(String url) {
if (url.length > TextInputLimits.url) {
final appLocalizations = context.appLocalizations;
return appLocalizations.maxLengthTip(_urlLabel, TextInputLimits.url);
}
return _validateUrl(url);
}
({_NamedUrlField field, String message})? _issue() {
final labelError = widget.labelValidator?.call(_labelController.text);
if (labelError != null) {
return (field: _NamedUrlField.label, message: labelError);
}
final urlError = _validateUrl(_urlController.text);
if (urlError != null) {
return (field: _NamedUrlField.url, message: urlError);
}
return null;
}
void _toggleBatch() {
setState(() {
_isBatch = !_isBatch;
_attempted = false;
if (_isBatch && _batchController.text.isEmpty) {
_batchController.text = _urlController.text.trim();
}
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<NamedUrl>>([
for (final url in parsed.entries)
(label: '', url: url, ageSecretKey: ''),
]);
return;
}
if (_issue() != null) {
setState(() {
_attempted = true;
});
return;
}
Navigator.of(context).pop<List<NamedUrl>>([
(
label: _labelController.text.trim(),
url: _urlController.text.trim(),
ageSecretKey: _ageSecretKeyController.text.trim(),
),
]);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final issue = _attempted && !_isBatch ? _issue() : null;
return FormPage(
title: _isBatch ? appLocalizations.batchImport : widget.title,
saveLabel: appLocalizations.submit,
onSave: _handleSubmit,
error: _isBatch
? _batchInput.errorOf(
appLocalizations,
_parsed,
attempted: _attempted,
)
: issue?.message,
actions: [
if (widget.batch)
IconButtonData(
glyph: _isBatch ? AppGlyphs.textShort : AppGlyphs.listAdd,
tooltip: _isBatch
? appLocalizations.singleImport
: appLocalizations.batchImport,
onPressed: _toggleBatch,
),
],
children: [
_isBatch
? BatchSection(
batch: _batchInput,
controller: _batchController,
parsed: _parsed,
onChanged: _handleBatchChanged,
)
: _buildFields(appLocalizations, issue?.field),
],
);
}
Widget _buildFields(
AppLocalizations appLocalizations,
_NamedUrlField? invalid,
) {
return generateSection(
items: [
FormRow(
title: appLocalizations.name,
invalid: invalid == _NamedUrlField.label,
trailing: FormRowTextField(
controller: _labelController,
hintText: appLocalizations.optional,
textInputAction: TextInputAction.next,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
onChanged: _handleChanged,
onFieldSubmitted: (_) => _urlFocusNode.requestFocus(),
),
),
FormRow(
title: _urlLabel,
invalid: invalid == _NamedUrlField.url,
trailing: FormRowTextField(
controller: _urlController,
focusNode: _urlFocusNode,
autofocus: true,
hintText: appLocalizations.required,
keyboardType: TextInputType.url,
textInputAction: widget.withAgeSecretKey
? TextInputAction.next
: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
onChanged: _handleChanged,
onFieldSubmitted: (_) {
if (widget.withAgeSecretKey) {
_ageSecretKeyFocusNode.requestFocus();
} else {
_handleSubmit();
}
},
),
),
if (widget.withAgeSecretKey)
FormRow(
title: appLocalizations.ageSecretKey,
trailing: FormRowSecretTextField(
controller: _ageSecretKeyController,
focusNode: _ageSecretKeyFocusNode,
hintText: appLocalizations.optional,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _handleSubmit(),
),
),
],
);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ Future<void> showNestedFormSheet<T>({
required T Function(WidgetRef ref) currentOf,
required WidgetBuilder formBuilder,
}) {
return showSheet(
return showFormSheet(
context: context,
props: nestedPagedSheetProps,
builder: (_) {
+155 -274
View File
@@ -1,6 +1,5 @@
library;
import 'package:collection/collection.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/clash_config.dart';
@@ -9,6 +8,9 @@ import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'form_page.dart';
import 'form_row.dart';
const _ruleRowVerticalPadding = 7.0;
/// Rule lists run to thousands of rows, so a rule row is padded tighter than
@@ -88,7 +90,7 @@ class RuleItem extends StatelessWidget {
icon: GlyphIcon(
AppGlyphs.info,
fill: 1,
size: 16.ap,
size: 16.ap(context),
color: context.colorScheme.error,
),
),
@@ -241,310 +243,189 @@ class RuleStatusItem extends StatelessWidget {
}
}
class AddOrEditRuleDialog extends StatefulWidget {
enum _RuleField { content, target }
class RuleForm extends StatefulWidget {
final Rule? rule;
const AddOrEditRuleDialog({super.key, this.rule});
const RuleForm({super.key, this.rule});
@override
State<AddOrEditRuleDialog> createState() => _AddOrEditRuleDialogState();
State<RuleForm> createState() => _RuleFormState();
}
class _AddOrEditRuleDialogState extends State<AddOrEditRuleDialog> {
class _RuleFormState extends State<RuleForm> {
late RuleAction _ruleAction;
String _ruleTarget = '';
final _ruleTargetController = TextEditingController();
final _contentController = TextEditingController();
bool _noResolve = false;
bool _src = false;
List<DropdownMenuEntry> _targetItems = [];
final _formKey = GlobalKey<FormState>();
bool _initialized = false;
late String _ruleTarget;
late final TextEditingController _contentController;
late bool _noResolve;
late bool _src;
bool _attempted = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) {
return;
}
_initialized = true;
_initState();
}
void _initState() {
_targetItems = [
...RuleTarget.ruleTargetNames.map(
(item) => DropdownMenuEntry(value: item, label: item),
),
DropdownMenuEntry(
value: RuleAction.MATCH.value,
label: context.appLocalizations.matchTarget,
),
];
void initState() {
super.initState();
final rule = widget.rule;
if (rule != null) {
_ruleAction = rule.ruleAction;
_contentController.text = rule.content ?? '';
_selectTarget(rule.ruleTarget ?? '');
_noResolve = rule.noResolve;
_src = rule.src;
return;
}
_ruleAction = RuleAction.addedRuleActions.first;
if (_targetItems.isNotEmpty) {
_selectTarget(_targetItems.first.value);
}
}
void _selectTarget(String value) {
_ruleTarget = value;
_ruleTargetController.text =
_targetItems.firstWhereOrNull((item) => item.value == value)?.label ??
value;
}
@override
void didUpdateWidget(AddOrEditRuleDialog oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.rule != widget.rule) {
_initState();
}
_ruleAction = rule?.ruleAction ?? RuleAction.addedRuleActions.first;
_ruleTarget = rule?.ruleTarget ?? RuleTarget.ruleTargetNames.first;
_contentController = TextEditingController(text: rule?.content);
_noResolve = rule?.noResolve ?? false;
_src = rule?.src ?? false;
}
@override
void dispose() {
_ruleTargetController.dispose();
_contentController.dispose();
super.dispose();
}
({_RuleField field, String message})? _issue() {
final appLocalizations = context.appLocalizations;
final content = _contentController.text;
if (content.isEmpty) {
return (
field: _RuleField.content,
message: appLocalizations.emptyTip(appLocalizations.content),
);
}
final payloadError = Rule(
ruleAction: _ruleAction,
content: content,
).payloadError;
if (payloadError != null) {
return (
field: _RuleField.content,
message: payloadError.getMessage(context),
);
}
if (_ruleTarget.isEmpty) {
return (
field: _RuleField.target,
message: appLocalizations.splitStrategyNotEmpty,
);
}
return null;
}
void _handleSubmit() {
final res = _formKey.currentState?.validate();
if (res == false) {
if (_issue() != null) {
setState(() {
_attempted = true;
});
return;
}
final rule = Rule(
id: widget.rule?.id ?? snowflake.id,
ruleAction: _ruleAction,
content: _contentController.text,
ruleTarget: _ruleTarget,
noResolve: _noResolve,
src: _src,
Navigator.of(context).pop(
Rule(
id: widget.rule?.id ?? snowflake.id,
ruleAction: _ruleAction,
content: _contentController.text,
ruleTarget: _ruleTarget,
noResolve: _noResolve,
src: _src,
),
);
}
String _targetLabel(String target) => target == RuleAction.MATCH.value
? context.appLocalizations.matchTarget
: target;
Widget _buildSwitchRow({
required String title,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return FormRow(
title: title,
onPressed: () => onChanged(!value),
trailing: Switch(value: value, onChanged: onChanged),
);
Navigator.of(context).pop(rule);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
final issue = _attempted ? _issue() : null;
final targets = [...RuleTarget.ruleTargetNames, RuleAction.MATCH.value];
return FormPage(
title: widget.rule != null
? appLocalizations.editRule
: appLocalizations.addRule,
actions: [
TextButton(
onPressed: _handleSubmit,
child: Text(appLocalizations.confirm),
),
],
child: DropdownMenuTheme(
data: DropdownMenuThemeData(
inputDecorationTheme: InputDecorationTheme.of(context).copyWith(
labelStyle: AppTextStyle.body
.of(context)
.copyWith(overflow: TextOverflow.ellipsis),
),
),
child: Form(
key: _formKey,
child: LayoutBuilder(
builder: (_, constraints) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommonPopupBox(
targetBuilder: (open) => TonalButtonGroup(
size: TonalButtonSize.section,
children: [
FilledButton(
onPressed: open,
child: PopupValueText(_ruleAction.value),
),
],
),
popupBuilder: (_) => CommonPopupMenu(
items: [
for (final action in RuleAction.addedRuleActions)
CommonPopupMenuItem(
label: action.value,
checked: action == _ruleAction,
onPressed: () {
setState(() {
_ruleAction = action;
});
},
),
],
),
),
const SizedBox(height: 24),
_RuleContentField(
controller: _contentController,
ruleAction: _ruleAction,
onSubmitted: _handleSubmit,
),
const SizedBox(height: 24),
_RuleTargetField(
controller: _ruleTargetController,
entries: _targetItems,
onSelected: (value) {
if (value != null) {
_selectTarget(value);
}
},
),
if (_ruleAction.hasParams) ...[
const SizedBox(height: 20),
Wrap(
spacing: 8,
children: [
_RuleFlagChip(
label: appLocalizations.sourceIp,
isSelected: _src,
onPressed: () {
setState(() {
_src = !_src;
});
},
),
_RuleFlagChip(
label: appLocalizations.noResolve,
isSelected: _noResolve,
onPressed: () {
setState(() {
_noResolve = !_noResolve;
});
},
),
],
),
],
const SizedBox(height: 20),
],
);
},
),
),
),
);
}
}
class _RuleContentField extends StatelessWidget {
const _RuleContentField({
required this.controller,
required this.ruleAction,
required this.onSubmitted,
});
final TextEditingController controller;
final RuleAction ruleAction;
final VoidCallback onSubmitted;
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return TextFormField(
keyboardType: TextInputType.text,
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
onFieldSubmitted: (_) {
onSubmitted();
},
controller: controller,
decoration: InputDecoration(labelText: appLocalizations.content),
validator: (_) {
if (controller.text.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.content);
}
return Rule(
ruleAction: ruleAction,
content: controller.text,
).payloadError?.getMessage(context);
},
);
}
}
class _RuleTargetField extends StatelessWidget {
const _RuleTargetField({
required this.controller,
required this.entries,
required this.onSelected,
});
final TextEditingController controller;
final List<DropdownMenuEntry> entries;
final ValueChanged<String?> onSelected;
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return FormField<String>(
validator: (_) {
if (controller.text.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.ruleTarget);
}
return null;
},
builder: (filed) {
return DropdownMenu(
controller: controller,
label: Text(appLocalizations.ruleTarget),
width: 200,
menuHeight: 250,
enableFilter: false,
enableSearch: false,
trailingIcon: const GlyphIcon(AppGlyphs.chevronDown, size: 20),
selectedTrailingIcon: const GlyphIcon(AppGlyphs.chevronUp, size: 20),
dropdownMenuEntries: entries,
onSelected: (value) => onSelected(value as String?),
errorText: filed.errorText,
);
},
);
}
}
class _RuleFlagChip extends StatelessWidget {
const _RuleFlagChip({
required this.label,
required this.isSelected,
required this.onPressed,
});
final String label;
final bool isSelected;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return CommonCard(
radius: AppCorner.sm,
isSelected: isSelected,
fillsSelection: true,
onPressed: onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Text(
label,
style: AppTextStyle.subheadline
.of(context)
.copyWith(
color: isSelected ? context.colorScheme.onPrimary : null,
onSave: _handleSubmit,
error: issue?.message,
children: [
generateSection(
title: appLocalizations.basicInfo,
items: [
ListItem<RuleAction>.options(
title: Text(appLocalizations.proxyType),
options: RuleAction.addedRuleActions,
value: _ruleAction,
textBuilder: (action) => action.value,
onChanged: (action) {
setState(() {
_ruleAction = action;
});
},
),
FormRow(
title: appLocalizations.content,
invalid: issue?.field == _RuleField.content,
trailing: FormRowTextField(
controller: _contentController,
hintText: appLocalizations.inputRuleContent,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
onChanged: (_) {
if (_attempted) {
setState(() {});
}
},
onFieldSubmitted: (_) => _handleSubmit(),
),
),
ListItem<String>.options(
title: Text(appLocalizations.splitStrategy),
options: [
if (!targets.contains(_ruleTarget)) _ruleTarget,
...targets,
],
value: _ruleTarget,
textBuilder: _targetLabel,
onChanged: (target) {
setState(() {
_ruleTarget = target;
});
},
),
],
),
),
if (_ruleAction.hasParams)
generateSection(
title: appLocalizations.additionalParameters,
items: [
_buildSwitchRow(
title: appLocalizations.noResolveHostname,
value: _noResolve,
onChanged: (value) {
setState(() {
_noResolve = value;
});
},
),
_buildSwitchRow(
title: appLocalizations.matchSourceIp,
value: _src,
onChanged: (value) {
setState(() {
_src = value;
});
},
),
],
),
],
);
}
}
+3 -7
View File
@@ -98,13 +98,8 @@ class _InitErrorScreenState extends State<InitErrorScreen> {
context: context,
child: Builder(
builder: (context) {
return AlertDialog(
title: Text(appLocalizations.clearData),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Text(appLocalizations.clearDataAndExitTip),
),
actionsPadding: const EdgeInsets.fromLTRB(12, 0, 12, 16),
return CommonDialog(
title: appLocalizations.clearData,
actions: [
TextButton(
onPressed: () {
@@ -119,6 +114,7 @@ class _InitErrorScreenState extends State<InitErrorScreen> {
child: Text(appLocalizations.confirm),
),
],
child: Text(appLocalizations.clearDataAndExitTip),
);
},
),
+6 -8
View File
@@ -2,7 +2,6 @@ import 'package:fl_clash/ui/color.dart';
import 'package:fl_clash/ui/inherited.dart';
import 'package:fl_clash/ui/metrics.dart';
import 'package:fl_clash/ui/scaffold.dart';
import 'package:fl_clash/ui/sheet.dart';
import 'package:fl_clash/ui/typography.dart';
import 'package:fl_clash/ui/view_scope.dart';
import 'package:material_ui/material_ui.dart';
@@ -23,20 +22,19 @@ extension BuildContextExtension on BuildContext {
}
}
bool get isInBottomSheet =>
SheetProvider.of(this)?.type == SheetType.bottomSheet;
bool get isInCompactSheet => SheetProvider.of(this)?.type.isCompact ?? false;
/// Where a page's app bar ends, for a body built outside the scaffold's.
double get appBarInset =>
FloatingBarScope.of(this) ??
(isInBottomSheet
(isInCompactSheet
? sheetAppBarHeight
: MediaQuery.paddingOf(this).top + pageToolbarHeight);
/// A bottom sheet's header already trails its own gap.
/// A compact sheet's header already trails its own gap.
double get contentTopPadding =>
TopInsetScope.of(this) ??
(isInBottomSheet ? sheetAppBarHeight : appBarInset + 12);
(isInCompactSheet ? sheetAppBarHeight : appBarInset + 12);
ColorScheme get colorScheme => Theme.of(this).colorScheme;
@@ -56,7 +54,7 @@ extension BuildContextExtension on BuildContext {
.of(this)
.copyWith(color: colorScheme.onSurfaceVariant);
TextStyle get cardHeaderStyle => AppTextStyle.subheadline
.of(this, emphasized: true)
TextStyle get cardHeaderStyle => AppTextStyle.headline
.of(this)
.copyWith(color: colorScheme.onSurfaceVariant.opacity80);
}
+416 -14
View File
@@ -1,33 +1,202 @@
import 'package:animations/animations.dart';
import 'dart:math' as math;
import 'package:fl_clash/ui/color.dart';
import 'package:fl_clash/ui/context.dart';
import 'package:fl_clash/ui/motion.dart';
import 'package:fl_clash/ui/navigation_dock.dart';
import 'package:fl_clash/ui/shape.dart';
import 'package:fl_clash/ui/surface.dart';
import 'package:fl_clash/ui/view_scope.dart';
import 'package:fl_clash/ui/typography.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
const double _actionGap = 8;
const double _actionInset = 16;
const double _actionHeight = 48;
// UIAlertController on iOS 27 grows in from 1.2 times its size and only fades
// out, both on one critically damped spring that settles in 0.404 s.
const _alertDuration = Duration(milliseconds: 404);
const double _alertEntryScale = 1.2;
final _alertSpring = SpringCurve(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 275),
),
seconds: _alertDuration.inMicroseconds / Duration.microsecondsPerSecond,
);
Future<T?> showFloatingDialog<T>({
required BuildContext context,
required Widget child,
bool dismissible = true,
}) {
return showModal<T>(
useRootNavigator: false,
context: context,
configuration: FadeScaleTransitionConfiguration(
return Navigator.of(context).push<T>(
_AlertRoute<T>(
barrierColor: context.colorScheme.modalScrim,
barrierDismissible: dismissible,
),
builder: (context) => SurfaceScope(
level: SurfaceLevel.floatingOf(context.colorScheme),
child: child,
barrierLabel: dismissible
? MaterialLocalizations.of(context).modalBarrierDismissLabel
: null,
builder: (context) => SurfaceScope(
level: SurfaceLevel.floatingOf(context.colorScheme),
child: child,
),
),
);
}
class _AlertRoute<T> extends PopupRoute<T> with AlertMotionRouteMixin<T> {
_AlertRoute({
required this.barrierColor,
required this.barrierDismissible,
required this.barrierLabel,
required this.builder,
});
@override
final Color barrierColor;
@override
final bool barrierDismissible;
@override
final String? barrierLabel;
final WidgetBuilder builder;
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: SafeArea(child: Builder(builder: builder)),
);
}
}
/// Moves a route, and its barrier, as UIKit moves an alert.
mixin AlertMotionRouteMixin<T> on PopupRoute<T> {
CurvedAnimation? _sprung;
// The barrier fades on the spring both ways, where `barrierCurve` would
// replay it backwards on the way out.
Animation<double> get _spring => _sprung ??= CurvedAnimation(
parent: animation!,
curve: _alertSpring,
reverseCurve: _alertSpring.flipped,
);
VoidCallback? get onBarrierDismiss => null;
@override
Duration get transitionDuration => _alertDuration;
@override
Widget buildModalBarrier() {
final barrierColor = this.barrierColor;
if (offstage || barrierColor == null) {
return ModalBarrier(
dismissible: barrierDismissible,
semanticsLabel: barrierLabel,
barrierSemanticsDismissible: semanticsDismissible,
onDismiss: onBarrierDismiss,
);
}
return AnimatedModalBarrier(
color: _spring.drive(
ColorTween(begin: barrierColor.withValues(alpha: 0), end: barrierColor),
),
dismissible: barrierDismissible,
semanticsLabel: barrierLabel,
barrierSemanticsDismissible: semanticsDismissible,
onDismiss: onBarrierDismiss,
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return _AlertTransition(animation: _spring, child: child);
}
@override
void dispose() {
_sprung?.dispose();
super.dispose();
}
}
class _AlertTransition extends StatefulWidget {
const _AlertTransition({required this.animation, required this.child});
final Animation<double> animation;
final Widget child;
@override
State<_AlertTransition> createState() => _AlertTransitionState();
}
class _AlertTransitionState extends State<_AlertTransition> {
double? _heldScale;
double get _scale =>
_heldScale ??
_alertEntryScale + (1 - _alertEntryScale) * widget.animation.value;
@override
void initState() {
super.initState();
widget.animation.addStatusListener(_handleStatus);
}
@override
void didUpdateWidget(_AlertTransition oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.animation != oldWidget.animation) {
oldWidget.animation.removeStatusListener(_handleStatus);
widget.animation.addStatusListener(_handleStatus);
}
}
@override
void dispose() {
widget.animation.removeStatusListener(_handleStatus);
super.dispose();
}
void _handleStatus(AnimationStatus status) {
_heldScale = status == AnimationStatus.reverse ? _scale : null;
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: widget.animation,
child: AnimatedBuilder(
animation: widget.animation,
builder: (_, child) => Transform.scale(scale: _scale, child: child),
child: widget.child,
),
);
}
}
class CommonDialog extends StatelessWidget {
final String title;
final Widget? trailing;
final Widget? child;
/// Text buttons, laid out as an iOS alert lays out its own: the last one
/// is the default action and is filled, and the first one, the cancel action
/// by convention, ends a stack.
final List<Widget>? actions;
final EdgeInsets? padding;
@@ -42,8 +211,12 @@ class CommonDialog extends StatelessWidget {
@override
Widget build(BuildContext context) {
final size = ViewScope.sizeOf(context);
final actions = this.actions ?? const <Widget>[];
return AlertDialog(
titleTextStyle: AppTextStyle.headline.of(context),
contentTextStyle: AppTextStyle.subheadline
.of(context)
.copyWith(color: context.colorScheme.onSurfaceVariant),
title: trailing == null
? Text(title)
: Row(
@@ -52,14 +225,243 @@ class CommonDialog extends StatelessWidget {
trailing!,
],
),
actions: actions,
actionsPadding: const EdgeInsets.fromLTRB(12, 0, 12, 16),
actions: actions.isEmpty ? null : [AlertActions(actions: actions)],
actionsPadding: const EdgeInsets.fromLTRB(
_actionInset,
0,
_actionInset,
_actionInset,
),
contentPadding: padding,
content: Container(
constraints: const BoxConstraints(maxWidth: 300),
width: size.width - 40,
width: double.infinity,
child: SingleChildScrollView(child: child),
),
);
}
}
/// Text buttons laid out and filled as [CommonDialog] sets its own.
class AlertActions extends StatelessWidget {
const AlertActions({super.key, required this.actions});
final List<Widget> actions;
@override
Widget build(BuildContext context) {
return _AlertActions(
children: [
for (final (index, action) in actions.indexed)
_AlertAction(isDefault: index == actions.length - 1, child: action),
],
);
}
}
class _AlertAction extends StatelessWidget {
const _AlertAction({required this.isDefault, required this.child});
final bool isDefault;
final Widget child;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final fill = context.raisedSurfaceLevel.colorOf(colorScheme);
final foreground = isDefault
? colorScheme.onPrimary
: colorScheme.onSurface;
final child = this.child;
return TextButtonTheme(
data: TextButtonThemeData(
style:
TextButton.styleFrom(
backgroundColor: isDefault ? colorScheme.primary : fill,
foregroundColor: foreground,
disabledBackgroundColor: fill,
disabledForegroundColor: colorScheme.onSurface.withValues(
alpha: 0.38,
),
textStyle: AppTextStyle.body.of(context, emphasized: isDefault),
minimumSize: const Size(0, _actionHeight),
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: AppShape.full,
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
splashFactory: NoSplash.splashFactory,
).copyWith(
overlayColor: WidgetStateProperty<Color?>.fromMap({
WidgetState.focused: foreground.withValues(alpha: 0.1),
WidgetState.pressed: Colors.transparent,
WidgetState.hovered: foreground.withValues(alpha: 0.08),
}),
),
),
child: ElasticPress(
enabled: switch (child) {
ButtonStyleButton(:final enabled) => enabled,
_ => true,
},
child: child,
),
);
}
}
/// Two actions side by side when both labels fit half the width; any other
/// count, or labels too long, stacked in order with the first one at the
/// bottom.
class _AlertActions extends MultiChildRenderObjectWidget {
const _AlertActions({required super.children});
@override
_RenderAlertActions createRenderObject(BuildContext context) {
return _RenderAlertActions(textDirection: Directionality.of(context));
}
@override
void updateRenderObject(
BuildContext context,
_RenderAlertActions renderObject,
) {
renderObject.textDirection = Directionality.of(context);
}
}
class _AlertActionsParentData extends ContainerBoxParentData<RenderBox> {}
class _RenderAlertActions extends RenderBox
with
ContainerRenderObjectMixin<RenderBox, _AlertActionsParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, _AlertActionsParentData> {
_RenderAlertActions({required TextDirection textDirection})
: _textDirection = textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
if (value == _textDirection) {
return;
}
_textDirection = value;
markNeedsLayout();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! _AlertActionsParentData) {
child.parentData = _AlertActionsParentData();
}
}
bool _sideBySide(List<RenderBox> children, double width) =>
children.length == 2 &&
children.every(
(child) =>
child.getMaxIntrinsicWidth(double.infinity) <=
(width - _actionGap) / 2,
);
double _childWidth(bool sideBySide, double width) =>
sideBySide ? (width - _actionGap) / 2 : width;
double _widest(double Function(RenderBox child) widthOf) =>
getChildrenAsList().map(widthOf).fold(0, math.max);
double _heightFor(
double width,
double Function(RenderBox child, double width) heightOf,
) {
final children = getChildrenAsList();
if (children.isEmpty) {
return 0;
}
final sideBySide = _sideBySide(children, width);
final heights = children.map(
(child) => heightOf(child, _childWidth(sideBySide, width)),
);
return sideBySide
? heights.reduce(math.max)
: heights.reduce((a, b) => a + b) + _actionGap * (children.length - 1);
}
@override
double computeMinIntrinsicWidth(double height) =>
_widest((child) => child.getMinIntrinsicWidth(height));
@override
double computeMaxIntrinsicWidth(double height) =>
_widest((child) => child.getMaxIntrinsicWidth(height));
@override
double computeMinIntrinsicHeight(double width) =>
_heightFor(width, (child, width) => child.getMinIntrinsicHeight(width));
@override
double computeMaxIntrinsicHeight(double width) =>
_heightFor(width, (child, width) => child.getMaxIntrinsicHeight(width));
@override
Size computeDryLayout(BoxConstraints constraints) =>
_layout(constraints, ChildLayoutHelper.dryLayoutChild);
@override
void performLayout() {
size = _layout(constraints, ChildLayoutHelper.layoutChild, place: true);
}
Size _layout(
BoxConstraints constraints,
ChildLayouter layoutChild, {
bool place = false,
}) {
final children = getChildrenAsList();
final width = constraints.hasBoundedWidth
? constraints.maxWidth
: _widest((child) => child.getMaxIntrinsicWidth(double.infinity));
final sideBySide = _sideBySide(children, width);
final childConstraints = BoxConstraints.tightFor(
width: _childWidth(sideBySide, width),
);
final sizes = [
for (final child in children) layoutChild(child, childConstraints),
];
final height = sizes.isEmpty
? 0.0
: sideBySide
? math.max(sizes.first.height, sizes.last.height)
: sizes.fold(0.0, (sum, size) => sum + size.height) +
_actionGap * (sizes.length - 1);
if (place) {
final rtl = _textDirection == TextDirection.rtl;
var y = 0.0;
for (var step = 1; step <= children.length; step++) {
final index = step % children.length;
final size = sizes[index];
final parentData =
children[index].parentData! as _AlertActionsParentData;
if (sideBySide) {
final atLeft = (index == 0) != rtl;
parentData.offset = Offset(
atLeft ? 0 : width - size.width,
(height - size.height) / 2,
);
} else {
parentData.offset = Offset(0, y);
y += size.height + _actionGap;
}
}
}
return constraints.constrain(Size(width, height));
}
@override
void paint(PaintingContext context, Offset offset) {
defaultPaint(context, offset);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
+70
View File
@@ -0,0 +1,70 @@
part of 'focus.dart';
/// Whether focus last moved by key, the focus `.agents/surfaces.md` rings.
class FocusMovedByKey extends ChangeNotifier implements ValueListenable<bool> {
FocusMovedByKey._();
static final instance = FocusMovedByKey._();
static final _arrowKeys = {
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowRight,
};
int _adapters = 0;
bool _value = false;
@override
bool get value => _value;
set _moved(bool value) {
if (_value == value) {
return;
}
_value = value;
notifyListeners();
}
void _attach() {
if (_adapters++ > 0) {
return;
}
HardwareKeyboard.instance.addHandler(_handleKey);
GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointer);
}
void _detach() {
if (--_adapters > 0) {
return;
}
HardwareKeyboard.instance.removeHandler(_handleKey);
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointer);
_value = false;
}
static bool get _arrowsMoveCaret =>
FocusManager.instance.primaryFocus?.context
?.findAncestorStateOfType<EditableTextState>() !=
null;
bool _handleKey(KeyEvent event) {
if (_value || event is KeyUpEvent) {
return false;
}
final key = event.logicalKey;
if (key == LogicalKeyboardKey.tab ||
remoteSelectKeys.contains(key) ||
(_arrowKeys.contains(key) && !_arrowsMoveCaret)) {
_moved = true;
}
return false;
}
void _handlePointer(PointerEvent event) {
if (event is PointerDownEvent) {
_moved = false;
}
}
}
+134
View File
@@ -0,0 +1,134 @@
import 'dart:async';
import 'package:fl_clash/ui/dialog.dart';
import 'package:fl_clash/ui/inherited.dart';
import 'package:fl_clash/ui/sheet.dart';
import 'package:fl_clash/ui/sheet_navigator.dart';
import 'package:fl_clash/ui/shape.dart';
import 'package:fl_clash/ui/surface.dart';
import 'package:material_ui/material_ui.dart';
const double _width = 440;
const double _maxHeight = 720;
const double _margin = 40;
/// A form in a card centered over the navigator, moving as a dialog does.
class FormSheetRoute<T> extends PopupRoute<T>
with SheetCoverRouteMixin<T>, AlertMotionRouteMixin<T>, AlertHostRoute<T> {
FormSheetRoute({
required this.builder,
required this.capturedThemes,
required this.barrierColor,
required this.barrierLabel,
this.backgroundColor,
});
final WidgetBuilder builder;
final CapturedThemes capturedThemes;
final Color? backgroundColor;
@override
final Color barrierColor;
@override
final String barrierLabel;
@override
bool get barrierDismissible => true;
final _dismissHandler = ValueNotifier<SheetDismissHandler?>(null);
@override
VoidCallback get onBarrierDismiss =>
() => unawaited(_dismiss());
Future<void> _dismiss() async {
final handler = _dismissHandler.value;
if (handler != null) {
await handler();
} else {
await navigator?.maybePop();
}
}
@override
Widget buildPage(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
) {
return capturedThemes.wrap(
ElevatedSurfaceTheme(
child: _FormSheet(
backgroundColor: backgroundColor,
dismissHandler: _dismissHandler,
builder: builder,
),
),
);
}
@override
void dispose() {
_dismissHandler.dispose();
super.dispose();
}
}
class _FormSheet extends StatelessWidget {
const _FormSheet({
required this.backgroundColor,
required this.dismissHandler,
required this.builder,
});
final Color? backgroundColor;
final ValueNotifier<SheetDismissHandler?> dismissHandler;
final WidgetBuilder builder;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
return Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(
_margin,
).copyWith(bottom: _margin + keyboard),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: _width,
maxHeight: _maxHeight,
),
child: SizedBox(
width: double.infinity,
child: Material(
color:
backgroundColor ??
SurfaceLevel.page.colorOf(theme.colorScheme),
shape: theme.dialogTheme.shape ?? AppShape.xxl,
clipBehavior: Clip.antiAlias,
child: MediaQuery.removeViewInsets(
context: context,
removeBottom: true,
child: SheetProvider(
type: SheetType.formSheet,
child: SheetDismissScope(
handler: dismissHandler,
child: Builder(builder: builder),
),
),
),
),
),
),
),
),
),
);
}
}
+61 -6
View File
@@ -1,7 +1,9 @@
import 'package:fl_clash/ui/context.dart';
import 'package:fl_clash/ui/dialog.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/l10n.dart';
import 'package:fl_clash/ui/shape.dart';
import 'package:fl_clash/ui/surface.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
@@ -74,8 +76,7 @@ class _SecretTextFormFieldState extends State<SecretTextFormField> {
keyboardType: TextInputType.visiblePassword,
maxLines: _obscure ? 1 : widget.maxLines,
minLines: 1,
maxLength: widget.maxLength,
inputFormatters: widget.inputFormatters,
inputFormatters: _limited(widget.inputFormatters, widget.maxLength),
textInputAction: widget.textInputAction,
onFieldSubmitted: widget.onFieldSubmitted,
validator: widget.validator,
@@ -96,6 +97,17 @@ class _SecretTextFormFieldState extends State<SecretTextFormField> {
}
}
// A limit holds as iOS and macOS hold one, without a counter under the field.
List<TextInputFormatter>? _limited(
List<TextInputFormatter>? formatters,
int? maxLength,
) {
if (maxLength == null) {
return formatters;
}
return [...?formatters, LengthLimitingTextInputFormatter(maxLength)];
}
class InputDialog extends StatefulWidget {
final String title;
final String value;
@@ -179,11 +191,11 @@ class _InputDialogState extends State<InputDialog> {
final localizations = UiLocalizations.of(context);
final decoration = InputDecoration(
suffixText: suffixText,
hintText: widget.hintText,
labelText: widget.labelText,
hintText: widget.hintText ?? widget.labelText,
);
return CommonDialog(
title: title,
hasFields: true,
actions: [
ValueListenableBuilder(
valueListenable: _textController,
@@ -226,8 +238,10 @@ class _InputDialogState extends State<InputDialog> {
else
TextFormField(
autofocus: widget.selectAll,
maxLength: widget.maxLength,
inputFormatters: widget.inputFormatters,
inputFormatters: _limited(
widget.inputFormatters,
widget.maxLength,
),
keyboardType: widget.keyboardType ?? TextInputType.url,
maxLines: null,
minLines: 1,
@@ -293,3 +307,44 @@ class NoInputBorder extends InputBorder {
TextDirection? textDirection,
}) {}
}
/// For a field with no grouped row to sit in; `.agents/surfaces.md` has why.
class FilledInputTheme extends StatelessWidget {
const FilledInputTheme({
super.key,
this.borderRadius = AppRadius.full,
this.contentPadding,
required this.child,
});
final BorderRadius borderRadius;
final EdgeInsetsGeometry? contentPadding;
final Widget child;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final shape = AppShape.input.copyWith(borderRadius: borderRadius);
final none = shape.copyWith(borderSide: BorderSide.none);
return InputDecorationTheme(
data: InputDecorationTheme.of(context).copyWith(
filled: true,
fillColor: context.raisedSurfaceLevel.colorOf(colorScheme),
hoverColor: Colors.transparent,
floatingLabelBehavior: FloatingLabelBehavior.never,
contentPadding: contentPadding,
border: none,
enabledBorder: none,
disabledBorder: none,
errorBorder: none,
focusedBorder: shape.copyWith(
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
focusedErrorBorder: shape.copyWith(
borderSide: BorderSide(color: colorScheme.error, width: 2),
),
),
child: child,
);
}
}
+4 -2
View File
@@ -4,6 +4,7 @@ import 'package:collection/collection.dart';
import 'package:fl_clash/common/input_limits.dart';
import 'package:fl_clash/ui/context.dart';
import 'package:fl_clash/ui/dialog.dart';
import 'package:fl_clash/ui/focus.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/inherited.dart';
import 'package:fl_clash/ui/input.dart';
@@ -524,7 +525,7 @@ class ListHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final padding = this.padding ?? listHeaderPadding;
final padding = this.padding ?? listHeaderPaddingOf(context);
final lineHeight = MeasureScope.of(
context,
).textHeight(AppTextStyle.footnote);
@@ -567,7 +568,8 @@ class ListFooter extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 16.mAp, right: 16.mAp, top: 8.mAp),
padding:
const EdgeInsets.only(left: 16, right: 16, top: 8) * 1.mAp(context),
child: Text(text, style: context.listCaptionStyle),
);
}
+17 -3
View File
@@ -27,6 +27,7 @@ class _ListRowSeparator extends SingleChildRenderObjectWidget {
separated: separated,
emphasized: emphasized,
states: states,
remote: RemoteFocusAdapter.isEnabledOf(context),
endIndent: endIndent,
color: _color(context),
textDirection: Directionality.of(context),
@@ -42,6 +43,7 @@ class _ListRowSeparator extends SingleChildRenderObjectWidget {
..separated = separated
..emphasized = emphasized
..states = states
..remote = RemoteFocusAdapter.isEnabledOf(context)
..endIndent = endIndent
..color = _color(context)
..textDirection = Directionality.of(context)
@@ -91,6 +93,7 @@ class _RenderListRowSeparator extends RenderProxyBox {
required bool separated,
required bool emphasized,
required WidgetStatesController states,
required bool remote,
required double endIndent,
required Color color,
required TextDirection textDirection,
@@ -98,6 +101,7 @@ class _RenderListRowSeparator extends RenderProxyBox {
}) : _separated = separated,
_emphasized = emphasized,
_states = states,
_remote = remote,
_endIndent = endIndent,
_color = color,
_textDirection = textDirection,
@@ -147,6 +151,16 @@ class _RenderListRowSeparator extends RenderProxyBox {
_updateHighlighted();
}
bool _remote;
set remote(bool value) {
if (_remote == value) {
return;
}
_remote = value;
_updateHighlighted();
}
double _endIndent;
set endIndent(double value) {
@@ -197,11 +211,9 @@ class _RenderListRowSeparator extends RenderProxyBox {
return true;
}
final states = _states.value;
// InkWell only paints the focus overlay in traditional highlight mode.
return states.contains(WidgetState.pressed) ||
(states.contains(WidgetState.focused) &&
FocusManager.instance.highlightMode ==
FocusHighlightMode.traditional);
(_remote || FocusMovedByKey.instance.value));
}
void _updateHighlighted() {
@@ -253,6 +265,7 @@ class _RenderListRowSeparator extends RenderProxyBox {
void attach(PipelineOwner owner) {
super.attach(owner);
_states.addListener(_updateHighlighted);
FocusMovedByKey.instance.addListener(_updateHighlighted);
_highlighted = _resolveHighlighted();
_PixelRealignment.watch(this);
}
@@ -261,6 +274,7 @@ class _RenderListRowSeparator extends RenderProxyBox {
void detach() {
_PixelRealignment.unwatch(this);
_states.removeListener(_updateHighlighted);
FocusMovedByKey.instance.removeListener(_updateHighlighted);
super.detach();
}
+8 -53
View File
@@ -7,7 +7,6 @@ import 'package:fl_clash/ui/drag_back.dart';
import 'package:fl_clash/ui/keyboard_inset_hold.dart';
import 'package:fl_clash/ui/page_transition.dart';
import 'package:fl_clash/ui/sheet_navigator.dart';
import 'package:fl_clash/ui/surface.dart';
import 'package:flutter/scheduler.dart';
import 'package:material_ui/material_ui.dart';
@@ -184,30 +183,20 @@ class CommonDesktopRoute<T> extends PageRoute<T>
}
class CommonRoute<T> extends PageRoute<T>
with DragBackRouteMixin<T>, DeferredPushRouteMixin<T> {
with
DragBackRouteMixin<T>,
DeferredPushRouteMixin<T>,
SharedXPageRouteMixin<T> {
final Widget Function(BuildContext context) builder;
CommonRoute({required this.builder});
bool _overSettledPage = false;
@override
bool sharesMotionWith(Route<dynamic>? route) => route is CommonRoute;
@override
void didChangePrevious(Route<dynamic>? previousRoute) {
super.didChangePrevious(previousRoute);
_overSettledPage =
previousRoute is CommonRoute &&
previousRoute.animation!.isCompleted &&
previousRoute.secondaryAnimation!.isDismissed;
}
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
@override
bool get maintainState => true;
Color backdropOf(Color surface, {required bool dragging}) =>
dragging ? Colors.transparent : surface;
@override
Widget buildPage(
@@ -221,38 +210,4 @@ class CommonRoute<T> extends PageRoute<T>
child: KeyboardInsetHold(child: builder(context)),
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final dragging = isDragBackActive;
final surface = SurfaceLevel.page.colorOf(context.colorScheme);
return dragBackDetector(
dragBackSlide(
context,
animation,
SharedXPageTransition(
animation: dragging ? kAlwaysCompleteAnimation : animation,
secondaryAnimation: dragging
? kAlwaysDismissedAnimation
: secondaryAnimation,
color: surface,
slide: SharedXPageTransition.pageSlide,
overSettledPage: _overSettledPage,
uncoveredByGesture: navigator!.userGestureInProgress,
child: ColoredBox(
color: dragging ? Colors.transparent : surface,
child: child,
),
),
),
);
}
@override
Duration get transitionDuration => SharedXPageTransition.duration;
}
+65
View File
@@ -1,6 +1,71 @@
import 'package:fl_clash/ui/drag_back.dart';
import 'package:fl_clash/ui/surface.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
/// The motion a pushed page and a page inside a sheet share: a
/// [SharedXPageTransition] that a drag back from the edge takes over.
mixin SharedXPageRouteMixin<T> on PageRoute<T>, DragBackRouteMixin<T> {
bool _overSettledPage = false;
bool sharesMotionWith(Route<dynamic>? route);
Color backdropOf(Color surface, {required bool dragging});
@override
bool get maintainState => true;
@override
Color? get barrierColor => null;
@override
String? get barrierLabel => null;
@override
Duration get transitionDuration => SharedXPageTransition.duration;
@override
void didChangePrevious(Route<dynamic>? previousRoute) {
super.didChangePrevious(previousRoute);
_overSettledPage =
previousRoute is TransitionRoute &&
sharesMotionWith(previousRoute) &&
previousRoute.animation!.isCompleted &&
previousRoute.secondaryAnimation!.isDismissed;
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final surface = SurfaceLevel.page.colorOf(ColorScheme.of(context));
final dragging = isDragBackActive;
return dragBackDetector(
dragBackSlide(
context,
animation,
SharedXPageTransition(
animation: dragging ? kAlwaysCompleteAnimation : animation,
secondaryAnimation: dragging
? kAlwaysDismissedAnimation
: secondaryAnimation,
color: surface,
slide: SharedXPageTransition.pageSlide,
overSettledPage: _overSettledPage,
uncoveredByGesture: navigator!.userGestureInProgress,
child: ColoredBox(
color: backdropOf(surface, dragging: dragging),
child: child,
),
),
),
);
}
}
/// Slides pages by [slide] while they fade. A page fades by laying
/// [color] over it wherever only that color lies beneath, since an opacity
/// layer costs Impeller an offscreen pass of the page every frame.
+7 -58
View File
@@ -19,29 +19,14 @@ class PagedSheetRoute<T> extends PageRoute<T>
with
DragBackRouteMixin<T>,
ResizableNavigatorRouteMixin<T>,
DeferredPushRouteMixin<T> {
DeferredPushRouteMixin<T>,
SharedXPageRouteMixin<T> {
PagedSheetRoute({required this.builder});
final WidgetBuilder builder;
ScrollController? _sheetScroll;
@override
bool get maintainState => true;
/// Whether the page below sat still as this one came in, so it fades out on
/// this route's animation and leaves only the sheet's surface beneath.
bool _overSettledPage = false;
@override
void didChangePrevious(Route<dynamic>? previousRoute) {
super.didChangePrevious(previousRoute);
_overSettledPage =
previousRoute is PagedSheetRoute &&
previousRoute.animation!.isCompleted &&
previousRoute.secondaryAnimation!.isDismissed;
}
@override
void dispose() {
_sheetScroll?.dispose();
@@ -49,16 +34,11 @@ class PagedSheetRoute<T> extends PageRoute<T>
}
@override
Color? get barrierColor => null;
bool sharesMotionWith(Route<dynamic>? route) => route is PagedSheetRoute;
@override
String? get barrierLabel => null;
@override
Duration get transitionDuration => SharedXPageTransition.duration;
@override
Duration get reverseTransitionDuration => SharedXPageTransition.duration;
Color backdropOf(Color surface, {required bool dragging}) =>
dragging ? surface : Colors.transparent;
@override
bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) {
@@ -96,37 +76,6 @@ class PagedSheetRoute<T> extends PageRoute<T>
),
);
}
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final color = _sheetColorOf(context);
final dragging = isDragBackActive;
return dragBackDetector(
dragBackSlide(
context,
animation,
SharedXPageTransition(
animation: dragging ? kAlwaysCompleteAnimation : animation,
secondaryAnimation: dragging
? kAlwaysDismissedAnimation
: secondaryAnimation,
color: color,
slide: SharedXPageTransition.pageSlide,
overSettledPage: _overSettledPage,
uncoveredByGesture: navigator!.userGestureInProgress,
child: ColoredBox(
color: dragging ? color : Colors.transparent,
child: child,
),
),
),
);
}
}
class PagedSheet extends StatelessWidget {
@@ -253,8 +202,8 @@ class _NestedPagedSheetState extends State<NestedPagedSheet> {
child: sheetProvider.copyWith(
nestedNavigatorPop: _popSheet,
onClose: _handlePop,
// A bottom sheet's route takes the taps outside it and the drag away.
child: sheetProvider.type == SheetType.bottomSheet
// A compact sheet's route takes the taps outside it and the drag away.
child: sheetProvider.type.isCompact
? sheet
: SizedBox(
height: double.infinity,
+1
View File
@@ -54,6 +54,7 @@ class _SearchFieldState extends State<SearchField> {
inputFormatters: TextInputLimits.limit(TextInputLimits.search),
onChanged: widget.onChanged,
decoration: InputDecoration(
floatingLabelBehavior: FloatingLabelBehavior.never,
hintText: localizations.search,
prefixIcon: const GlyphIcon(AppGlyphs.search, size: 20),
suffixIcon: value.text.isEmpty
+4 -1
View File
@@ -247,10 +247,13 @@ class AppInputBorder extends InputBorder {
int get hashCode => Object.hash(borderSide, borderRadius, gapPadding);
}
// UIAlertController's corner on iOS 27, rounder than a sheet's.
const double _dialogCorner = 34;
extension AppShapeThemeExt on ThemeData {
ThemeData get withAppShapes => copyWith(
cardTheme: cardTheme.copyWith(shape: AppShape.md),
dialogTheme: dialogTheme.copyWith(shape: AppShape.xxl),
dialogTheme: dialogTheme.copyWith(shape: AppShape.all(_dialogCorner)),
bottomSheetTheme: bottomSheetTheme.copyWith(
shape: AppShape.top(AppCorner.xxl),
),
+57 -20
View File
@@ -7,6 +7,7 @@ import 'package:fl_clash/ui/navigator.dart';
import 'package:fl_clash/ui/view_scope.dart';
import 'package:material_ui/material_ui.dart';
import 'form_sheet.dart';
import 'sheet_navigator.dart';
import 'side_sheet.dart';
import 'snap_sheet.dart';
@@ -27,7 +28,14 @@ class ExtendProps {
const ExtendProps({this.forceFull = false});
}
enum SheetType { page, bottomSheet, sideSheet }
enum SheetType {
page,
bottomSheet,
sideSheet,
formSheet;
bool get isCompact => this == bottomSheet || this == formSheet;
}
const stackedSheetPeek = 14.0;
@@ -39,25 +47,8 @@ Future<T?> showSheet<T>({
required WidgetBuilder builder,
SheetProps props = const SheetProps(),
}) {
final isMobile = context.isMobileView;
final barrierColor = context.colorScheme.modalScrim;
if (isMobile) {
final navigator = sheetNavigatorOf(context);
return navigator.push(
SnapSheetRoute<T>(
builder: (sheetContext, _) => builder(sheetContext),
detents: props.detents,
fitsContent: true,
sheetBarrierColor: barrierColor,
barrierLabel: MaterialLocalizations.of(
context,
).modalBarrierDismissLabel,
capturedThemes: InheritedTheme.capture(
from: context,
to: navigator.context,
),
),
);
if (context.isMobileView) {
return _showBottomSheet<T>(context, builder: builder, props: props);
}
return _showSideSheet<T>(
context,
@@ -66,6 +57,52 @@ Future<T?> showSheet<T>({
);
}
/// Opens a form: a bottom sheet in the mobile layout, and elsewhere a card
/// centered over the navigator, as iPad and macOS present a form sheet.
Future<T?> showFormSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
SheetProps props = const SheetProps(),
}) {
if (context.isMobileView) {
return _showBottomSheet<T>(context, builder: builder, props: props);
}
final navigator = sheetNavigatorOf(context);
return navigator.push(
FormSheetRoute<T>(
builder: builder,
backgroundColor: props.backgroundColor,
barrierColor: context.colorScheme.modalScrim,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
capturedThemes: InheritedTheme.capture(
from: context,
to: navigator.context,
),
),
);
}
Future<T?> _showBottomSheet<T>(
BuildContext context, {
required WidgetBuilder builder,
required SheetProps props,
}) {
final navigator = sheetNavigatorOf(context);
return navigator.push(
SnapSheetRoute<T>(
builder: (sheetContext, _) => builder(sheetContext),
detents: props.detents,
fitsContent: true,
sheetBarrierColor: context.colorScheme.modalScrim,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
capturedThemes: InheritedTheme.capture(
from: context,
to: navigator.context,
),
),
);
}
Future<T?> _showSideSheet<T>(
BuildContext context, {
required WidgetBuilder builder,
+7 -3
View File
@@ -34,21 +34,25 @@ class SheetDragHandle extends StatelessWidget {
}
}
/// A drag handle over [appBar], together exactly [sheetAppBarHeight] tall.
/// A drag handle, or its space, over [appBar], together exactly
/// [sheetAppBarHeight] tall.
class SheetToolBar extends StatelessWidget {
const SheetToolBar({super.key, required this.appBar});
const SheetToolBar({super.key, required this.appBar, this.dragHandle = true});
static const _trailing =
sheetAppBarHeight - SheetDragHandle.height - sheetToolbarHeight;
final Widget appBar;
final bool dragHandle;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetDragHandle(),
dragHandle
? const SheetDragHandle()
: const SizedBox(height: SheetDragHandle.height),
appBar,
const SizedBox(height: _trailing),
],
+1
View File
@@ -18,6 +18,7 @@ export 'effect.dart';
export 'fade_box.dart';
export 'focus.dart';
export 'font.dart';
export 'form_sheet.dart';
export 'inherited.dart';
export 'input.dart';
export 'item_position.dart';
+114 -100
View File
@@ -3,6 +3,8 @@ import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/dav_client.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
@@ -44,8 +46,9 @@ class _BackupAndRestoreState extends ConsumerState<BackupAndRestore>
}
Future<void> _showAddWebDAV(DAVProps? dav) async {
await dialogs.showCommonDialog<String>(
child: WebDAVFormDialog(dav: dav?.copyWith()),
await showFormSheet<void>(
context: context,
builder: (_) => WebDAVForm(dav: dav?.copyWith()),
);
}
@@ -331,21 +334,22 @@ class _RestoreOptionsDialogState extends State<RestoreOptionsDialog> {
}
}
class WebDAVFormDialog extends ConsumerStatefulWidget {
enum _DavField { uri, user, password }
class WebDAVForm extends ConsumerStatefulWidget {
final DAVProps? dav;
const WebDAVFormDialog({super.key, this.dav});
const WebDAVForm({super.key, this.dav});
@override
ConsumerState<WebDAVFormDialog> createState() => _WebDAVFormDialogState();
ConsumerState<WebDAVForm> createState() => _WebDAVFormState();
}
class _WebDAVFormDialogState extends ConsumerState<WebDAVFormDialog> {
late TextEditingController _uriController;
late TextEditingController _userController;
late TextEditingController _passwordController;
final _obscureController = ValueNotifier<bool>(true);
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
class _WebDAVFormState extends ConsumerState<WebDAVForm> {
late final TextEditingController _uriController;
late final TextEditingController _userController;
late final TextEditingController _passwordController;
bool _attempted = false;
@override
void initState() {
@@ -355,8 +359,49 @@ class _WebDAVFormDialogState extends ConsumerState<WebDAVFormDialog> {
_passwordController = TextEditingController(text: widget.dav?.password);
}
@override
void dispose() {
_uriController.dispose();
_userController.dispose();
_passwordController.dispose();
super.dispose();
}
({_DavField field, String message})? _issue(
AppLocalizations appLocalizations,
) {
final uri = _uriController.text;
if (uri.isEmpty || !uri.isUrl) {
return (field: _DavField.uri, message: appLocalizations.addressTip);
}
if (_userController.text.isEmpty) {
return (
field: _DavField.user,
message: appLocalizations.emptyTip(appLocalizations.account),
);
}
if (_passwordController.text.isEmpty) {
return (
field: _DavField.password,
message: appLocalizations.emptyTip(appLocalizations.password),
);
}
return null;
}
void _handleChanged(String _) {
if (_attempted) {
setState(() {});
}
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
if (_issue(context.appLocalizations) != null) {
setState(() {
_attempted = true;
});
return;
}
ref
.read(davSettingProvider.notifier)
.update(
@@ -375,105 +420,74 @@ class _WebDAVFormDialogState extends ConsumerState<WebDAVFormDialog> {
Navigator.pop(context);
}
@override
void dispose() {
_obscureController.dispose();
_uriController.dispose();
_userController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
final issue = _attempted ? _issue(appLocalizations) : null;
return FormPage(
title: appLocalizations.webDAVConfiguration,
actions: [
if (widget.dav != null)
TextButton(onPressed: _delete, child: Text(appLocalizations.delete)),
TextButton(onPressed: _submit, child: Text(appLocalizations.save)),
],
child: Form(
key: _formKey,
child: Wrap(
runSpacing: 16,
children: [
TextFormField(
controller: _uriController,
inputFormatters: TextInputLimits.limit(TextInputLimits.uri),
maxLines: null,
minLines: 1,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
prefixIcon: const GlyphIcon(AppGlyphs.link),
labelText: appLocalizations.address,
helperText: appLocalizations.addressHelp,
onSave: _submit,
error: issue?.message,
children: [
generateSection(
footer: appLocalizations.addressHelp,
items: [
FormRow(
title: appLocalizations.address,
invalid: issue?.field == _DavField.uri,
trailing: FormRowTextField(
controller: _uriController,
hintText: appLocalizations.required,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(TextInputLimits.uri),
onChanged: _handleChanged,
),
validator: (String? value) {
if (value == null || value.isEmpty || !value.isUrl) {
return appLocalizations.addressTip;
}
return null;
},
),
TextFormField(
controller: _userController,
inputFormatters: TextInputLimits.limit(TextInputLimits.userName),
textInputAction: TextInputAction.next,
decoration: InputDecoration(
prefixIcon: const GlyphIcon(AppGlyphs.account),
labelText: appLocalizations.account,
FormRow(
title: appLocalizations.account,
invalid: issue?.field == _DavField.user,
trailing: FormRowTextField(
controller: _userController,
hintText: appLocalizations.required,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: TextInputLimits.limit(
TextInputLimits.userName,
),
onChanged: _handleChanged,
),
validator: (String? value) {
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.account);
}
return null;
},
),
ValueListenableBuilder(
valueListenable: _obscureController,
builder: (_, obscure, _) {
return TextFormField(
controller: _passwordController,
inputFormatters: TextInputLimits.limit(
TextInputLimits.password,
),
obscureText: obscure,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
_submit();
},
decoration: InputDecoration(
prefixIcon: const GlyphIcon(AppGlyphs.password),
suffixIcon: IconButton(
tooltip: obscure
? context.appLocalizations.showPassword
: context.appLocalizations.hidePassword,
icon: GlyphIcon(
obscure ? AppGlyphs.eye : AppGlyphs.eyeOff,
),
onPressed: () {
_obscureController.value = !obscure;
},
),
labelText: appLocalizations.password,
),
validator: (String? value) {
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip(
appLocalizations.password,
);
}
return null;
},
);
},
FormRow(
title: appLocalizations.password,
invalid: issue?.field == _DavField.password,
trailing: FormRowSecretTextField(
controller: _passwordController,
hintText: appLocalizations.required,
textInputAction: TextInputAction.done,
inputFormatters: TextInputLimits.limit(
TextInputLimits.password,
),
onChanged: _handleChanged,
onFieldSubmitted: (_) => _submit(),
),
),
],
),
),
if (widget.dav != null)
generateSection(
items: [
FormRow(
title: appLocalizations.delete,
titleStyle: TextStyle(color: context.colorScheme.error),
onPressed: _delete,
),
],
),
],
);
}
}
+1
View File
@@ -1,5 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/pages/editor.dart';
+77 -66
View File
@@ -23,8 +23,9 @@ class _FiltersViewState extends ConsumerState<FiltersView> {
}
Future<void> _handleAddOrUpdate([Filter? filter]) async {
final res = await dialogs.showCommonDialog<Filter>(
child: _FilterDialog(
final res = await showFormSheet<Filter>(
context: context,
builder: (_) => _FilterForm(
filter: filter,
checkRegex: _checkRegex,
reservedLabels: {
@@ -132,27 +133,29 @@ class _FilterItem extends StatelessWidget {
}
}
class _FilterDialog extends StatefulWidget {
enum _FilterField { label, regex }
class _FilterForm extends StatefulWidget {
final Filter? filter;
final Future<String?> Function(String regex) checkRegex;
final Set<String> reservedLabels;
const _FilterDialog({
const _FilterForm({
required this.filter,
required this.checkRegex,
required this.reservedLabels,
});
@override
State<_FilterDialog> createState() => _FilterDialogState();
State<_FilterForm> createState() => _FilterFormState();
}
class _FilterDialogState extends State<_FilterDialog> {
final _formKey = GlobalKey<FormState>();
class _FilterFormState extends State<_FilterForm> {
late final TextEditingController _labelController;
late final TextEditingController _regexController;
String? _regexError;
bool _checking = false;
bool _attempted = false;
@override
void initState() {
@@ -168,28 +171,41 @@ class _FilterDialogState extends State<_FilterDialog> {
super.dispose();
}
String? _validateLabel(String? value) {
({_FilterField field, String message})? _issue() {
final appLocalizations = context.appLocalizations;
final label = value?.trim() ?? '';
final label = _labelController.text.trim();
if (label.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.label);
return (
field: _FilterField.label,
message: appLocalizations.emptyTip(appLocalizations.label),
);
}
if (widget.reservedLabels.contains(label)) {
return appLocalizations.existsTip(appLocalizations.label);
return (
field: _FilterField.label,
message: appLocalizations.existsTip(appLocalizations.label),
);
}
return null;
}
String? _validateRegex(String? value) {
final appLocalizations = context.appLocalizations;
if (value == null || value.trim().isEmpty) {
return appLocalizations.emptyTip(appLocalizations.regex);
if (_regexController.text.trim().isEmpty) {
return (
field: _FilterField.regex,
message: appLocalizations.emptyTip(appLocalizations.regex),
);
}
if (_regexError case final error?) {
return (field: _FilterField.regex, message: error);
}
return null;
}
Future<void> _handleSubmit() async {
if (_checking || _formKey.currentState?.validate() == false) {
if (_checking) {
return;
}
if (_issue() != null) {
setState(() {
_attempted = true;
});
return;
}
final filter = Filter(
@@ -206,6 +222,7 @@ class _FilterDialogState extends State<_FilterDialog> {
if (mounted) {
setState(() {
_checking = false;
_attempted = true;
_regexError = error;
});
}
@@ -218,62 +235,56 @@ class _FilterDialogState extends State<_FilterDialog> {
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
final issue = _attempted ? _issue() : null;
return FormPage(
title: widget.filter == null
? appLocalizations.add
: appLocalizations.edit,
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(appLocalizations.cancel),
),
TextButton(
onPressed: _checking ? null : _handleSubmit,
child: Text(appLocalizations.confirm),
),
],
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Wrap(
runSpacing: 16,
children: [
TextFormField(
controller: _labelController,
autofocus: widget.filter == null,
validator: _validateLabel,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
decoration: InputDecoration(labelText: appLocalizations.label),
onSave: _handleSubmit,
saving: _checking,
error: issue?.message,
children: [
generateSection(
items: [
FormRow(
title: appLocalizations.label,
invalid: issue?.field == _FilterField.label,
trailing: FormRowTextField(
controller: _labelController,
autofocus: widget.filter == null,
hintText: appLocalizations.required,
textInputAction: TextInputAction.next,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
onChanged: (_) {
if (_attempted) {
setState(() {});
}
},
),
),
TextFormField(
controller: _regexController,
validator: _validateRegex,
forceErrorText: _regexError,
onChanged: (_) {
if (_regexError != null) {
setState(() {
_regexError = null;
});
}
},
minLines: 1,
maxLines: null,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
inputFormatters: TextInputLimits.limit(TextInputLimits.filter),
onFieldSubmitted: (_) => _handleSubmit(),
decoration: InputDecoration(
labelText: appLocalizations.regex,
FormRow(
title: appLocalizations.regex,
invalid: issue?.field == _FilterField.regex,
trailing: FormRowTextField(
controller: _regexController,
hintText: r'(?i)hk|hong\s*kong',
errorMaxLines: 3,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
inputFormatters: TextInputLimits.limit(TextInputLimits.filter),
onChanged: (_) {
if (_attempted || _regexError != null) {
setState(() {
_regexError = null;
});
}
},
onFieldSubmitted: (_) => _handleSubmit(),
),
),
],
),
),
],
);
}
}
+8 -4
View File
@@ -1,5 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -9,7 +10,7 @@ import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
part 'general/port_dialog.dart';
part 'general/port_form.dart';
class LogLevelItem extends ConsumerWidget {
const LogLevelItem({super.key});
@@ -85,8 +86,11 @@ class TestUrlItem extends ConsumerWidget {
class PortItem extends ConsumerWidget {
const PortItem({super.key});
Future<void> handleShowPortDialog() async {
await dialogs.showCommonDialog(child: const _PortDialog());
Future<void> handleShowPortForm(BuildContext context) async {
await showFormSheet<void>(
context: context,
builder: (_) => const _PortForm(),
);
}
@override
@@ -99,7 +103,7 @@ class PortItem extends ConsumerWidget {
title: Text(appLocalizations.port),
subtitle: Text('$mixedPort'),
onTap: () {
handleShowPortDialog();
handleShowPortForm(context);
},
);
}
@@ -15,17 +15,14 @@ class _PortField {
});
}
class _PortDialog extends ConsumerStatefulWidget {
const _PortDialog();
class _PortForm extends ConsumerStatefulWidget {
const _PortForm();
@override
ConsumerState<_PortDialog> createState() => _PortDialogState();
ConsumerState<_PortForm> createState() => _PortFormState();
}
class _PortDialogState extends ConsumerState<_PortDialog> {
final _formKey = GlobalKey<FormState>();
bool _isMore = false;
class _PortFormState extends ConsumerState<_PortForm> {
late final TextEditingController _mixedPortController;
late final TextEditingController _portController;
late final TextEditingController _socksPortController;
@@ -82,6 +79,8 @@ class _PortDialogState extends ConsumerState<_PortDialog> {
Future<void> _handleReset() async {
final res = await dialogs.showMessage(
message: TextSpan(text: context.appLocalizations.resetTip),
destructive: true,
confirmText: context.appLocalizations.reset,
);
if (res != true) {
return;
@@ -102,26 +101,15 @@ class _PortDialogState extends ConsumerState<_PortDialog> {
}
}
Map<_PortField, String> _issues(AppLocalizations appLocalizations) {
return {
for (final field in _fields)
field: ?validatePort(field, field.controller.text, appLocalizations),
};
}
void _handleUpdate() {
final appLocalizations = context.appLocalizations;
final hasInvalid = _fields.any(
(field) =>
validatePort(field, field.controller.text, appLocalizations) != null,
);
if (hasInvalid) {
if (_isMore) {
_formKey.currentState?.validate();
return;
}
setState(() {
_isMore = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_formKey.currentState?.validate();
});
if (_issues(context.appLocalizations).isNotEmpty) {
return;
}
ref
@@ -138,12 +126,6 @@ class _PortDialogState extends ConsumerState<_PortDialog> {
Navigator.of(context).pop();
}
void _handleMore() {
setState(() {
_isMore = !_isMore;
});
}
@visibleForTesting
String? validatePort(
_PortField field,
@@ -166,28 +148,13 @@ class _PortDialogState extends ConsumerState<_PortDialog> {
}
final others = _fields
.where((item) => item != field)
.map((item) => item.controller.text.trim());
if (others.contains(value.trim())) {
.map((item) => int.tryParse(item.controller.text.trim()));
if (others.contains(port)) {
return appLocalizations.portConflictTip;
}
return null;
}
Widget _buildField(_PortField field, AppLocalizations appLocalizations) {
return TextFormField(
keyboardType: TextInputType.number,
maxLines: 1,
minLines: 1,
inputFormatters: TextInputLimits.digitsOnly(TextInputLimits.port),
controller: field.controller,
onFieldSubmitted: (_) {
_handleUpdate();
},
decoration: InputDecoration(labelText: field.label(appLocalizations)),
validator: (value) => validatePort(field, value, appLocalizations),
);
}
@override
void dispose() {
_mixedPortController.dispose();
@@ -201,62 +168,40 @@ class _PortDialogState extends ConsumerState<_PortDialog> {
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
final issues = _issues(appLocalizations);
return FormPage(
title: appLocalizations.port,
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TonalButtonTheme(
size: TonalButtonSize.section,
child: ElasticPress(
child: IconButton(
tooltip: _isMore
? context.appLocalizations.showLess
: context.appLocalizations.showMore,
onPressed: _handleMore,
icon: CommonExpandIcon(expand: _isMore),
onSave: _handleUpdate,
error: issues.values.firstOrNull,
children: [
generateSection(
items: [
for (final field in _fields)
FormRow(
title: field.label(appLocalizations),
invalid: issues.containsKey(field),
trailing: FormRowTextField(
controller: field.controller,
hintText: appLocalizations.required,
keyboardType: TextInputType.number,
textInputAction: field == _fields.last
? TextInputAction.done
: TextInputAction.next,
inputFormatters: TextInputLimits.digitsOnly(
TextInputLimits.port,
),
onChanged: (_) => setState(() {}),
onFieldSubmitted: (_) => _handleUpdate(),
),
),
),
Row(
children: [
TextButton(
onPressed: _handleReset,
child: Text(appLocalizations.reset),
),
const SizedBox(width: 4),
TextButton(
onPressed: _handleUpdate,
child: Text(appLocalizations.submit),
),
],
),
],
),
generateSection(
items: [
FormRow(title: appLocalizations.reset, onPressed: _handleReset),
],
),
],
child: Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
key: _formKey,
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: AnimatedSize(
duration: midDuration,
curve: Curves.easeOutQuad,
alignment: Alignment.topCenter,
child: Column(
spacing: 24,
children: [
_buildField(_fields.first, appLocalizations),
if (_isMore)
..._fields
.skip(1)
.map((field) => _buildField(field, appLocalizations)),
],
),
),
),
),
);
}
}
+5 -2
View File
@@ -32,8 +32,9 @@ class _AddedRulesViewState extends ConsumerState<AddedRulesView> {
}
Future<void> _handleAddOrUpdate([Rule? rule]) async {
final res = await dialogs.showCommonDialog<Rule>(
child: AddOrEditRuleDialog(rule: rule),
final res = await showFormSheet<Rule>(
context: context,
builder: (_) => RuleForm(rule: rule),
);
if (res == null || !mounted) {
return;
@@ -73,6 +74,8 @@ class _AddedRulesViewState extends ConsumerState<AddedRulesView> {
message: TextSpan(
text: appLocalizations.deleteMultipTip(appLocalizations.rule),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res != true || !mounted) {
return;
+2
View File
@@ -87,6 +87,8 @@ class _UserAgentsViewState extends ConsumerState<UserAgentsView> {
message: TextSpan(
text: appLocalizations.deleteTip(appLocalizations.userAgent),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (!mounted || res != true) {
return;
+3
View File
@@ -51,6 +51,7 @@ class DeveloperView extends ConsumerWidget {
final coreAction = ref.read(coreActionProvider.notifier);
final res = await dialogs.showMessage(
message: TextSpan(text: appLocalizations.confirmForceCrashCore),
destructive: true,
);
if (res != true) {
return;
@@ -64,6 +65,8 @@ class DeveloperView extends ConsumerWidget {
final storeAction = ref.read(storeActionProvider.notifier);
final res = await dialogs.showMessage(
message: TextSpan(text: appLocalizations.confirmClearAllData),
destructive: true,
confirmText: appLocalizations.clearData,
);
if (res != true) {
return;
+11 -5
View File
@@ -480,18 +480,24 @@ class _HotKeyRecorderState extends ConsumerState<HotKeyRecorder> {
autofocus: true,
child: CommonDialog(
title: widget.hotKeyAction.action.label,
hasFields: true,
actions: [
if (widget.hotKeyAction.key != null)
TextButton(
onPressed: _handleRemove,
child: Text(appLocalizations.remove),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(appLocalizations.cancel),
),
if (widget.hotKeyAction.key != null)
TextButton(
onPressed: _handleRemove,
style: ButtonStyle(
foregroundColor: WidgetStatePropertyAll(
context.colorScheme.destructive,
),
),
child: Text(appLocalizations.remove),
),
TextButton(
onPressed: isValid ? _handleSave : null,
child: Text(appLocalizations.save),
+22 -129
View File
@@ -1,6 +1,7 @@
part of 'input.dart';
abstract class _EditViewState<W extends ConsumerStatefulWidget, T>
/// Public for `MapEditView`, whose entries are entered in a form.
abstract class EditViewState<W extends ConsumerStatefulWidget, T>
extends ConsumerState<W> {
List<T> _entries = [];
late final List<T> _initialEntries;
@@ -20,9 +21,12 @@ abstract class _EditViewState<W extends ConsumerStatefulWidget, T>
Object popResult(List<T> entries);
@protected
List<T> get entries => _entries;
/// Adds when [entry] is null and edits it otherwise; an add may return
/// several entries from batch mode.
Future<List<T>?> showEntryDialog(T? entry);
Future<List<T>?> showEntryEditor(T? entry);
@override
void initState() {
@@ -51,14 +55,14 @@ abstract class _EditViewState<W extends ConsumerStatefulWidget, T>
}
Future<void> _handleAdd() async {
final entries = await showEntryDialog(null);
final entries = await showEntryEditor(null);
if (!mounted || entries == null || entries.isEmpty) return;
_entries = [..._entries, ...entries];
setState(() {});
}
Future<void> _handleEdit(T entry) async {
final entries = await showEntryDialog(entry);
final entries = await showEntryEditor(entry);
if (!mounted || entries == null || entries.isEmpty) return;
final next = entries.single;
final id = idOf(entry);
@@ -69,7 +73,15 @@ abstract class _EditViewState<W extends ConsumerStatefulWidget, T>
setState(() {});
}
void _handleDelete() {
Future<void> _handleDelete() async {
final res = await dialogs.showMessage(
message: TextSpan(text: context.appLocalizations.deleteMultipTip(title)),
destructive: true,
confirmText: context.appLocalizations.delete,
);
if (res != true || !mounted) {
return;
}
final selected = ref.read(itemsProvider(_selectionKey));
_entries = _entries
.where((entry) => !selected.contains(idOf(entry)))
@@ -81,6 +93,8 @@ abstract class _EditViewState<W extends ConsumerStatefulWidget, T>
Future<void> _handleReset() async {
final res = await dialogs.showMessage(
message: TextSpan(text: context.appLocalizations.resetPageChangesTip),
destructive: true,
confirmText: context.appLocalizations.reset,
);
if (!mounted || res != true) {
return;
@@ -227,7 +241,7 @@ class ListEditView extends ConsumerStatefulWidget {
ConsumerState<ListEditView> createState() => _ListEditViewState();
}
class _ListEditViewState extends _EditViewState<ListEditView, String> {
class _ListEditViewState extends EditViewState<ListEditView, String> {
@override
String get title => widget.title;
@@ -256,7 +270,7 @@ class _ListEditViewState extends _EditViewState<ListEditView, String> {
Object popResult(List<String> entries) => entries;
@override
Future<List<String>?> showEntryDialog(String? entry) {
Future<List<String>?> showEntryEditor(String? entry) {
return showListEntryDialog(
context,
title: widget.title,
@@ -303,7 +317,7 @@ Future<List<String>?> showListEntryDialog(
title: entry == null ? appLocalizations.add : appLocalizations.edit,
valueField: Field(label: label, value: entry ?? '', validator: validator),
valueMaxLength: itemMaxLength,
toEntry: (_, value) => parseListInput(value).entries.single,
toEntry: (value) => parseListInput(value).entries.single,
batch: entry != null
? null
: BatchInput(
@@ -325,124 +339,3 @@ Future<List<String>?> showListEntryDialog(
),
);
}
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, MapEntry<String, String>> {
@override
String get title => widget.title;
@override
List<MapEntry<String, String>> get initialEntries =>
widget.entries.entries.toList();
@override
String idOf(MapEntry<String, String> entry) => entry.key;
@override
Widget buildTitle(MapEntry<String, String> entry) =>
widget.titleBuilder(entry);
@override
Widget? buildSubtitle(MapEntry<String, String> entry) =>
widget.subtitleBuilder?.call(entry);
@override
bool sameEntries(
List<MapEntry<String, String>> a,
List<MapEntry<String, String>> b,
) => stringAndStringMapEntryListEquality.equals(a, b);
@override
Object popResult(List<MapEntry<String, String>> entries) =>
Map<String, String>.fromEntries(entries);
String get _keyLabel => context.appLocalizations.key;
String get _valueLabel => context.appLocalizations.value;
ParsedInput<MapEntry<String, String>> _parseBatch(String text) {
return parseMapInput(
text,
existingKeys: _entries.map(idOf).toSet(),
keyMaxLength: widget.keyMaxLength,
valueMaxLength: widget.valueMaxLength,
);
}
String _issueMessage(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),
};
}
static MapEntry<String, String> _toEntry(String? key, String value) =>
MapEntry(key!, value);
@override
Future<List<MapEntry<String, String>>?> showEntryDialog(
MapEntry<String, String>? entry,
) {
final appLocalizations = context.appLocalizations;
String? keyValidator(String? value) {
final taken = value != entry?.key && _entries.any((e) => e.key == value);
return taken ? appLocalizations.existsTip(_keyLabel) : null;
}
return dialogs.showCommonDialog<List<MapEntry<String, String>>>(
child: EntryDialog<MapEntry<String, String>>(
title: entry == null ? appLocalizations.add : appLocalizations.edit,
keyField: Field(
label: _keyLabel,
value: entry?.key ?? '',
validator: keyValidator,
),
valueField: Field(label: _valueLabel, value: entry?.value ?? ''),
keyMaxLength: widget.keyMaxLength,
valueMaxLength: widget.valueMaxLength,
toEntry: _toEntry,
batch: entry != null
? null
: BatchInput(
label: widget.title,
formatTip: appLocalizations.batchMapInputTip,
parse: _parseBatch,
issueMessage: _issueMessage,
),
),
);
}
}
+63 -345
View File
@@ -9,256 +9,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
part 'edit_view.dart';
typedef NamedUrl = ({String label, String url, String ageSecretKey});
/// The url below the name holds the focus, as the one field that must be filled.
/// Pops a list so a single url and a batch return through the same path.
class NamedUrlDialog extends StatefulWidget {
final String title;
final String label;
final String url;
final String? urlLabel;
final String? batchTip;
final FormFieldValidator<String>? labelValidator;
final FormFieldValidator<String>? urlValidator;
final bool batch;
final Set<String> existingUrls;
final bool withAgeSecretKey;
const NamedUrlDialog({
super.key,
required this.title,
this.label = '',
this.url = '',
this.urlLabel,
this.batchTip,
this.labelValidator,
this.urlValidator,
this.batch = false,
this.existingUrls = const {},
this.withAgeSecretKey = false,
});
@override
State<NamedUrlDialog> createState() => _NamedUrlDialogState();
}
class _NamedUrlDialogState extends State<NamedUrlDialog> {
final _formKey = GlobalKey<FormState>();
final _urlFocusNode = FocusNode();
final _ageSecretKeyFocusNode = FocusNode();
late final TextEditingController _labelController;
late final TextEditingController _urlController;
final _ageSecretKeyController = TextEditingController();
final _batchController = TextEditingController();
bool _isBatch = false;
ParsedInput<String>? _parsed;
bool get _canSubmit {
if (!_isBatch) {
return true;
}
final parsed = _parsed;
return parsed != null && parsed.isValid && parsed.entries.isNotEmpty;
}
String get _urlLabel => widget.urlLabel ?? context.appLocalizations.url;
BatchInput<String> get _batchInput {
final appLocalizations = context.appLocalizations;
return BatchInput(
label: _urlLabel,
formatTip: widget.batchTip ?? appLocalizations.batchUrlInputTip,
parse: (text) => parseUrlInput(
text,
isValid: (url) => _validateBatchUrl(url) == null,
existing: widget.existingUrls,
),
issueMessage: (issue) => _validateBatchUrl(issue.raw)!,
);
}
@override
void initState() {
super.initState();
_labelController = TextEditingController(text: widget.label);
_urlController = TextEditingController(text: widget.url);
}
@override
void dispose() {
_urlFocusNode.dispose();
_ageSecretKeyFocusNode.dispose();
_labelController.dispose();
_urlController.dispose();
_ageSecretKeyController.dispose();
_batchController.dispose();
super.dispose();
}
String? _validateUrl(String? value) {
final validator = widget.urlValidator;
if (validator != null) {
return validator(value);
}
final appLocalizations = context.appLocalizations;
final url = value?.trim() ?? '';
if (url.isEmpty) {
return appLocalizations.emptyTip(_urlLabel);
}
if (!url.isUrl) {
return appLocalizations.urlTip(_urlLabel);
}
return null;
}
String? _validateBatchUrl(String url) {
if (url.length > TextInputLimits.url) {
final appLocalizations = context.appLocalizations;
return appLocalizations.maxLengthTip(_urlLabel, TextInputLimits.url);
}
return _validateUrl(url);
}
void _toggleBatch() {
setState(() {
_isBatch = !_isBatch;
if (_isBatch && _batchController.text.isEmpty) {
_batchController.text = _urlController.text.trim();
}
if (_isBatch) {
_parsed = _batchInput.parse(_batchController.text);
}
});
}
void _handleBatchChanged(String text) {
setState(() {
_parsed = _batchInput.parse(text);
});
}
void _handleSubmit() {
if (!_canSubmit) {
return;
}
if (_isBatch) {
Navigator.of(context).pop<List<NamedUrl>>([
for (final url in _parsed!.entries)
(label: '', url: url, ageSecretKey: ''),
]);
return;
}
if (_formKey.currentState?.validate() == false) {
return;
}
Navigator.of(context).pop<List<NamedUrl>>([
(
label: _labelController.text.trim(),
url: _urlController.text.trim(),
ageSecretKey: _ageSecretKeyController.text.trim(),
),
]);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: _isBatch ? appLocalizations.batchImport : widget.title,
trailing: widget.batch
? _BatchToggle(
isBatch: _isBatch,
tooltip: _isBatch
? appLocalizations.singleImport
: appLocalizations.batchImport,
onPressed: _toggleBatch,
)
: null,
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(appLocalizations.cancel),
),
TextButton(
onPressed: _canSubmit ? _handleSubmit : null,
child: Text(appLocalizations.submit),
),
],
child: _isBatch
? _BatchInputField(
batch: _batchInput,
controller: _batchController,
parsed: _parsed,
onChanged: _handleBatchChanged,
)
: _buildForm(appLocalizations),
);
}
Widget _buildForm(AppLocalizations appLocalizations) {
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Wrap(
runSpacing: 16,
children: [
TextFormField(
controller: _labelController,
validator: widget.labelValidator,
textInputAction: TextInputAction.next,
inputFormatters: TextInputLimits.limit(TextInputLimits.name),
onFieldSubmitted: (_) {
_urlFocusNode.requestFocus();
},
decoration: InputDecoration(
labelText: appLocalizations.name,
helperText: appLocalizations.optional,
),
),
TextFormField(
autofocus: true,
focusNode: _urlFocusNode,
controller: _urlController,
validator: _validateUrl,
keyboardType: TextInputType.url,
minLines: 1,
maxLines: null,
textInputAction: widget.withAgeSecretKey
? TextInputAction.next
: TextInputAction.done,
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
onFieldSubmitted: (_) {
if (widget.withAgeSecretKey) {
_ageSecretKeyFocusNode.requestFocus();
} else {
_handleSubmit();
}
},
decoration: InputDecoration(labelText: _urlLabel),
),
if (widget.withAgeSecretKey)
SecretTextFormField(
focusNode: _ageSecretKeyFocusNode,
controller: _ageSecretKeyController,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
_handleSubmit();
},
decoration: InputDecoration(
labelText: appLocalizations.ageSecretKey,
helperText: appLocalizations.optional,
),
),
],
),
);
}
}
class BatchInput<T> {
static const maxShownIssues = 3;
final String label;
final String formatTip;
final ParsedInput<T> Function(String text) parse;
@@ -270,24 +23,49 @@ class BatchInput<T> {
required this.parse,
required this.issueMessage,
});
String? issuesOf(AppLocalizations appLocalizations, ParsedInput<T>? parsed) {
if (parsed == null || parsed.isValid) {
return null;
}
return parsed.issues
.take(maxShownIssues)
.map(
(issue) =>
appLocalizations.lineIssueTip(issue.line, issueMessage(issue)),
)
.join('\n');
}
/// [issuesOf], or once a save was [attempted], that nothing was entered.
String? errorOf(
AppLocalizations appLocalizations,
ParsedInput<T>? parsed, {
required bool attempted,
}) {
final issues = issuesOf(appLocalizations, parsed);
if (issues != null) {
return issues;
}
if (attempted && parsed?.entries.isNotEmpty != true) {
return appLocalizations.emptyTip(label);
}
return null;
}
}
/// Pops a list so a single entry and a batch return through the same path.
class EntryDialog<T> extends StatefulWidget {
final String title;
final Field? keyField;
final Field valueField;
final int? keyMaxLength;
final int? valueMaxLength;
final T Function(String? key, String value) toEntry;
final T Function(String value) toEntry;
final BatchInput<T>? batch;
const EntryDialog({
super.key,
required this.title,
this.keyField,
required this.valueField,
this.keyMaxLength,
this.valueMaxLength,
required this.toEntry,
this.batch,
@@ -298,15 +76,12 @@ class EntryDialog<T> extends StatefulWidget {
}
class _EntryDialogState<T> extends State<EntryDialog<T>> {
TextEditingController? _keyController;
late final TextEditingController _valueController;
final _batchController = TextEditingController();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
bool _isBatch = false;
ParsedInput<T>? _parsed;
Field? get keyField => widget.keyField;
Field get valueField => widget.valueField;
bool get _canSubmit {
@@ -320,9 +95,6 @@ class _EntryDialogState<T> extends State<EntryDialog<T>> {
@override
void initState() {
super.initState();
if (keyField != null) {
_keyController = TextEditingController(text: keyField!.value);
}
_valueController = TextEditingController(text: valueField.value);
}
@@ -330,11 +102,7 @@ class _EntryDialogState<T> extends State<EntryDialog<T>> {
setState(() {
_isBatch = !_isBatch;
if (_isBatch && _batchController.text.isEmpty) {
_batchController.text = [_keyController?.text, _valueController.text]
.nonNulls
.map((text) => text.trim())
.where((t) => t.isNotEmpty)
.join(' ');
_batchController.text = _valueController.text.trim();
}
if (_isBatch) {
_parsed = widget.batch!.parse(_batchController.text);
@@ -355,14 +123,11 @@ class _EntryDialogState<T> extends State<EntryDialog<T>> {
return;
}
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop<List<T>>([
widget.toEntry(_keyController?.text, _valueController.text),
]);
Navigator.of(context).pop<List<T>>([widget.toEntry(_valueController.text)]);
}
@override
void dispose() {
_keyController?.dispose();
_valueController.dispose();
_batchController.dispose();
super.dispose();
@@ -372,60 +137,31 @@ class _EntryDialogState<T> extends State<EntryDialog<T>> {
return Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
key: _formKey,
child: Wrap(
runSpacing: 16,
children: [
if (keyField != null)
TextFormField(
maxLines: null,
minLines: 1,
inputFormatters: widget.keyMaxLength == null
? null
: TextInputLimits.limit(widget.keyMaxLength!),
controller: _keyController,
textInputAction: TextInputAction.next,
decoration: InputDecoration(labelText: keyField!.label),
validator: (String? value) {
String? res;
if (keyField!.validator != null) {
res = keyField!.validator!(value);
}
if (res != null) {
return res;
}
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.key);
}
return null;
},
),
TextFormField(
maxLines: null,
minLines: 1,
inputFormatters: widget.valueMaxLength == null
? null
: TextInputLimits.limit(widget.valueMaxLength!),
keyboardType: TextInputType.text,
controller: _valueController,
decoration: InputDecoration(labelText: valueField.label),
onFieldSubmitted: (_) {
_submit();
},
validator: (String? value) {
String? res;
if (valueField.validator != null) {
res = valueField.validator!(value);
}
if (res != null) {
return res;
}
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.value);
}
return null;
},
),
],
child: TextFormField(
maxLines: null,
minLines: 1,
inputFormatters: widget.valueMaxLength == null
? null
: TextInputLimits.limit(widget.valueMaxLength!),
keyboardType: TextInputType.text,
controller: _valueController,
decoration: InputDecoration(hintText: valueField.label),
onFieldSubmitted: (_) {
_submit();
},
validator: (String? value) {
String? res;
if (valueField.validator != null) {
res = valueField.validator!(value);
}
if (res != null) {
return res;
}
if (value == null || value.isEmpty) {
return appLocalizations.emptyTip(appLocalizations.value);
}
return null;
},
),
);
}
@@ -435,6 +171,7 @@ class _EntryDialogState<T> extends State<EntryDialog<T>> {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: _isBatch ? appLocalizations.batchAdd : widget.title,
hasFields: true,
trailing: widget.batch == null
? null
: _BatchToggle(
@@ -486,8 +223,6 @@ class _BatchToggle extends StatelessWidget {
}
class _BatchInputField<T> extends StatelessWidget {
static const _maxShownIssues = 3;
final BatchInput<T> batch;
final TextEditingController controller;
final ParsedInput<T>? parsed;
@@ -500,22 +235,6 @@ class _BatchInputField<T> extends StatelessWidget {
required this.onChanged,
});
String? _errorText(AppLocalizations appLocalizations) {
final parsed = this.parsed;
if (parsed == null || parsed.isValid) {
return null;
}
return parsed.issues
.take(_maxShownIssues)
.map(
(issue) => appLocalizations.lineIssueTip(
issue.line,
batch.issueMessage(issue),
),
)
.join('\n');
}
String _helperText(AppLocalizations appLocalizations) {
final parsed = this.parsed;
if (parsed == null || controller.text.trim().isEmpty) {
@@ -538,12 +257,11 @@ class _BatchInputField<T> extends StatelessWidget {
keyboardType: TextInputType.multiline,
onChanged: onChanged,
decoration: InputDecoration(
labelText: batch.label,
alignLabelWithHint: true,
hintText: batch.label,
helperText: _helperText(appLocalizations),
helperMaxLines: 2,
errorText: _errorText(appLocalizations),
errorMaxLines: _maxShownIssues + 1,
errorText: batch.issuesOf(appLocalizations, parsed),
errorMaxLines: BatchInput.maxShownIssues + 1,
),
);
}
+1 -1
View File
@@ -74,7 +74,7 @@ Future<void> _answerDialog(WidgetTester tester, {required bool confirm}) async {
await tester.pumpAndSettle();
final localizations = AppLocalizations.current;
await tester.tap(
find.text(confirm ? localizations.confirm : localizations.cancel),
find.text(confirm ? localizations.delete : localizations.cancel),
);
await tester.pumpAndSettle();
}
+134
View File
@@ -0,0 +1,134 @@
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
Future<void> _open(
WidgetTester tester,
NamedUrlForm form,
ValueSetter<List<NamedUrl>?> onResult,
) async {
const size = Size(1200, 1000);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
TestApp(
overrides: [viewSizeProvider.overrideWithBuild((_, _) => size)],
child: Builder(
builder: (context) => FilledButton(
onPressed: () async => onResult(
await showFormSheet<List<NamedUrl>>(
context: context,
builder: (_) => form,
),
),
child: const Text('Open'),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
Finder _field(String title) => find.descendant(
of: find.widgetWithText(FormRow, title),
matching: find.byType(TextFormField),
);
bool _focused(WidgetTester tester, Finder field) => tester
.widget<EditableText>(
find.descendant(of: field, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus;
void main() {
testWidgets('the optional name comes first and the url holds the focus', (
tester,
) async {
List<NamedUrl>? result;
await _open(
tester,
const NamedUrlForm(title: 'Import'),
(value) => result = value,
);
expect(
tester.getTopLeft(_field('Name')).dy,
lessThan(tester.getTopLeft(_field('URL')).dy),
);
expect(find.text('Optional'), findsOneWidget);
expect(find.byTooltip('Batch import'), findsNothing);
expect(_focused(tester, _field('URL')), isTrue);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.text('URL cannot be empty'), findsOneWidget);
expect(result, isNull);
await tester.enterText(_field('Name'), 'Home');
await tester.testTextInput.receiveAction(TextInputAction.next);
await tester.pump();
expect(_focused(tester, _field('URL')), isTrue);
await tester.enterText(_field('URL'), 'https://example.com/sub');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(result, [
(label: 'Home', url: 'https://example.com/sub', ageSecretKey: ''),
]);
});
testWidgets('a batch previews, refuses invalid lines and returns every url', (
tester,
) async {
List<NamedUrl>? result;
await _open(
tester,
const NamedUrlForm(
title: 'Import',
batch: true,
existingUrls: {'https://example.com/old'},
),
(value) => result = value,
);
final submit = find.widgetWithText(TextButton, 'Submit');
await tester.enterText(_field('URL'), 'https://example.com/a');
await tester.tap(find.byTooltip('Batch import'));
await tester.pumpAndSettle();
expect(find.text('Batch import'), findsOneWidget);
expect(find.byTooltip('Single import'), findsOneWidget);
expect(find.text('1 to add, 0 skipped as existing'), findsOneWidget);
await tester.enterText(
find.byType(TextField),
'https://example.com/a\nnot-a-url',
);
await tester.pumpAndSettle();
expect(find.text('Line 2: URL must be a URL'), findsOneWidget);
await tester.tap(submit);
await tester.pumpAndSettle();
expect(find.byType(NamedUrlForm), findsOneWidget);
await tester.enterText(
find.byType(TextField),
'https://example.com/a\nhttps://example.com/old\n'
'https://example.com/b',
);
await tester.pump();
expect(find.text('2 to add, 1 skipped as existing'), findsOneWidget);
await tester.tap(submit);
await tester.pumpAndSettle();
expect(result, [
(label: '', url: 'https://example.com/a', ageSecretKey: ''),
(label: '', url: 'https://example.com/b', ageSecretKey: ''),
]);
});
}
+54
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/l10n/l10n.dart';
@@ -264,4 +265,57 @@ void main() {
expect(find.byType(CommonDialog), findsNothing);
expect(find.byType(NestedFormSheet<ProxyGroup>), findsOneWidget);
});
testWidgets('an alert from a page inside a form sheet opens where the '
'sheet sits', (tester) async {
tester.view.physicalSize = const Size(1000, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [
viewSizeProvider.overrideWithBuild((_, _) => const Size(1000, 800)),
],
);
addTearDown(container.dispose);
globalState.container = container;
final pageNavigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
homeBuilder: (child) => Navigator(
key: pageNavigator,
pages: [MaterialPage(child: child)],
onDidRemovePage: (_) {},
),
child: const Scaffold(body: SizedBox.shrink()),
),
),
);
unawaited(
pageNavigator.currentState!.push(
FormSheetRoute<void>(
barrierColor: Colors.black26,
barrierLabel: 'sheet',
builder: (_) => Navigator(
onGenerateRoute: (_) => PagedSheetRoute<void>(
builder: (_) => const Material(child: TextField(autofocus: true)),
),
),
),
),
);
await tester.pumpAndSettle();
unawaited(dialogs.showMessage(message: const TextSpan(text: 'nested')));
await tester.pumpAndSettle();
final alertNavigator = tester.state<NavigatorState>(
find
.ancestor(of: find.text('nested'), matching: find.byType(Navigator))
.first,
);
expect(alertNavigator, same(pageNavigator.currentState));
});
}
+10 -10
View File
@@ -1,4 +1,5 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
@@ -32,6 +33,11 @@ class _RecordingProfilesAction extends ProfilesAction {
batches.add(links);
}
Finder _field(String title) => find.descendant(
of: find.widgetWithText(FormRow, title),
matching: find.byType(TextFormField),
);
ProviderContainer _containerFor(
WidgetTester tester, {
ProfilesAction? profilesAction,
@@ -152,7 +158,7 @@ void main() {
await tester.tap(find.text(l10n.link));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, l10n.link),
_field(l10n.link),
'trojan://pass@example.com:443#HK',
);
await tester.pump();
@@ -187,14 +193,8 @@ void main() {
final l10n = currentAppLocalizations;
await tester.tap(find.text(l10n.link));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, l10n.link),
'https://example.com/sub',
);
await tester.enterText(
find.widgetWithText(TextFormField, l10n.ageSecretKey),
' AGE-SECRET-KEY-1A ',
);
await tester.enterText(_field(l10n.link), 'https://example.com/sub');
await tester.enterText(_field(l10n.ageSecretKey), ' AGE-SECRET-KEY-1A ');
await tester.pump();
await tester.tap(find.text(l10n.submit));
await tester.pumpAndSettle();
@@ -229,7 +229,7 @@ void main() {
await tester.tap(find.text(l10n.link));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, l10n.link),
_field(l10n.link),
'wireguard://key@example.com:51820',
);
await tester.pump();
+3 -3
View File
@@ -116,7 +116,7 @@ void main() {
await tester.pumpAndSettle();
await _tapBarAction(tester, currentAppLocalizations.selectAll);
await _tapBarAction(tester, currentAppLocalizations.delete);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
expect(rules.deleted, [
@@ -143,7 +143,7 @@ void main() {
await tester.tap(find.byType(CommonCheckBox).last);
await tester.pumpAndSettle();
await _tapBarAction(tester, currentAppLocalizations.delete);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
expect(rules.deleted, [
@@ -205,7 +205,7 @@ void main() {
await tester.pumpAndSettle();
await _tapBarAction(tester, currentAppLocalizations.selectAll);
await _tapBarAction(tester, currentAppLocalizations.delete);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
expect(rules.deleted, [
+63 -50
View File
@@ -1,10 +1,12 @@
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:fl_clash/views/backup_and_restore.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -37,7 +39,7 @@ void main() {
tearDown(() => container.dispose());
Future<void> pumpDialog(WidgetTester tester, Widget dialog) async {
Future<void> pumpForm(WidgetTester tester, Widget form) async {
tester.view.physicalSize = const Size(1200, 1400);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -46,9 +48,31 @@ void main() {
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(child: Scaffold(body: dialog)),
child: TestApp(
child: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () =>
showFormSheet<void>(context: context, builder: (_) => form),
child: const Text('open'),
),
),
),
),
),
);
await tester.pump();
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
Finder field(String title) => find.descendant(
of: find.widgetWithText(FormRow, title),
matching: find.byType(TextFormField),
);
Future<void> save(WidgetTester tester) async {
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
}
@@ -109,24 +133,30 @@ void main() {
});
});
group('WebDAVFormDialog', () {
group('WebDAVForm', () {
testWidgets('rejects an empty form and stores nothing', (tester) async {
await pumpDialog(tester, const WebDAVFormDialog());
await pumpForm(tester, const WebDAVForm());
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
await save(tester);
expect(container.read(davSettingProvider), isNull);
expect(find.byType(WebDAVFormDialog), findsOneWidget);
expect(find.byType(WebDAVForm), findsOneWidget);
expect(
find.descendant(
of: find.byType(ErrorBanner),
matching: find.text('Please enter a valid WebDAV address'),
),
findsOneWidget,
);
});
testWidgets('names the password toggle by what pressing it does', (
tester,
) async {
await pumpDialog(tester, const WebDAVFormDialog());
await pumpForm(tester, const WebDAVForm());
final toggle = find.descendant(
of: find.widgetWithGlyph(TextFormField, AppGlyphs.password),
of: find.widgetWithText(FormRow, 'Password'),
matching: find.byType(IconButton),
);
expect(tester.widget<IconButton>(toggle).tooltip, 'Show password');
@@ -138,65 +168,46 @@ void main() {
});
testWidgets('rejects a malformed address', (tester) async {
await pumpDialog(tester, const WebDAVFormDialog());
await pumpForm(tester, const WebDAVForm());
await tester.enterText(
find.widgetWithGlyph(TextFormField, AppGlyphs.link),
'not-a-url',
);
await tester.enterText(
find.widgetWithGlyph(TextFormField, AppGlyphs.account),
'alice',
);
await tester.enterText(
find.widgetWithGlyph(TextFormField, AppGlyphs.password),
'secret',
);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
await tester.enterText(field('Address'), 'not-a-url');
await tester.enterText(field('Account'), 'alice');
await tester.enterText(field('Password'), 'secret');
await save(tester);
expect(container.read(davSettingProvider), isNull);
expect(find.byType(WebDAVForm), findsOneWidget);
});
testWidgets('stores a valid binding with the default file name', (
tester,
) async {
await pumpDialog(tester, const WebDAVFormDialog());
await pumpForm(tester, const WebDAVForm());
await tester.enterText(
find.widgetWithGlyph(TextFormField, AppGlyphs.link),
field('Address'),
'https://dav.example.com/remote',
);
await tester.enterText(
find.widgetWithGlyph(TextFormField, AppGlyphs.account),
'alice',
);
await tester.enterText(
find.widgetWithGlyph(TextFormField, AppGlyphs.password),
'secret',
);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
await tester.enterText(field('Account'), 'alice');
await tester.enterText(field('Password'), 'secret');
await save(tester);
final dav = container.read(davSettingProvider);
expect(dav?.uri, 'https://dav.example.com/remote');
expect(dav?.user, 'alice');
expect(dav?.password, 'secret');
expect(dav?.fileName, defaultDavFileName);
expect(find.byType(WebDAVForm), findsNothing);
});
testWidgets('editing preserves the previously chosen file name', (
tester,
) async {
container.read(davSettingProvider.notifier).update((_) => _existing);
await pumpDialog(tester, const WebDAVFormDialog(dav: _existing));
await pumpForm(tester, const WebDAVForm(dav: _existing));
await tester.enterText(
find.widgetWithGlyph(TextFormField, AppGlyphs.account),
'bob',
);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
await tester.enterText(field('Account'), 'bob');
await save(tester);
final dav = container.read(davSettingProvider);
expect(dav?.user, 'bob');
@@ -206,25 +217,27 @@ void main() {
testWidgets('offers delete only when editing an existing binding', (
tester,
) async {
await pumpDialog(tester, const WebDAVFormDialog());
expect(find.widgetWithText(TextButton, 'Delete'), findsNothing);
await pumpForm(tester, const WebDAVForm());
expect(find.widgetWithText(FormRow, 'Delete'), findsNothing);
Navigator.of(tester.element(find.byType(WebDAVForm))).pop();
await tester.pumpAndSettle();
await pumpDialog(tester, const WebDAVFormDialog(dav: _existing));
expect(find.widgetWithText(TextButton, 'Delete'), findsOneWidget);
await pumpForm(tester, const WebDAVForm(dav: _existing));
expect(find.widgetWithText(FormRow, 'Delete'), findsOneWidget);
});
testWidgets('delete clears the stored binding', (tester) async {
container.read(davSettingProvider.notifier).update((_) => _existing);
await pumpDialog(tester, const WebDAVFormDialog(dav: _existing));
await pumpForm(tester, const WebDAVForm(dav: _existing));
await tester.tap(find.widgetWithText(TextButton, 'Delete'));
await tester.tap(find.widgetWithText(FormRow, 'Delete'));
await tester.pumpAndSettle();
expect(container.read(davSettingProvider), isNull);
});
testWidgets('toggles password visibility', (tester) async {
await pumpDialog(tester, const WebDAVFormDialog(dav: _existing));
await pumpForm(tester, const WebDAVForm(dav: _existing));
expect(find.byGlyph(AppGlyphs.eye), findsOneWidget);
await tester.tap(find.byGlyph(AppGlyphs.eye));
+22 -29
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
@@ -87,6 +88,16 @@ ProviderContainer _containerFor(
return container;
}
Finder _rowField(String title) => find.descendant(
of: find.widgetWithText(FormRow, title),
matching: find.byType(TextFormField),
);
Finder _formField(String title) => find.descendant(
of: find.widgetWithText(FormRow, title),
matching: find.byType(TextFormField),
);
void main() {
late Directory home;
@@ -178,23 +189,14 @@ void main() {
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'Name'),
'Ad block',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/more.yaml',
);
await tester.enterText(_formField('Name'), 'Ad block');
await tester.enterText(_formField('URL'), 'https://example.com/more.yaml');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(action.put, isEmpty);
await tester.enterText(
find.widgetWithText(TextFormField, 'Name'),
'Extra rules',
);
await tester.enterText(_formField('Name'), 'Extra rules');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
@@ -228,10 +230,7 @@ void main() {
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/ads.list',
);
await tester.enterText(_formField('URL'), 'https://example.com/ads.list');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
@@ -316,10 +315,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/mixed.list',
);
await tester.enterText(_formField('URL'), 'https://example.com/mixed.list');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
@@ -368,8 +364,8 @@ void main() {
await tester.tap(find.text('Options'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextFormField, 'Name'), findsOneWidget);
expect(find.widgetWithText(TextFormField, 'URL'), findsNothing);
expect(_rowField('Name'), findsOneWidget);
expect(_rowField('URL'), findsNothing);
expect(find.text('Behavior'), findsNothing);
expect(find.text('Format'), findsNothing);
});
@@ -459,11 +455,8 @@ void main() {
expect(find.text('Behavior'), findsNothing);
expect(find.text('Format'), findsNothing);
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/ads.list',
);
await tester.tap(find.text('Confirm'));
await tester.enterText(_rowField('URL'), 'https://example.com/ads.list');
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
expect(
@@ -498,7 +491,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(action.deleted, [appRules]);
@@ -534,7 +527,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.textContaining('used by Work'), findsOneWidget);
await tester.tap(find.text('Confirm'));
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(action.deleted, [appRules]);
+6
View File
@@ -480,6 +480,12 @@ void main() {
.descendant(of: picker, matching: find.byType(Scrollable))
.first,
);
// Clears the docked search, which floats over the end of the list.
await Scrollable.ensureVisible(
tester.element(offered(_hongKong.label)),
alignment: 0.5,
);
await tester.pumpAndSettle();
await tester.tap(offered(_hongKong.label));
await tester.pump();
expect(find.byTooltip(l.confirm), findsNothing);
+23 -17
View File
@@ -1,6 +1,7 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
@@ -28,11 +29,21 @@ void main() {
Finder dialogField(int index) => find
.descendant(
of: find.byType(CommonDialog),
of: find.byType(FormPage),
matching: find.byType(TextFormField),
)
.at(index);
Finder banner(String text) =>
find.descendant(of: find.byType(ErrorBanner), matching: find.text(text));
Future<void> save(WidgetTester tester) async {
await tester.tap(
find.widgetWithText(TextButton, currentAppLocalizations.save),
);
await tester.pumpAndSettle();
}
Future<void> pumpView(WidgetTester tester, {List<Filter>? filters}) async {
tester.view.physicalSize = const Size(1000, 1600);
tester.view.devicePixelRatio = 1;
@@ -74,8 +85,7 @@ void main() {
Future<void> submit(WidgetTester tester, String label, String regex) async {
await tester.enterText(dialogField(0), label);
await tester.enterText(dialogField(1), regex);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
await save(tester);
}
testWidgets('lists the presets by their English labels', (tester) async {
@@ -110,29 +120,26 @@ void main() {
await tester.pumpAndSettle();
await submit(tester, taken, 'x');
expect(
find.text(
currentAppLocalizations.existsTip(currentAppLocalizations.label),
),
banner(currentAppLocalizations.existsTip(currentAppLocalizations.label)),
findsOneWidget,
);
await submit(tester, ' ', ' ');
expect(
find.text(
currentAppLocalizations.emptyTip(currentAppLocalizations.label),
),
banner(currentAppLocalizations.emptyTip(currentAppLocalizations.label)),
findsOneWidget,
);
await tester.enterText(dialogField(0), 'Fresh');
await tester.pump();
expect(
find.text(
currentAppLocalizations.emptyTip(currentAppLocalizations.regex),
),
banner(currentAppLocalizations.emptyTip(currentAppLocalizations.regex)),
findsOneWidget,
);
expect(saved(), defaultFilters);
});
testWidgets('adding keeps the dialog open over what the core rejects', (
testWidgets('adding keeps the form open over what the core rejects', (
tester,
) async {
await pumpView(tester);
@@ -141,15 +148,14 @@ void main() {
await tester.pumpAndSettle();
await submit(tester, 'Broken', '(hk');
expect(find.text('missing closing )'), findsOneWidget);
expect(banner('missing closing )'), findsOneWidget);
expect(saved(), defaultFilters);
verify(() => core.validateFilters(['(hk'])).called(1);
await tester.enterText(dialogField(1), 'hk');
await tester.pump();
expect(find.text('missing closing )'), findsNothing);
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.pumpAndSettle();
await save(tester);
expect(saved().last, const Filter(label: 'Broken', regex: 'hk'));
});
@@ -183,7 +189,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byTooltip(currentAppLocalizations.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
expect(saved(), isEmpty);
+1 -1
View File
@@ -321,7 +321,7 @@ void main() {
await tester.longPress(find.text('Proxy'));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(find.text('Proxy'), findsNothing);
+10 -10
View File
@@ -1,3 +1,4 @@
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
@@ -115,6 +116,11 @@ Future<void> _openMenu(WidgetTester tester, String name) async {
await tester.pumpAndSettle();
}
Finder _formField(String title) => find.descendant(
of: find.widgetWithText(FormRow, title),
matching: find.byType(TextFormField),
);
void main() {
testWidgets('lists each set with its size and where it comes from', (
tester,
@@ -140,10 +146,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/set.json',
);
await tester.enterText(_formField('URL'), 'https://example.com/set.json');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
@@ -163,10 +166,7 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Import from URL'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/page.html',
);
await tester.enterText(_formField('URL'), 'https://example.com/page.html');
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
@@ -189,7 +189,7 @@ void main() {
await _openMenu(tester, 'Qure');
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
await tester.tap(find.text('Confirm'));
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(iconSets.deleted, [_remote.id]);
@@ -225,7 +225,7 @@ void main() {
await tester.tap(find.text('Options'));
await tester.pumpAndSettle();
expect(find.widgetWithText(TextFormField, 'URL'), findsNothing);
expect(_formField('URL'), findsNothing);
await tester.enterText(find.byType(TextField), 'Flags');
await tester.tap(find.text('Submit'));
+2 -2
View File
@@ -169,7 +169,7 @@ void main() {
await openMenu(tester, defaultUserAgents.last);
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
expect(saved(), [defaultUserAgents.first]);
@@ -188,7 +188,7 @@ void main() {
await openMenu(tester, defaultUserAgents.first);
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
await tester.tap(find.text(currentAppLocalizations.confirm));
await tester.tap(find.text(currentAppLocalizations.delete));
await tester.pumpAndSettle();
expect(saved(), [defaultUserAgents.last, 'Legacy/1.0']);
+177
View File
@@ -0,0 +1,177 @@
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import '../helpers/test_app.dart';
final _viewSizeOverride = viewSizeProvider.overrideWithBuild(
(_, _) => const Size(400, 800),
);
Future<void> _pumpDialog(
WidgetTester tester,
List<String> labels, {
TextDirection textDirection = TextDirection.ltr,
ValueChanged<String>? onPressed,
}) async {
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Directionality(
textDirection: textDirection,
child: Scaffold(
body: Center(
child: CommonDialog(
title: 'Title',
actions: [
for (final label in labels)
TextButton(
onPressed: () => onPressed?.call(label),
child: Text(label),
),
],
child: const Text('Body'),
),
),
),
),
),
);
}
Rect _rectOf(WidgetTester tester, String label) =>
tester.getRect(find.widgetWithText(TextButton, label));
Future<void> _pumpLauncher(WidgetTester tester) async {
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showFloatingDialog<void>(
context: context,
child: const CommonDialog(title: 'Title', child: Text('Body')),
),
child: const Text('Open'),
),
),
),
),
);
}
double _dialogScale(WidgetTester tester) => tester
.widget<Transform>(
find
.ancestor(
of: find.byType(CommonDialog),
matching: find.byType(Transform),
)
.first,
)
.transform
.entry(0, 0);
double _dialogOpacity(WidgetTester tester) => tester
.widget<FadeTransition>(
find
.ancestor(
of: find.byType(CommonDialog),
matching: find.byType(FadeTransition),
)
.first,
)
.opacity
.value;
void main() {
testWidgets('two short actions share a row, the default one at the end', (
tester,
) async {
await _pumpDialog(tester, ['No', 'Yes']);
final no = _rectOf(tester, 'No');
final yes = _rectOf(tester, 'Yes');
expect(no.top, yes.top);
expect(no.width, yes.width);
expect(no.right, lessThan(yes.left));
});
testWidgets('a row follows the reading direction', (tester) async {
await _pumpDialog(tester, ['No', 'Yes'], textDirection: TextDirection.rtl);
expect(_rectOf(tester, 'Yes').right, lessThan(_rectOf(tester, 'No').left));
});
testWidgets('a label too long for half the width stacks the default on top', (
tester,
) async {
await _pumpDialog(tester, ['No', 'A label far too long to share a row']);
final no = _rectOf(tester, 'No');
final long = _rectOf(tester, 'A label far too long to share a row');
expect(long.bottom, lessThan(no.top));
expect(long.width, no.width);
});
testWidgets('three actions stack in order with the first at the bottom', (
tester,
) async {
final pressed = <String>[];
await _pumpDialog(tester, ['No', 'Remove', 'Yes'], onPressed: pressed.add);
final no = _rectOf(tester, 'No');
final remove = _rectOf(tester, 'Remove');
final yes = _rectOf(tester, 'Yes');
expect(remove.bottom, lessThan(yes.top));
expect(yes.bottom, lessThan(no.top));
for (final label in ['No', 'Remove', 'Yes']) {
await tester.tap(find.text(label));
await tester.pumpAndSettle();
}
expect(pressed, ['No', 'Remove', 'Yes']);
});
testWidgets('a dialog grows in as it fades in and only fades out', (
tester,
) async {
await _pumpLauncher(tester);
await tester.tap(find.text('Open'));
await tester.pump();
expect(_dialogScale(tester), closeTo(1.2, 0.001));
expect(_dialogOpacity(tester), 0);
await tester.pump(const Duration(milliseconds: 100));
expect(_dialogScale(tester), inExclusiveRange(1, 1.2));
expect(_dialogOpacity(tester), inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(_dialogScale(tester), 1);
expect(_dialogOpacity(tester), 1);
Navigator.of(tester.element(find.byType(CommonDialog))).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(_dialogScale(tester), 1);
expect(_dialogOpacity(tester), inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(find.byType(CommonDialog), findsNothing);
});
testWidgets('a tap on the scrim dismisses a dismissible dialog', (
tester,
) async {
await _pumpLauncher(tester);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.tapAt(const Offset(4, 4));
await tester.pumpAndSettle();
expect(find.byType(CommonDialog), findsNothing);
});
}
+154
View File
@@ -0,0 +1,154 @@
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
class _Form extends StatefulWidget {
const _Form({this.onDismiss});
final SheetDismissHandler? onDismiss;
@override
State<_Form> createState() => _FormState();
}
class _FormState extends State<_Form> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (widget.onDismiss case final onDismiss?) {
SheetDismissScope.of(context)!.value = onDismiss;
}
}
@override
Widget build(BuildContext context) {
return FormPage(
title: 'Form',
onSave: () {},
children: [
generateSection(
items: const [FormRow(title: 'Name', trailing: FormRowTextField())],
),
],
);
}
}
Future<void> _open(
WidgetTester tester,
Size size, {
_Form form = const _Form(),
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
TestApp(
overrides: [viewSizeProvider.overrideWithBuild((_, _) => size)],
child: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () =>
showFormSheet<void>(context: context, builder: (_) => form),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
SheetType? _sheetType(WidgetTester tester) =>
SheetProvider.of(tester.element(find.byType(FormRowTextField)))?.type;
TextField _field(WidgetTester tester) =>
tester.widget<TextField>(find.byType(TextField));
void main() {
testWidgets('a desktop form is a card centered over the window', (
tester,
) async {
await _open(tester, const Size(1200, 800));
expect(_sheetType(tester), SheetType.formSheet);
final card = find
.ancestor(of: find.byType(FormPage), matching: find.byType(Material))
.first;
expect(tester.getCenter(card).dx, 600);
expect(tester.getSize(card).width, 440);
expect(_field(tester).textAlign, TextAlign.end);
expect(_field(tester).decoration?.border, isA<NoInputBorder>());
expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget);
expect(find.widgetWithText(TextButton, 'Save'), findsOneWidget);
expect(find.byTooltip('Save'), findsNothing);
expect(find.byTooltip('Close'), findsNothing);
});
testWidgets('a mobile form is a bottom sheet with bare fields', (
tester,
) async {
await _open(tester, const Size(390, 844));
expect(_sheetType(tester), SheetType.bottomSheet);
expect(_field(tester).textAlign, TextAlign.end);
expect(_field(tester).decoration?.border, isA<NoInputBorder>());
expect(find.byTooltip('Save'), findsOneWidget);
expect(find.byTooltip('Close'), findsOneWidget);
expect(find.widgetWithText(TextButton, 'Cancel'), findsNothing);
});
testWidgets('a bare field floats no label, whose focus animation would lay '
'the form out on every frame', (tester) async {
await _open(tester, const Size(390, 844));
final decorator = tester.widget<InputDecorator>(
find.byType(InputDecorator),
);
expect(
decorator.decoration.floatingLabelBehavior,
FloatingLabelBehavior.never,
);
});
testWidgets('cancel at the foot closes the card', (tester) async {
await _open(tester, const Size(1200, 800));
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
await tester.pumpAndSettle();
expect(find.byType(FormPage), findsNothing);
});
testWidgets('a tap outside the card closes it', (tester) async {
await _open(tester, const Size(1200, 800));
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(find.byType(FormPage), findsNothing);
});
testWidgets('a tap outside goes to the form when it takes the dismiss', (
tester,
) async {
var asked = 0;
await _open(
tester,
const Size(1200, 800),
form: _Form(onDismiss: () async => asked++),
);
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(asked, 1);
expect(find.byType(FormPage), findsOneWidget);
});
}
+43 -208
View File
@@ -1,5 +1,6 @@
import 'dart:ui' as ui;
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/ui/icons/icons.dart';
@@ -365,7 +366,7 @@ void main() {
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextFormField),
List.filled(12, 'https://a.example').join('/'),
List.filled(6, 'https://a.example').join('/'),
);
await tester.pumpAndSettle();
@@ -410,153 +411,7 @@ void main() {
expect(controller.text, 'AGE-SECRET-KEY-1A');
});
testWidgets(
'NamedUrlDialog puts the optional name first and focuses the url',
(tester) async {
List<NamedUrl>? result;
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Builder(
builder: (context) {
return FilledButton(
onPressed: () async {
result = await showDialog<List<NamedUrl>>(
context: context,
builder: (_) => const NamedUrlDialog(title: 'Import'),
);
},
child: const Text('Open'),
);
},
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final nameField = find.widgetWithText(TextFormField, 'Name');
final urlField = find.widgetWithText(TextFormField, 'URL');
expect(
tester.getTopLeft(nameField).dy,
lessThan(tester.getTopLeft(urlField).dy),
);
expect(find.text('Optional'), findsOneWidget);
expect(find.byTooltip('Batch import'), findsNothing);
expect(
tester
.widget<EditableText>(
find.descendant(
of: urlField,
matching: find.byType(EditableText),
),
)
.focusNode
.hasFocus,
isTrue,
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(find.text('URL cannot be empty'), findsOneWidget);
expect(result, isNull);
await tester.enterText(nameField, 'Home');
await tester.testTextInput.receiveAction(TextInputAction.next);
await tester.pump();
expect(
tester
.widget<EditableText>(
find.descendant(
of: urlField,
matching: find.byType(EditableText),
),
)
.focusNode
.hasFocus,
isTrue,
);
await tester.enterText(urlField, 'https://example.com/sub');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(result, [
(label: 'Home', url: 'https://example.com/sub', ageSecretKey: ''),
]);
},
);
testWidgets(
'NamedUrlDialog batch previews, blocks invalid lines, and pops every url',
(tester) async {
List<NamedUrl>? result;
await tester.pumpWidget(
TestApp(
overrides: [_viewSizeOverride],
child: Builder(
builder: (context) {
return FilledButton(
onPressed: () async {
result = await showDialog<List<NamedUrl>>(
context: context,
builder: (_) => const NamedUrlDialog(
title: 'Import',
batch: true,
existingUrls: {'https://example.com/old'},
),
);
},
child: const Text('Open'),
);
},
),
),
);
VoidCallback? submit() => tester
.widget<TextButton>(find.widgetWithText(TextButton, 'Submit'))
.onPressed;
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
await tester.enterText(
find.widgetWithText(TextFormField, 'URL'),
'https://example.com/a',
);
await tester.tap(find.byTooltip('Batch import'));
await tester.pumpAndSettle();
expect(find.text('Batch import'), findsOneWidget);
expect(find.byTooltip('Single import'), findsOneWidget);
expect(find.text('1 to add, 0 skipped as existing'), findsOneWidget);
await tester.enterText(
find.byType(TextField),
'https://example.com/a\nnot-a-url',
);
await tester.pump();
expect(find.text('Line 2: URL must be a URL'), findsOneWidget);
expect(submit(), isNull);
await tester.enterText(
find.byType(TextField),
'https://example.com/a\nhttps://example.com/old\n'
'https://example.com/b',
);
await tester.pump();
expect(find.text('2 to add, 1 skipped as existing'), findsOneWidget);
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
expect(result, [
(label: '', url: 'https://example.com/a', ageSecretKey: ''),
(label: '', url: 'https://example.com/b', ageSecretKey: ''),
]);
},
);
testWidgets('EntryDialog returns scalar and map values', (tester) async {
testWidgets('EntryDialog returns a scalar value', (tester) async {
Object? result;
await tester.pumpWidget(
@@ -564,40 +419,19 @@ void main() {
overrides: [_viewSizeOverride],
child: Builder(
builder: (context) {
return Column(
children: [
FilledButton(
onPressed: () async {
result = await showDialog<List<String>>(
context: context,
builder: (_) => const EntryDialog<String>(
title: 'Scalar',
valueField: Field(label: 'Value', value: ''),
valueMaxLength: 4,
toEntry: _scalarEntry,
),
);
},
child: const Text('Scalar'),
),
FilledButton(
onPressed: () async {
result = await showDialog<List<MapEntry<String, String>>>(
context: context,
builder: (_) =>
const EntryDialog<MapEntry<String, String>>(
title: 'Pair',
keyField: Field(label: 'Key', value: ''),
valueField: Field(label: 'Value', value: ''),
keyMaxLength: 3,
valueMaxLength: 4,
toEntry: _pairEntry,
),
);
},
child: const Text('Pair'),
),
],
return FilledButton(
onPressed: () async {
result = await showDialog<List<String>>(
context: context,
builder: (_) => const EntryDialog<String>(
title: 'Scalar',
valueField: Field(label: 'Value', value: ''),
valueMaxLength: 4,
toEntry: _scalarEntry,
),
);
},
child: const Text('Scalar'),
);
},
),
@@ -613,17 +447,6 @@ void main() {
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(result, ['valu']);
await tester.tap(find.text('Pair'));
await tester.pumpAndSettle();
final fields = find.byType(TextFormField);
await tester.enterText(fields.first, 'key1');
await tester.enterText(fields.last, 'value');
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
final pair = (result! as List<MapEntry<String, String>>).single;
expect(pair.key, 'key');
expect(pair.value, 'valu');
});
testWidgets('ListEditView adds, edits, selects, and deletes items', (
@@ -681,10 +504,13 @@ void main() {
await tester.tap(find.byType(Checkbox).first);
await tester.pump();
expect(find.byGlyph(AppGlyphs.delete), findsOneWidget);
await tester.tap(find.text('Select all'));
await tester.tap(find.byTooltip('Select all'));
await tester.pump();
await tester.tap(find.byGlyph(AppGlyphs.delete));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('No data'), findsNothing);
await tester.tap(find.widgetWithText(TextButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('No data'), findsOneWidget);
});
@@ -724,18 +550,28 @@ void main() {
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
final fields = find.byType(TextFormField);
await tester.enterText(fields.first, 'c');
await tester.enterText(fields.last, '3');
await tester.tap(find.text('Confirm'));
await tester.tap(find.byTooltip('Confirm'));
await tester.pumpAndSettle();
expect(find.text('c'), findsOneWidget);
expect(find.text('Key cannot be empty'), findsOneWidget);
await tester.enterText(fields.first, 'a');
await tester.pumpAndSettle();
expect(find.text('Key already exists'), findsOneWidget);
await tester.enterText(fields.first, 'ccccc');
await tester.enterText(fields.last, '33333');
await tester.tap(find.byTooltip('Confirm'));
await tester.pumpAndSettle();
expect(find.text('cccc'), findsOneWidget);
expect(find.text('3333'), findsOneWidget);
await tester.tap(find.byType(Checkbox).first);
await tester.pump();
await tester.tap(find.text('Select all'));
await tester.tap(find.byTooltip('Select all'));
await tester.pump();
await tester.tap(find.byGlyph(AppGlyphs.delete));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('No data'), findsNothing);
await tester.tap(find.widgetWithText(TextButton, 'Delete'));
await tester.pumpAndSettle();
expect(find.text('No data'), findsOneWidget);
});
@@ -820,9 +656,11 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('1 to add, 0 skipped as existing'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'c 3\na 9\nd');
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('Line 3: Value cannot be empty'), findsOneWidget);
await tester.tap(find.byTooltip('Confirm'));
await tester.pumpAndSettle();
expect(find.text('Line 3: Value cannot be empty'), findsOneWidget);
expect(_confirmButton(tester).onPressed, isNull);
await tester.enterText(
find.byType(TextField),
@@ -830,7 +668,7 @@ void main() {
);
await tester.pump();
expect(find.text('1 to add, 1 skipped as existing'), findsOneWidget);
await tester.tap(find.text('Confirm'));
await tester.tap(find.byTooltip('Confirm'));
await tester.pumpAndSettle();
expect(find.text('geosite:cn'), findsOneWidget);
expect(find.text('tls://1.1.1.1:853'), findsOneWidget);
@@ -858,10 +696,7 @@ void main() {
});
}
String _scalarEntry(String? key, String value) => value;
MapEntry<String, String> _pairEntry(String? key, String value) =>
MapEntry(key!, value);
String _scalarEntry(String value) => value;
Widget _textBuilder(String value) {
return Text(value);
-136
View File
@@ -1,136 +0,0 @@
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/config/general.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
Future<ProviderContainer> _openPortDialog(WidgetTester tester) async {
tester.view.physicalSize = const Size(1000, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1000, 900));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Scaffold(body: ListView(children: const [PortItem()])),
),
),
);
await tester.pump();
await tester.tap(find.byType(PortItem));
await tester.pumpAndSettle();
return container;
}
Future<void> _expandMore(WidgetTester tester) async {
await tester.tap(find.byType(IconButton).first);
await tester.pumpAndSettle();
}
void main() {
testWidgets('opens with the mixed port and hides the rest until expanded', (
tester,
) async {
final container = await _openPortDialog(tester);
final config = container.read(patchClashConfigProvider);
expect(find.byType(TextFormField), findsOneWidget);
expect(
tester.widget<TextFormField>(find.byType(TextFormField)).controller?.text,
config.mixedPort.toString(),
);
await _expandMore(tester);
expect(find.byType(TextFormField), findsNWidgets(5));
});
testWidgets('rejects a mixed port outside the allowed range', (tester) async {
await _openPortDialog(tester);
await tester.enterText(find.byType(TextFormField).first, '80');
await tester.pumpAndSettle();
expect(find.textContaining('1024'), findsWidgets);
});
testWidgets('rejects zero for the mixed port but accepts it elsewhere', (
tester,
) async {
await _openPortDialog(tester);
await _expandMore(tester);
final fields = find.byType(TextFormField);
await tester.enterText(fields.at(1), '0');
await tester.pumpAndSettle();
final errorsWithDisabledPort = find
.textContaining('1024')
.evaluate()
.length;
await tester.enterText(fields.first, '0');
await tester.pumpAndSettle();
expect(
find.textContaining('1024').evaluate().length,
greaterThan(errorsWithDisabledPort),
);
});
testWidgets('reports a conflict when two ports share a value', (
tester,
) async {
await _openPortDialog(tester);
await _expandMore(tester);
final fields = find.byType(TextFormField);
await tester.enterText(fields.first, '7890');
await tester.pumpAndSettle();
await tester.enterText(fields.at(1), '7890');
await tester.pumpAndSettle();
expect(find.text('Please enter a different port'), findsWidgets);
});
testWidgets('submitting valid ports writes them to the patch config', (
tester,
) async {
final container = await _openPortDialog(tester);
await _expandMore(tester);
final fields = find.byType(TextFormField);
await tester.enterText(fields.first, '7891');
await tester.enterText(fields.at(1), '7892');
await tester.enterText(fields.at(2), '7893');
await tester.enterText(fields.at(3), '0');
await tester.enterText(fields.at(4), '0');
await tester.pumpAndSettle();
await tester.tap(find.text('Submit'));
await tester.pumpAndSettle();
final config = container.read(patchClashConfigProvider);
expect(config.mixedPort, 7891);
expect(config.port, 7892);
expect(config.socksPort, 7893);
expect(config.redirPort, 0);
expect(config.tproxyPort, 0);
});
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/config/general.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
Future<ProviderContainer> _openPortForm(WidgetTester tester) async {
tester.view.physicalSize = const Size(1000, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final container = ProviderContainer(
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
);
addTearDown(container.dispose);
globalState.container = container;
container
.read(viewSizeProvider.notifier)
.update((_) => const Size(1000, 900));
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: TestApp(
child: Scaffold(body: ListView(children: const [PortItem()])),
),
),
);
await tester.pump();
await tester.tap(find.byType(PortItem));
await tester.pumpAndSettle();
return container;
}
Finder get _fields => find.byType(TextFormField);
Finder _banner(String text) => find.descendant(
of: find.byType(ErrorBanner),
matching: find.textContaining(text),
);
void main() {
testWidgets('opens with every port filled in', (tester) async {
final container = await _openPortForm(tester);
final config = container.read(patchClashConfigProvider);
expect(_fields, findsNWidgets(5));
expect(
[
for (final field in tester.widgetList<TextFormField>(_fields))
field.controller?.text,
],
[
for (final port in [
config.mixedPort,
config.port,
config.socksPort,
config.redirPort,
config.tproxyPort,
])
'$port',
],
);
expect(
tester
.widget<ErrorBanner>(find.byType(ErrorBanner, skipOffstage: false))
.message,
isNull,
);
});
testWidgets('rejects a mixed port outside the allowed range', (tester) async {
await _openPortForm(tester);
await tester.enterText(_fields.first, '80');
await tester.pumpAndSettle();
expect(_banner('1024'), findsOneWidget);
});
testWidgets('rejects zero for the mixed port but accepts it elsewhere', (
tester,
) async {
await _openPortForm(tester);
await tester.enterText(_fields.at(1), '0');
await tester.pumpAndSettle();
expect(_banner('1024'), findsNothing);
await tester.enterText(_fields.first, '0');
await tester.pumpAndSettle();
expect(_banner('1024'), findsOneWidget);
});
testWidgets('reports a conflict when two ports share a value', (
tester,
) async {
await _openPortForm(tester);
await tester.enterText(_fields.first, '7890');
await tester.enterText(_fields.at(1), '7890');
await tester.pumpAndSettle();
expect(_banner('Please enter a different port'), findsOneWidget);
await tester.enterText(_fields.at(1), '07890');
await tester.pumpAndSettle();
expect(_banner('Please enter a different port'), findsOneWidget);
});
testWidgets('an invalid port keeps the form open and writes nothing', (
tester,
) async {
final container = await _openPortForm(tester);
final before = container.read(patchClashConfigProvider);
await tester.enterText(_fields.first, '80');
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
expect(_fields, findsNWidgets(5));
expect(container.read(patchClashConfigProvider), before);
});
testWidgets('saving valid ports writes them to the patch config', (
tester,
) async {
final container = await _openPortForm(tester);
await tester.enterText(_fields.first, '7891');
await tester.enterText(_fields.at(1), '7892');
await tester.enterText(_fields.at(2), '7893');
await tester.enterText(_fields.at(3), '0');
await tester.enterText(_fields.at(4), '0');
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Save'));
await tester.pumpAndSettle();
expect(_fields, findsNothing);
final config = container.read(patchClashConfigProvider);
expect(config.mixedPort, 7891);
expect(config.port, 7892);
expect(config.socksPort, 7893);
expect(config.redirPort, 0);
expect(config.tproxyPort, 0);
});
}
+7
View File
@@ -208,6 +208,13 @@ void main() {
expect(focused, isA<AppInputBorder>());
expect((focused! as AppInputBorder).borderRadius, AppRadius.full);
expect(focused.borderSide.color, Theme.of(field).colorScheme.primary);
final decorator = tester.widget<InputDecorator>(
find.byType(InputDecorator),
);
expect(
decorator.decoration.floatingLabelBehavior,
FloatingLabelBehavior.never,
);
});
testWidgets('a docked field clears the bottom safe area once', (