Files
MWClash/lib/widgets/input.dart
chen08209 bc552b63bb cache
2026-03-05 17:29:05 +08:00

886 lines
25 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/common.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/dialog.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:fl_clash/widgets/null_status.dart';
import 'package:fl_clash/widgets/pop_scope.dart';
import 'package:fl_clash/widgets/scaffold.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'effect.dart';
import 'list.dart';
import 'theme.dart';
class OptionsDialog<T> extends StatelessWidget {
final String title;
final List<T> options;
final T value;
final String Function(T value) textBuilder;
const OptionsDialog({
super.key,
required this.title,
required this.options,
required this.textBuilder,
required this.value,
});
@override
Widget build(BuildContext context) {
return CommonDialog(
title: title,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: RadioGroup(
onChanged: (value) {
Navigator.of(context).pop(value);
},
groupValue: value,
child: Wrap(
children: [
for (final option in options)
Builder(
builder: (context) {
if (value == option) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Scrollable.ensureVisible(context);
});
}
return ListItem.radio(
delegate: RadioDelegate(
value: option,
onTab: () {
Navigator.of(context).pop(option);
},
),
title: Text(textBuilder(option)),
);
},
),
],
),
),
);
}
}
class CommonCheckBox extends StatelessWidget {
final bool? value;
final ValueChanged<bool?>? onChanged;
final bool isCircle;
const CommonCheckBox({
required this.value,
required this.onChanged,
this.isCircle = false,
super.key,
});
@override
Widget build(BuildContext context) {
return Checkbox(
shape: isCircle ? const CircleBorder() : null,
value: value,
onChanged: onChanged,
);
}
}
class InputDialog extends StatefulWidget {
final String title;
final String value;
final String? suffixText;
final String? labelText;
final String? resetValue;
final String? hintText;
final FormFieldValidator<String>? validator;
final AutovalidateMode? autovalidateMode;
final bool? obscureText;
const InputDialog({
super.key,
required this.title,
required this.value,
this.suffixText,
this.resetValue,
this.hintText,
this.validator,
this.obscureText,
this.labelText,
this.autovalidateMode = AutovalidateMode.onUserInteraction,
});
@override
State<InputDialog> createState() => _InputDialogState();
}
class _InputDialogState extends State<InputDialog> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _textController;
String get value => widget.value;
String get title => widget.title;
String? get suffixText => widget.suffixText;
@override
void initState() {
super.initState();
_textController = TextEditingController(text: value);
}
Future<void> _handleUpdate() async {
if (_formKey.currentState?.validate() == false) return;
final text = _textController.value.text;
Navigator.of(context).pop<String>(text);
}
Future<void> _handleReset() async {
if (widget.resetValue == null) {
return;
}
Navigator.of(context).pop<String>(widget.resetValue);
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CommonDialog(
title: title,
actions: [
if (widget.resetValue != null &&
_textController.value.text != widget.resetValue) ...[
TextButton(
onPressed: _handleReset,
child: Text(appLocalizations.reset),
),
const SizedBox(width: 4),
],
TextButton(
onPressed: _handleUpdate,
child: Text(appLocalizations.submit),
),
],
child: Form(
autovalidateMode: widget.autovalidateMode,
key: _formKey,
child: Wrap(
runSpacing: 16,
children: [
TextFormField(
obscureText: widget.obscureText ?? false,
keyboardType: TextInputType.url,
maxLines: widget.obscureText == true ? 1 : 5,
minLines: 1,
controller: _textController,
onFieldSubmitted: (_) {
_handleUpdate();
},
decoration: InputDecoration(
border: const OutlineInputBorder(),
suffixText: suffixText,
hintText: widget.hintText,
labelText: widget.labelText,
),
validator: widget.validator,
),
],
),
),
);
}
}
class ListInputPage extends ConsumerStatefulWidget {
final String title;
final List<String> items;
final Widget Function(String item) titleBuilder;
final Widget Function(String item)? subtitleBuilder;
final Widget Function(String item)? leadingBuilder;
final String? valueLabel;
const ListInputPage({
super.key,
required this.title,
required this.items,
required this.titleBuilder,
this.leadingBuilder,
this.valueLabel,
this.subtitleBuilder,
});
@override
ConsumerState createState() => _ListInputPageState();
}
class _ListInputPageState extends ConsumerState<ListInputPage> {
List<String> _items = [];
late List<String> _originItems;
final _key = utils.id;
@override
void initState() {
super.initState();
_items = widget.items;
_originItems = List<String>.from(_items);
}
void _handleReorder(int oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final nextItems = List<String>.from(_items);
final item = nextItems.removeAt(oldIndex);
nextItems.insert(newIndex, item);
_items = nextItems;
setState(() {});
}
void _handleSelected(String value) {
ref.read(selectedItemsProvider(_key).notifier).update((state) {
final newState = Set<String>.from(state)..addOrRemove(value);
return newState;
});
}
void _handleSelectAll() {
final ids = _items.toSet();
ref.read(selectedItemsProvider(_key).notifier).update((selected) {
return selected.containsAll(ids) ? {} : ids;
});
}
Future<void> _handleAddOrEdit([String? item]) async {
uniqueValidator(String? value) {
final index = _items.indexWhere((entry) {
return entry == value;
});
final current = item == value;
if (index != -1 && !current) {
return appLocalizations.existsTip(appLocalizations.value);
}
return null;
}
final value = await globalState.showCommonDialog<String>(
child: AddDialog(
valueField: Field(
label: widget.valueLabel ?? appLocalizations.value,
value: item ?? '',
validator: uniqueValidator,
),
title: item != null ? appLocalizations.edit : appLocalizations.add,
),
);
if (value == null) return;
final index = _items.indexWhere((entry) {
return entry == item;
});
final nextItems = List<String>.from(_items);
if (item != null) {
nextItems[index] = value;
} else {
nextItems.add(value);
}
_items = nextItems;
setState(() {});
}
void _handleDelete() {
final selectedItems = ref.read(selectedItemsProvider(_key));
final newItems = _items
.where((item) => !selectedItems.contains(item))
.toList();
_items = newItems;
ref.read(selectedItemsProvider(_key).notifier).value = {};
setState(() {});
}
Future<void> _handleReset() async {
final res = await globalState.showMessage(
message: TextSpan(text: appLocalizations.resetPageChangesTip),
);
if (res != true) {
return;
}
_items = _originItems;
setState(() {});
}
Widget _buildItem({
required String value,
required int index,
required int totalLength,
required bool isSelected,
required bool isEditing,
isDecorator = false,
}) {
ItemPosition position = ItemPosition.middle;
if (totalLength == 1) {
position = ItemPosition.startAndEnd;
} else if (index == totalLength - 1) {
position = ItemPosition.end;
} else if (index == 0) {
position = ItemPosition.start;
}
return ReorderableDelayedDragStartListener(
key: ValueKey(value),
index: index,
child: ItemPositionProvider(
position: position,
child: CommonSelectedInputListItem(
isDecorator: isDecorator,
title: widget.titleBuilder(value),
isSelected: isSelected,
isEditing: isEditing,
onSelected: () {
_handleSelected(value);
},
onPressed: () {
_handleAddOrEdit(value);
},
leading: widget.leadingBuilder != null
? widget.leadingBuilder!(value)
: null,
subtitle: widget.subtitleBuilder != null
? widget.subtitleBuilder!(value)
: null,
),
),
);
}
@override
Widget build(BuildContext context) {
final selectedItems = ref.watch(selectedItemsProvider(_key));
return CommonPopScope(
onPop: (_) {
if (selectedItems.isNotEmpty) {
ref.read(selectedItemsProvider(_key).notifier).value = {};
return false;
}
Navigator.of(context).pop(_items);
return false;
},
child: CommonScaffold(
title: widget.title,
actions: [
if (selectedItems.isNotEmpty) ...[
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
onPressed: _handleDelete,
icon: Icon(Icons.delete),
),
),
SizedBox(width: 2),
] else if (!stringListEquality.equals(_items, _originItems)) ...[
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
onPressed: _handleReset,
icon: const Icon(Icons.replay),
),
),
SizedBox(width: 2),
],
CommonMinFilledButtonTheme(
child: selectedItems.isNotEmpty
? FilledButton(
onPressed: _handleSelectAll,
child: Text(appLocalizations.selectAll),
)
: FilledButton.tonal(
onPressed: () {
_handleAddOrEdit();
},
child: Text(appLocalizations.add),
),
),
SizedBox(width: 8),
],
body: _items.isEmpty
? NullStatus(label: appLocalizations.noData)
: ReorderableListView.builder(
padding: const EdgeInsets.only(
bottom: 16 + 64,
top: 16,
left: 16,
right: 16,
),
buildDefaultDragHandles: false,
itemCount: _items.length,
itemBuilder: (context, index) {
final value = _items[index];
return _buildItem(
value: value,
index: index,
totalLength: _items.length,
isSelected: selectedItems.contains(value),
isEditing: selectedItems.isNotEmpty,
);
},
proxyDecorator: (child, index, animation) {
final value = _items[index];
return commonProxyDecorator(
_buildItem(
value: value,
index: index,
totalLength: _items.length,
isDecorator: true,
isSelected: selectedItems.contains(value),
isEditing: selectedItems.isNotEmpty,
),
index,
animation,
);
},
onReorder: _handleReorder,
),
),
);
}
}
class MapInputPage extends ConsumerStatefulWidget {
final String title;
final Map<String, String> map;
final Widget Function(MapEntry<String, String> item) titleBuilder;
final Widget Function(MapEntry<String, String> item)? subtitleBuilder;
final Widget Function(MapEntry<String, String> item)? leadingBuilder;
final String? keyLabel;
final String? valueLabel;
const MapInputPage({
super.key,
required this.title,
required this.map,
required this.titleBuilder,
this.leadingBuilder,
this.keyLabel,
this.valueLabel,
this.subtitleBuilder,
});
@override
ConsumerState<MapInputPage> createState() => _MapInputPageState();
}
class _MapInputPageState extends ConsumerState<MapInputPage> {
List<MapEntry<String, String>> _items = [];
late final List<MapEntry<String, String>> _originItems;
final _key = utils.id;
@override
void initState() {
super.initState();
_items = List<MapEntry<String, String>>.from(widget.map.entries);
_originItems = List<MapEntry<String, String>>.from(_items);
}
void _handleReorder(int oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final nextItems = List<MapEntry<String, String>>.from(_items);
final item = nextItems.removeAt(oldIndex);
nextItems.insert(newIndex, item);
_items = nextItems;
setState(() {});
}
void _handleSelected(MapEntry<String, String> value) {
ref.read(selectedItemsProvider(_key).notifier).update((state) {
final newState = Set<String>.from(state)..addOrRemove(value.key);
return newState;
});
}
void _handleSelectAll() {
final ids = _items.map((item) => item.key).toSet();
ref.read(selectedItemsProvider(_key).notifier).update((selected) {
return selected.containsAll(ids) ? {} : ids;
});
}
Future<void> _handleAddOrEdit([MapEntry<String, String>? item]) async {
uniqueValidator(String? value) {
final index = _items.indexWhere((entry) {
return entry.key == value;
});
final current = item?.key == value;
if (index != -1 && !current) {
return appLocalizations.existsTip(appLocalizations.key);
}
return null;
}
final keyField = Field(
label: widget.keyLabel ?? appLocalizations.key,
value: item == null ? '' : item.key,
validator: uniqueValidator,
);
final valueField = Field(
label: widget.valueLabel ?? appLocalizations.value,
value: item == null ? '' : item.value,
);
final value = await globalState.showCommonDialog<MapEntry<String, String>>(
child: AddDialog(
keyField: keyField,
valueField: valueField,
title: item != null ? appLocalizations.edit : appLocalizations.add,
),
);
if (value == null) return;
final index = _items.indexWhere((entry) {
return entry.key == item?.key;
});
final nextItems = List<MapEntry<String, String>>.from(_items);
if (item != null) {
nextItems[index] = value;
} else {
nextItems.add(value);
}
_items = nextItems;
setState(() {});
}
void _handleDelete() {
final selectedItems = ref.read(selectedItemsProvider(_key));
final newItems = _items
.where((item) => !selectedItems.contains(item.key))
.toList();
_items = newItems;
ref.read(selectedItemsProvider(_key).notifier).value = {};
setState(() {});
}
Future<void> _handleReset() async {
final res = await globalState.showMessage(
message: TextSpan(text: appLocalizations.resetPageChangesTip),
);
if (res != true) {
return;
}
_items = _originItems;
setState(() {});
}
Widget _buildItem({
required MapEntry<String, String> value,
required int index,
required int totalLength,
required bool isSelected,
required bool isEditing,
isDecorator = false,
}) {
ItemPosition position = ItemPosition.middle;
if (totalLength == 1) {
position = ItemPosition.startAndEnd;
} else if (index == totalLength - 1) {
position = ItemPosition.end;
} else if (index == 0) {
position = ItemPosition.start;
}
return ReorderableDelayedDragStartListener(
key: ValueKey(value),
index: index,
child: ItemPositionProvider(
position: position,
child: CommonSelectedInputListItem(
isDecorator: isDecorator,
title: widget.titleBuilder(value),
leading: widget.leadingBuilder != null
? widget.leadingBuilder!(value)
: null,
subtitle: widget.subtitleBuilder != null
? widget.subtitleBuilder!(value)
: null,
isSelected: isSelected,
isEditing: isEditing,
onSelected: () {
_handleSelected(value);
},
onPressed: () {
_handleAddOrEdit(value);
},
),
),
);
}
@override
Widget build(BuildContext context) {
final selectedItems = ref.watch(selectedItemsProvider(_key));
return CommonPopScope(
onPop: (_) {
if (selectedItems.isNotEmpty) {
ref.read(selectedItemsProvider(_key).notifier).value = {};
return false;
}
Navigator.of(context).pop(Map<String, String>.fromEntries(_items));
return false;
},
child: CommonScaffold(
title: widget.title,
actions: [
if (selectedItems.isNotEmpty) ...[
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
onPressed: _handleDelete,
icon: Icon(Icons.delete),
),
),
SizedBox(width: 2),
] else if (!stringAndStringMapEntryListEquality.equals(
_items,
_originItems,
)) ...[
CommonMinIconButtonTheme(
child: IconButton.filledTonal(
onPressed: _handleReset,
icon: const Icon(Icons.replay),
),
),
SizedBox(width: 2),
],
CommonMinFilledButtonTheme(
child: selectedItems.isNotEmpty
? FilledButton(
onPressed: _handleSelectAll,
child: Text(appLocalizations.selectAll),
)
: FilledButton.tonal(
onPressed: () {
_handleAddOrEdit();
},
child: Text(appLocalizations.add),
),
),
SizedBox(width: 8),
],
body: _items.isEmpty
? NullStatus(label: appLocalizations.noData)
: ReorderableListView.builder(
padding: const EdgeInsets.only(
bottom: 16 + 64,
top: 16,
left: 16,
right: 16,
),
buildDefaultDragHandles: false,
itemCount: _items.length,
itemBuilder: (context, index) {
final value = _items[index];
return _buildItem(
value: value,
index: index,
totalLength: _items.length,
isSelected: selectedItems.contains(value.key),
isEditing: selectedItems.isNotEmpty,
);
},
proxyDecorator: (child, index, animation) {
final value = _items[index];
return commonProxyDecorator(
_buildItem(
value: value,
index: index,
totalLength: _items.length,
isDecorator: true,
isSelected: selectedItems.contains(value.key),
isEditing: selectedItems.isNotEmpty,
),
index,
animation,
);
},
onReorder: _handleReorder,
),
),
);
}
}
class AddDialog extends StatefulWidget {
final String title;
final Field? keyField;
final Field valueField;
const AddDialog({
super.key,
required this.title,
this.keyField,
required this.valueField,
});
@override
State<AddDialog> createState() => _AddDialogState();
}
class _AddDialogState extends State<AddDialog> {
TextEditingController? _keyController;
late TextEditingController _valueController;
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
Field? get keyField => widget.keyField;
Field get valueField => widget.valueField;
@override
void initState() {
super.initState();
if (keyField != null) {
_keyController = TextEditingController(text: keyField!.value);
}
_valueController = TextEditingController(text: valueField.value);
}
void _submit() {
if (!_formKey.currentState!.validate()) return;
if (keyField != null) {
Navigator.of(context).pop<MapEntry<String, String>>(
MapEntry(_keyController!.text, _valueController.text),
);
} else {
Navigator.of(context).pop<String>(_valueController.text);
}
}
@override
void dispose() {
_keyController?.dispose();
_valueController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CommonDialog(
title: widget.title,
actions: [
TextButton(onPressed: _submit, child: Text(appLocalizations.confirm)),
],
child: Form(
autovalidateMode: AutovalidateMode.onUserInteraction,
key: _formKey,
child: Wrap(
runSpacing: 16,
children: [
if (keyField != null)
TextFormField(
maxLines: 3,
minLines: 1,
controller: _keyController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
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: 3,
minLines: 1,
keyboardType: TextInputType.text,
controller: _valueController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
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;
},
),
],
),
),
);
}
}
class NoInputBorder extends InputBorder {
const NoInputBorder() : super(borderSide: BorderSide.none);
@override
NoInputBorder copyWith({BorderSide? borderSide}) => const NoInputBorder();
@override
bool get isOutline => false;
@override
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
@override
NoInputBorder scale(double t) => const NoInputBorder();
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
return Path()..addRect(rect);
}
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
return Path()..addRect(rect);
}
@override
void paintInterior(
Canvas canvas,
Rect rect,
Paint paint, {
TextDirection? textDirection,
}) {
canvas.drawRect(rect, paint);
}
@override
bool get preferPaintInterior => true;
@override
void paint(
Canvas canvas,
Rect rect, {
double? gapStart,
double gapExtent = 0.0,
double gapPercentage = 0.0,
TextDirection? textDirection,
}) {}
}