import 'dart:math'; import 'package:hsluv/hsluv.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:obtainium/components/generated_form_modal.dart'; import 'package:obtainium/providers/source_provider.dart'; abstract class GeneratedFormItem { late String key; late String label; late List belowWidgets; late dynamic defaultValue; List additionalValidators; dynamic ensureType(dynamic val); GeneratedFormItem clone(); 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; late TextInputType? textInputType; GeneratedFormTextField(super.key, {super.label, super.belowWidgets, String super.defaultValue = '', List super.additionalValidators = const [], this.required = true, this.max = 1, this.hint, this.password = false, this.textInputType}); @override String ensureType(val) { return val.toString(); } @override GeneratedFormTextField clone() { return GeneratedFormTextField(key, label: label, belowWidgets: belowWidgets, defaultValue: defaultValue, additionalValidators: List.from(additionalValidators), required: required, max: max, hint: hint, password: password, textInputType: textInputType); } } class GeneratedFormDropdown extends GeneratedFormItem { late List>? opts; List? disabledOptKeys; GeneratedFormDropdown( super.key, this.opts, { super.label, super.belowWidgets, String super.defaultValue = '', this.disabledOptKeys, List super.additionalValidators = const [], }); @override String ensureType(val) { return val.toString(); } @override GeneratedFormDropdown clone() { return GeneratedFormDropdown( key, opts?.map((e) => MapEntry(e.key, e.value)).toList(), label: label, belowWidgets: belowWidgets, defaultValue: defaultValue, disabledOptKeys: disabledOptKeys != null ? List.from(disabledOptKeys!) : null, additionalValidators: List.from(additionalValidators), ); } } class GeneratedFormSwitch extends GeneratedFormItem { GeneratedFormSwitch( super.key, { super.label, super.belowWidgets, bool super.defaultValue = false, List super.additionalValidators = const [], }); @override bool ensureType(val) { return val == true || val == 'true'; } @override GeneratedFormSwitch clone() { return GeneratedFormSwitch(key, label: label, belowWidgets: belowWidgets, defaultValue: defaultValue, additionalValidators: List.from(additionalValidators)); } } class GeneratedFormTagInput extends GeneratedFormItem { late MapEntry? deleteConfirmationMessage; late bool singleSelect; late WrapAlignment alignment; late String emptyMessage; late bool showLabelWhenNotEmpty; GeneratedFormTagInput(super.key, {super.label, super.belowWidgets, Map> super.defaultValue = const {}, List> value)> super.additionalValidators = const [], this.deleteConfirmationMessage, this.singleSelect = false, this.alignment = WrapAlignment.start, this.emptyMessage = 'Input', this.showLabelWhenNotEmpty = true}); @override Map> ensureType(val) { return val is Map> ? val : {}; } @override GeneratedFormTagInput clone() { return GeneratedFormTagInput(key, label: label, belowWidgets: belowWidgets, defaultValue: defaultValue, additionalValidators: List.from(additionalValidators), deleteConfirmationMessage: deleteConfirmationMessage, singleSelect: singleSelect, alignment: alignment, emptyMessage: emptyMessage, showLabelWhenNotEmpty: showLabelWhenNotEmpty); } } typedef OnValueChanges = void Function( Map values, bool valid, bool isBuilding); class GeneratedForm extends StatefulWidget { const GeneratedForm( {super.key, required this.items, required this.onValueChanges}); final List> items; final OnValueChanges onValueChanges; @override State createState() => _GeneratedFormState(); } List> cloneFormItems( List> items) { List> clonedItems = []; for (var row in items) { List clonedRow = []; for (var it in row) { clonedRow.add(it.clone()); } clonedItems.add(clonedRow); } return clonedItems; } class GeneratedFormSubForm extends GeneratedFormItem { final List> items; GeneratedFormSubForm(super.key, this.items, {super.label, super.belowWidgets, super.defaultValue = const []}); @override ensureType(val) { return val; // Not easy to validate List> } @override GeneratedFormSubForm clone() { return GeneratedFormSubForm(key, cloneFormItems(items), label: label, belowWidgets: belowWidgets, defaultValue: defaultValue); } } // Generates a color in the HSLuv (Pastel) color space // https://pub.dev/documentation/hsluv/latest/hsluv/Hsluv/hpluvToRgb.html Color generateRandomLightColor() { final randomSeed = Random().nextInt(120); // https://en.wikipedia.org/wiki/Golden_angle final goldenAngle = 180 * (3 - sqrt(5)); // Generate next golden angle hue final double hue = randomSeed * goldenAngle; // Map from HPLuv color space to RGB, use constant saturation=100, lightness=70 final List rgbValuesDbl = Hsluv.hpluvToRgb([hue, 100, 70]); // Map RBG values from 0-1 to 0-255: final List rgbValues = rgbValuesDbl.map((rgb) => (rgb * 255).toInt()).toList(); return Color.fromARGB(255, rgbValues[0], rgbValues[1], rgbValues[2]); } int generateRandomNumber(int seed1, {int seed2 = 0, int seed3 = 0, max = 10000}) { int combinedSeed = seed1.hashCode ^ seed2.hashCode ^ seed3.hashCode; Random random = Random(combinedSeed); int randomNumber = random.nextInt(max); return randomNumber; } bool validateTextField(TextFormField tf) => (tf.key as GlobalKey).currentState?.isValid == true; class _GeneratedFormState extends State { final _formKey = GlobalKey(); Map values = {}; late List> formInputs; List> rows = []; String? initKey; int forceUpdateKeyCount = 0; // If any value changes, call this to update the parent with value and validity void someValueChanged({bool isBuilding = false, bool forceInvalid = false}) { Map 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) { valid = valid && validateTextField(formInputs[r][i] as TextFormField); } } } if (forceInvalid) { valid = false; } 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(); return TextFormField( keyboardType: formItem.textInputType, 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 if (formItem is GeneratedFormSubForm) { values[formItem.key] = []; for (Map v in ((formItem.defaultValue ?? []) as List)) { var fullDefaults = getDefaultValuesFromFormItems(formItem.items); for (var element in v.entries) { fullDefaults[element.key] = element.value; } values[formItem.key].add(fullDefaults); } return Container(); } 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++) { String fieldKey = widget.items[r][e].key; if (widget.items[r][e] is GeneratedFormSwitch) { formInputs[r][e] = Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible(child: Text(widget.items[r][e].label)), const SizedBox( width: 8, ), Switch( value: values[fieldKey], onChanged: (value) { setState(() { values[fieldKey] = value; someValueChanged(); }); }) ], ); } else if (widget.items[r][e] is GeneratedFormTagInput) { formInputs[r][e] = Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if ((values[fieldKey] as Map>?) ?.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[fieldKey] as Map>?) ?.isEmpty == true ? Text( (widget.items[r][e] as GeneratedFormTagInput) .emptyMessage, ) : const SizedBox.shrink(), ...(values[fieldKey] as Map>?) ?.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[fieldKey] as Map>)[e2.key] = MapEntry( (values[fieldKey] as Map>)[e2.key]! .key, value); if ((widget.items[r][e] as GeneratedFormTagInput) .singleSelect && value == true) { for (var key in (values[fieldKey] as Map>) .keys) { if (key != e2.key) { (values[fieldKey] as Map< String, MapEntry>)[key] = MapEntry( (values[fieldKey] as Map>)[key]! .key, false); } } } someValueChanged(); }); }, )); }) ?? [const SizedBox.shrink()], (values[fieldKey] as Map>?) ?.values .where((e) => e.value) .length == 1 ? Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: IconButton( onPressed: () { setState(() { var temp = values[fieldKey] as Map>; // get selected category str where bool is true final oldEntry = temp.entries .firstWhere((entry) => entry.value.value); // generate new color, ensure it is not the same int newColor = oldEntry.value.key; while (oldEntry.value.key == newColor) { newColor = generateRandomLightColor().value; } // Update entry with new color, remain selected temp.update(oldEntry.key, (old) => MapEntry(newColor, old.value)); values[fieldKey] = temp; someValueChanged(); }); }, icon: const Icon(Icons.format_color_fill_rounded), visualDensity: VisualDensity.compact, tooltip: tr('colour'), )) : const SizedBox.shrink(), (values[fieldKey] as Map>?) ?.values .where((e) => e.value) .isNotEmpty == true ? Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: IconButton( onPressed: () { fn() { setState(() { var temp = values[fieldKey] as Map>; temp.removeWhere((key, value) => value.value); values[fieldKey] = temp; someValueChanged(); }); } if ((widget.items[r][e] as GeneratedFormTagInput) .deleteConfirmationMessage != null) { var message = (widget.items[r][e] as GeneratedFormTagInput) .deleteConfirmationMessage!; showDialog?>( 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?>( 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[fieldKey] as Map>?; 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[fieldKey] = temp; someValueChanged(); } }); } }); }, icon: const Icon(Icons.add), visualDensity: VisualDensity.compact, tooltip: tr('add'), )), ], ) ]); } else if (widget.items[r][e] is GeneratedFormSubForm) { List subformColumn = []; var compact = (widget.items[r][e] as GeneratedFormSubForm) .items .length == 1 && (widget.items[r][e] as GeneratedFormSubForm).items[0].length == 1; for (int i = 0; i < values[fieldKey].length; i++) { var internalFormKey = ValueKey(generateRandomNumber( values[fieldKey].length, seed2: i, seed3: forceUpdateKeyCount)); subformColumn.add(Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!compact) const SizedBox( height: 16, ), if (!compact) Text( '${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})', style: const TextStyle(fontWeight: FontWeight.bold), ), GeneratedForm( key: internalFormKey, items: cloneFormItems( (widget.items[r][e] as GeneratedFormSubForm).items) .map((x) => x.map((y) { y.defaultValue = values[fieldKey]?[i]?[y.key]; y.key = '${y.key.toString()},$internalFormKey'; return y; }).toList()) .toList(), onValueChanges: (values, valid, isBuilding) { values = values.map( (key, value) => MapEntry(key.split(',')[0], value)); if (valid) { this.values[fieldKey]?[i] = values; } someValueChanged( isBuilding: isBuilding, forceInvalid: !valid); }, ), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton.icon( style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error), onPressed: (values[fieldKey].length > 0) ? () { var temp = List.from(values[fieldKey]); temp.removeAt(i); values[fieldKey] = List.from(temp); forceUpdateKeyCount++; someValueChanged(); } : null, label: Text( '${(widget.items[r][e] as GeneratedFormSubForm).label} (${i + 1})', ), icon: const Icon( Icons.delete_outline_rounded, )) ], ) ], )); } subformColumn.add(Padding( padding: const EdgeInsets.only(bottom: 0, top: 8), child: Row( children: [ Expanded( child: ElevatedButton.icon( onPressed: () { values[fieldKey].add(getDefaultValuesFromFormItems( (widget.items[r][e] as GeneratedFormSubForm) .items)); forceUpdateKeyCount++; someValueChanged(); }, icon: const Icon(Icons.add), label: Text((widget.items[r][e] as GeneratedFormSubForm) .label))), ], ), )); formInputs[r][e] = Column(children: subformColumn); } } } 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 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)], )) ], )); } }