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'; import 'package:flutter_typeahead/flutter_typeahead.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; late List? autoCompleteOptions; 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, this.autoCompleteOptions, }); @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 { bool disabled = false; GeneratedFormSwitch( super.key, { super.label, super.belowWidgets, bool super.defaultValue = false, bool disabled = 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, disabled: false, 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 < formInputs.length; r++) { for (int i = 0; i < formInputs[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); } void 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(); var ctrl = TextEditingController(text: values[formItem.key]); return TypeAheadField( controller: ctrl, builder: (context, controller, focusNode) { return TextFormField( controller: ctrl, focusNode: focusNode, keyboardType: formItem.textInputType, obscureText: formItem.password, autocorrect: !formItem.password, enableSuggestions: !formItem.password, key: formFieldKey, 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; }, ); }, itemBuilder: (context, value) { return ListTile(title: Text(value)); }, onSelected: (value) { ctrl.text = value; setState(() { values[formItem.key] = value; someValueChanged(); }); }, suggestionsCallback: (search) { return formItem.autoCompleteOptions ?.where((t) => t.toLowerCase().contains(search.toLowerCase())) .toList(); }, hideOnEmpty: true, ); } 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: (widget.items[r][e] as GeneratedFormSwitch).disabled ? null : (value) { setState(() { values[fieldKey] = value; someValueChanged(); }); }, ), ], ); } else if (widget.items[r][e] is GeneratedFormTagInput) { onAddPressed() { 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(); } }); } }); } 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< String, MapEntry >)[e2.key]! .key, value, ); if ((widget.items[r][e] as GeneratedFormTagInput) .singleSelect && value == true) { for (var key in (values[fieldKey] as Map< String, MapEntry >) .keys) { if (key != e2.key) { (values[fieldKey] as Map< String, MapEntry >)[key] = MapEntry( (values[fieldKey] as Map< String, MapEntry >)[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(), (values[fieldKey] as Map>?) ?.isEmpty == true ? Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: TextButton.icon( onPressed: onAddPressed, icon: const Icon(Icons.add), label: Text( (widget.items[r][e] as GeneratedFormTagInput) .label, ), ), ) : Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: IconButton( onPressed: onAddPressed, 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)], ), ), ], ), ); } }