Files
Obtainium/lib/components/generated_form.dart
Imran Remtulla 9fba747802 Version detection improvements, Mullvad web scraping fix and changelog addition, code readability improvements, general tweaks/bugfixes (#400)
1. Apps that don't have "standard" versioning formats now automatically stop using version detection. This will prevent users from having to learn about this feature and enable it manually.
    - For such Apps, the "standard" version detection option is greyed out.
2. The Mullvad Source recently broke due to a slight change in their website design. This is now fixed.
    - Mullvad also now provides an in-app changelog via their official GitHub repo.
3. Code has been refactored for readability (specifically the version detection code and UI code for most screens).
4. Minor UI tweaks and bugfixes.
2023-03-30 17:27:36 -04:00

508 lines
19 KiB
Dart

import 'dart:math';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:obtainium/components/generated_form_modal.dart';
abstract class GeneratedFormItem {
late String key;
late String label;
late List<Widget> belowWidgets;
late dynamic defaultValue;
List<dynamic> additionalValidators;
dynamic ensureType(dynamic val);
GeneratedFormItem(this.key,
{this.label = 'Input',
this.belowWidgets = const [],
this.defaultValue,
this.additionalValidators = const []});
}
class GeneratedFormTextField extends GeneratedFormItem {
late bool required;
late int max;
late String? hint;
late bool password;
GeneratedFormTextField(String key,
{String label = 'Input',
List<Widget> belowWidgets = const [],
String defaultValue = '',
List<String? Function(String? value)> additionalValidators = const [],
this.required = true,
this.max = 1,
this.hint,
this.password = false})
: super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
String ensureType(val) {
return val.toString();
}
}
class GeneratedFormDropdown extends GeneratedFormItem {
late List<MapEntry<String, String>>? opts;
List<String>? disabledOptKeys;
GeneratedFormDropdown(
String key,
this.opts, {
String label = 'Input',
List<Widget> belowWidgets = const [],
String defaultValue = '',
this.disabledOptKeys,
List<String? Function(String? value)> additionalValidators = const [],
}) : super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
String ensureType(val) {
return val.toString();
}
}
class GeneratedFormSwitch extends GeneratedFormItem {
GeneratedFormSwitch(
String key, {
String label = 'Input',
List<Widget> belowWidgets = const [],
bool defaultValue = false,
List<String? Function(bool value)> additionalValidators = const [],
}) : super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
bool ensureType(val) {
return val == true || val == 'true';
}
}
class GeneratedFormTagInput extends GeneratedFormItem {
late MapEntry<String, String>? deleteConfirmationMessage;
late bool singleSelect;
late WrapAlignment alignment;
late String emptyMessage;
late bool showLabelWhenNotEmpty;
GeneratedFormTagInput(String key,
{String label = 'Input',
List<Widget> belowWidgets = const [],
Map<String, MapEntry<int, bool>> defaultValue = const {},
List<String? Function(Map<String, MapEntry<int, bool>> value)>
additionalValidators = const [],
this.deleteConfirmationMessage,
this.singleSelect = false,
this.alignment = WrapAlignment.start,
this.emptyMessage = 'Input',
this.showLabelWhenNotEmpty = true})
: super(key,
label: label,
belowWidgets: belowWidgets,
defaultValue: defaultValue,
additionalValidators: additionalValidators);
@override
Map<String, MapEntry<int, bool>> ensureType(val) {
return val is Map<String, MapEntry<int, bool>> ? val : {};
}
}
typedef OnValueChanges = void Function(
Map<String, dynamic> values, bool valid, bool isBuilding);
class GeneratedForm extends StatefulWidget {
const GeneratedForm(
{super.key, required this.items, required this.onValueChanges});
final List<List<GeneratedFormItem>> items;
final OnValueChanges onValueChanges;
@override
State<GeneratedForm> createState() => _GeneratedFormState();
}
// Generates a random light color
// Courtesy of ChatGPT 😭 (with a bugfix 🥳)
Color generateRandomLightColor() {
// Create a random number generator
final Random random = Random();
// Generate random hue, saturation, and value values
final double hue = random.nextDouble() * 360;
final double saturation = 0.5 + random.nextDouble() * 0.5;
final double value = 0.9 + random.nextDouble() * 0.1;
// Create a HSV color with the random values
return HSVColor.fromAHSV(1.0, hue, saturation, value).toColor();
}
class _GeneratedFormState extends State<GeneratedForm> {
final _formKey = GlobalKey<FormState>();
Map<String, dynamic> values = {};
late List<List<Widget>> formInputs;
List<List<Widget>> rows = [];
String? initKey;
// If any value changes, call this to update the parent with value and validity
void someValueChanged({bool isBuilding = false}) {
Map<String, dynamic> returnValues = values;
var valid = true;
for (int r = 0; r < widget.items.length; r++) {
for (int i = 0; i < widget.items[r].length; i++) {
if (formInputs[r][i] is TextFormField) {
var fieldState =
(formInputs[r][i].key as GlobalKey<FormFieldState>).currentState;
if (fieldState != null) {
valid = valid && fieldState.isValid;
}
}
}
}
widget.onValueChanges(returnValues, valid, isBuilding);
}
initForm() {
initKey = widget.key.toString();
// Initialize form values as all empty
values.clear();
for (var row in widget.items) {
for (var e in row) {
values[e.key] = e.defaultValue;
}
}
// Dynamically create form inputs
formInputs = widget.items.asMap().entries.map((row) {
return row.value.asMap().entries.map((e) {
var formItem = e.value;
if (formItem is GeneratedFormTextField) {
final formFieldKey = GlobalKey<FormFieldState>();
return TextFormField(
obscureText: formItem.password,
autocorrect: !formItem.password,
enableSuggestions: !formItem.password,
key: formFieldKey,
initialValue: values[formItem.key],
autovalidateMode: AutovalidateMode.onUserInteraction,
onChanged: (value) {
setState(() {
values[formItem.key] = value;
someValueChanged();
});
},
decoration: InputDecoration(
helperText: formItem.label + (formItem.required ? ' *' : ''),
hintText: formItem.hint),
minLines: formItem.max <= 1 ? null : formItem.max,
maxLines: formItem.max <= 1 ? 1 : formItem.max,
validator: (value) {
if (formItem.required &&
(value == null || value.trim().isEmpty)) {
return '${formItem.label} ${tr('requiredInBrackets')}';
}
for (var validator in formItem.additionalValidators) {
String? result = validator(value);
if (result != null) {
return result;
}
}
return null;
},
);
} else if (formItem is GeneratedFormDropdown) {
if (formItem.opts!.isEmpty) {
return Text(tr('dropdownNoOptsError'));
}
return DropdownButtonFormField(
decoration: InputDecoration(labelText: formItem.label),
value: values[formItem.key],
items: formItem.opts!.map((e2) {
var enabled =
formItem.disabledOptKeys?.contains(e2.key) != true;
return DropdownMenuItem(
value: e2.key,
enabled: enabled,
child: Opacity(
opacity: enabled ? 1 : 0.5, child: Text(e2.value)));
}).toList(),
onChanged: (value) {
setState(() {
values[formItem.key] = value ?? formItem.opts!.first.key;
someValueChanged();
});
});
} else {
return Container(); // Some input types added in build
}
}).toList();
}).toList();
someValueChanged(isBuilding: true);
}
@override
void initState() {
super.initState();
initForm();
}
@override
Widget build(BuildContext context) {
if (widget.key.toString() != initKey) {
initForm();
}
for (var r = 0; r < formInputs.length; r++) {
for (var e = 0; e < formInputs[r].length; e++) {
if (widget.items[r][e] is GeneratedFormSwitch) {
formInputs[r][e] = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.items[r][e].label),
Switch(
value: values[widget.items[r][e].key],
onChanged: (value) {
setState(() {
values[widget.items[r][e].key] = value;
someValueChanged();
});
})
],
);
} else if (widget.items[r][e] is GeneratedFormTagInput) {
formInputs[r][e] =
Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if ((values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?)
?.isNotEmpty ==
true &&
(widget.items[r][e] as GeneratedFormTagInput)
.showLabelWhenNotEmpty)
Column(
crossAxisAlignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment ==
WrapAlignment.center
? CrossAxisAlignment.center
: CrossAxisAlignment.stretch,
children: [
Text(widget.items[r][e].label),
const SizedBox(
height: 8,
),
],
),
Wrap(
alignment:
(widget.items[r][e] as GeneratedFormTagInput).alignment,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
(values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?)
?.isEmpty ==
true
? Text(
(widget.items[r][e] as GeneratedFormTagInput)
.emptyMessage,
)
: const SizedBox.shrink(),
...(values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?)
?.entries
.map((e2) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(
label: Text(e2.key),
backgroundColor: Color(e2.value.key).withAlpha(50),
selectedColor: Color(e2.value.key),
visualDensity: VisualDensity.compact,
selected: e2.value.value,
onSelected: (value) {
setState(() {
(values[widget.items[r][e].key] as Map<String,
MapEntry<int, bool>>)[e2.key] =
MapEntry(
(values[widget.items[r][e].key] as Map<
String,
MapEntry<int, bool>>)[e2.key]!
.key,
value);
if ((widget.items[r][e]
as GeneratedFormTagInput)
.singleSelect &&
value == true) {
for (var key in (values[
widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>)
.keys) {
if (key != e2.key) {
(values[widget.items[r][e].key] as Map<
String,
MapEntry<int, bool>>)[key] =
MapEntry(
(values[widget.items[r][e].key]
as Map<
String,
MapEntry<int,
bool>>)[key]!
.key,
false);
}
}
}
someValueChanged();
});
},
));
}) ??
[const SizedBox.shrink()],
(values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?)
?.values
.where((e) => e.value)
.isNotEmpty ==
true
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
fn() {
setState(() {
var temp = values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>;
temp.removeWhere((key, value) => value.value);
values[widget.items[r][e].key] = temp;
someValueChanged();
});
}
if ((widget.items[r][e] as GeneratedFormTagInput)
.deleteConfirmationMessage !=
null) {
var message =
(widget.items[r][e] as GeneratedFormTagInput)
.deleteConfirmationMessage!;
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: message.key,
message: message.value,
items: const []);
}).then((value) {
if (value != null) {
fn();
}
});
} else {
fn();
}
},
icon: const Icon(Icons.remove),
visualDensity: VisualDensity.compact,
tooltip: tr('remove'),
))
: const SizedBox.shrink(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: IconButton(
onPressed: () {
showDialog<Map<String, dynamic>?>(
context: context,
builder: (BuildContext ctx) {
return GeneratedFormModal(
title: widget.items[r][e].label,
items: [
[
GeneratedFormTextField('label',
label: tr('label'))
]
]);
}).then((value) {
String? label = value?['label'];
if (label != null) {
setState(() {
var temp = values[widget.items[r][e].key]
as Map<String, MapEntry<int, bool>>?;
temp ??= {};
if (temp[label] == null) {
var singleSelect = (widget.items[r][e]
as GeneratedFormTagInput)
.singleSelect;
var someSelected = temp.entries
.where((element) => element.value.value)
.isNotEmpty;
temp[label] = MapEntry(
generateRandomLightColor().value,
!(someSelected && singleSelect));
values[widget.items[r][e].key] = temp;
someValueChanged();
}
});
}
});
},
icon: const Icon(Icons.add),
visualDensity: VisualDensity.compact,
tooltip: tr('add'),
)),
],
)
]);
}
}
}
rows.clear();
formInputs.asMap().entries.forEach((rowInputs) {
if (rowInputs.key > 0) {
rows.add([
SizedBox(
height: widget.items[rowInputs.key - 1][0] is GeneratedFormSwitch
? 8
: 25,
)
]);
}
List<Widget> rowItems = [];
rowInputs.value.asMap().entries.forEach((rowInput) {
if (rowInput.key > 0) {
rowItems.add(const SizedBox(
width: 20,
));
}
rowItems.add(Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
rowInput.value,
...widget.items[rowInputs.key][rowInput.key].belowWidgets
])));
});
rows.add(rowItems);
});
return Form(
key: _formKey,
child: Column(
children: [
...rows.map((row) => Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [...row.map((e) => e)],
))
],
));
}
}