feat(ui): present dialogs as iOS alerts and forms as sheets
Changelog: iOS-style alerts and form sheets
This commit is contained in:
+22
-49
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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
@@ -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,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';
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+44
-99
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: ''),
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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', (
|
||||
|
||||
Reference in New Issue
Block a user