This commit is contained in:
string 2025-09-10 10:57:03 +05:30
parent 13eca99151
commit bc02a06d56
14 changed files with 2580 additions and 79 deletions

View File

@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'base_field.dart';
import '../../../shared/widgets/inputs/modern_text_field.dart';
class AutocompleteDropdownField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final Future<List<Map<String, dynamic>>> Function() optionsLoader;
final String valueKey;
final String displayKey;
AutocompleteDropdownField({
required this.fieldKey,
required this.label,
required this.hint,
required this.optionsLoader,
required this.valueKey,
required this.displayKey,
this.isRequired = false,
}) : assert(valueKey != ''),
assert(displayKey != '');
@override
String? Function(String?)? get validator => (value) {
if (isRequired && (value == null || value.isEmpty)) {
return '$label is required';
}
return null;
};
@override
Map<String, dynamic>? get customProperties => {
'isAutocomplete': true,
'valueKey': valueKey,
'displayKey': displayKey,
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
// We'll lazily set the UI label into the Autocomplete's textController once options load
String initialLabel = '';
return FutureBuilder<List<Map<String, dynamic>>>(
future: optionsLoader(),
builder: (context, snapshot) {
final List<Map<String, dynamic>> options = snapshot.data ?? const [];
// Resolve initial display label from stored id
if (snapshot.connectionState == ConnectionState.done &&
controller.text.isNotEmpty) {
final match = options.firstWhere(
(o) => (o[valueKey]?.toString() ?? '') == controller.text,
orElse: () => const {},
);
initialLabel =
match.isNotEmpty ? (match[displayKey]?.toString() ?? '') : '';
}
final Iterable<String> displayOptions = options
.map((e) => e[displayKey])
.where((e) => e != null)
.map((e) => e.toString());
return Autocomplete<String>(
optionsBuilder: (TextEditingValue tev) {
if (tev.text.isEmpty) return const Iterable<String>.empty();
return displayOptions.where(
(opt) => opt.toLowerCase().contains(tev.text.toLowerCase()));
},
onSelected: (String selection) {
// set UI label and hidden id
final match = options.firstWhere(
(o) => (o[displayKey]?.toString() ?? '') == selection,
orElse: () => const {},
);
final idStr =
match.isNotEmpty ? (match[valueKey]?.toString() ?? '') : '';
controller.text = idStr;
onChanged?.call();
},
fieldViewBuilder:
(context, textController, focusNode, onFieldSubmitted) {
// Initialize UI text once options are available
if (initialLabel.isNotEmpty && textController.text.isEmpty) {
textController.text = initialLabel;
}
return ModernTextField(
label: label,
hint: hint,
controller: textController,
focusNode: focusNode,
validator: (v) => validator?.call(controller.text),
onChanged: (_) => onChanged?.call(),
suffixIcon: textController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
textController.clear();
controller.clear();
onChanged?.call();
},
)
: const Icon(Icons.search),
);
},
optionsViewBuilder: (context, onSelected, optionsIt) {
final optionsList = optionsIt.toList();
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 6,
color: colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side:
BorderSide(color: colorScheme.outline.withOpacity(0.15)),
),
child: ConstrainedBox(
constraints:
const BoxConstraints(maxHeight: 280, minWidth: 240),
child: optionsList.isEmpty
? Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.info_outline,
color: colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'No results',
style: TextStyle(
color: colorScheme.onSurfaceVariant),
),
),
],
),
)
: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: optionsList.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: colorScheme.outline.withOpacity(0.08)),
itemBuilder: (context, index) {
final opt = optionsList[index];
return InkWell(
onTap: () => onSelected(opt),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
child: _buildHighlightedText(
opt, // current query text
// Pull current query from the Autocomplete field's text
// (not stored here directly, so we simply render opt)
'',
colorScheme),
),
);
},
),
),
),
);
},
);
},
);
}
Widget _buildHighlightedText(
String text, String query, ColorScheme colorScheme) {
if (query.isEmpty)
return Text(text, style: TextStyle(color: colorScheme.onSurface));
final lowerText = text.toLowerCase();
final lowerQuery = query.toLowerCase();
final int start = lowerText.indexOf(lowerQuery);
if (start < 0)
return Text(text, style: TextStyle(color: colorScheme.onSurface));
final int end = start + query.length;
return RichText(
text: TextSpan(
children: [
TextSpan(
text: text.substring(0, start),
style: TextStyle(color: colorScheme.onSurface)),
TextSpan(
text: text.substring(start, end),
style: TextStyle(
color: colorScheme.primary, fontWeight: FontWeight.w600)),
TextSpan(
text: text.substring(end),
style: TextStyle(color: colorScheme.onSurface)),
],
),
);
}
}

View File

@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'base_field.dart';
import '../../../shared/widgets/inputs/modern_text_field.dart';
class AutocompleteMultiSelectField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final Future<List<String>> Function() optionsLoader;
AutocompleteMultiSelectField({
required this.fieldKey,
required this.label,
required this.hint,
required this.optionsLoader,
this.isRequired = false,
});
@override
String? Function(String?)? get validator => (value) {
if (isRequired && (value == null || value.isEmpty)) {
return '$label is required';
}
return null;
};
@override
Map<String, dynamic>? get customProperties => const {
'isMultiSelect': true,
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return FutureBuilder<List<String>>(
future: optionsLoader(),
builder: (context, snapshot) {
final options = snapshot.data ?? const <String>[];
final Set<String> selected = controller.text.isEmpty
? <String>{}
: controller.text
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toSet();
void toggleSelection(String value) {
if (selected.contains(value)) {
selected.remove(value);
} else {
selected.add(value);
}
controller.text = selected.join(',');
onChanged?.call();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Autocomplete<String>(
optionsBuilder: (TextEditingValue tev) {
if (tev.text.isEmpty) return const Iterable<String>.empty();
return options.where((opt) =>
opt.toLowerCase().contains(tev.text.toLowerCase()));
},
onSelected: (String selection) => toggleSelection(selection),
fieldViewBuilder:
(context, textController, focusNode, onFieldSubmitted) {
return ModernTextField(
label: label,
hint: hint,
controller: textController,
focusNode: focusNode,
onSubmitted: (_) {
final v = textController.text.trim();
if (v.isNotEmpty) {
toggleSelection(v);
textController.clear();
}
},
suffixIcon: textController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
textController.clear();
onChanged?.call();
},
)
: const Icon(Icons.search),
onChanged: (_) => onChanged?.call(),
);
},
optionsViewBuilder: (context, onSelected, optionsIt) {
final list = optionsIt.toList();
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 6,
color: colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: colorScheme.outline.withOpacity(0.15)),
),
child: ConstrainedBox(
constraints:
const BoxConstraints(maxHeight: 280, minWidth: 240),
child: list.isEmpty
? Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.info_outline,
color: colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'No results',
style: TextStyle(
color: colorScheme.onSurfaceVariant),
),
),
],
),
)
: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: list.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: colorScheme.outline.withOpacity(0.08)),
itemBuilder: (context, index) {
final opt = list[index];
return ListTile(
title: Text(opt),
onTap: () {
onSelected(opt);
},
);
},
),
),
),
);
},
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: selected
.map((value) => Chip(
label: Text(value),
onDeleted: () => toggleSelection(value),
))
.toList(),
),
if (snapshot.connectionState == ConnectionState.waiting)
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(minHeight: 2),
),
if (validator != null)
Builder(
builder: (context) {
final error = validator!(controller.text);
return error != null
? Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(error,
style: TextStyle(
color: colorScheme.error, fontSize: 12)),
)
: const SizedBox.shrink();
},
)
],
);
},
);
}
}

View File

@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'base_field.dart';
import '../ui/entity_form.dart';
import '../../../shared/widgets/inputs/modern_text_field.dart';
/// Calculated field
/// - Select multiple source fields from current form
/// - Apply operation: add, subtract, multiply, divide, concat
/// - Displays result (read-only) and stores it in controller
class CalculatedField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final List<String> sourceKeys; // keys of other fields in same form
final String operation; // add|sub|mul|div|concat
CalculatedField({
required this.fieldKey,
required this.label,
this.hint = '',
this.isRequired = false,
required this.sourceKeys,
required this.operation,
});
@override
String? Function(String?)? get validator => (value) {
if (isRequired && (value == null || value.isEmpty)) {
return '$label is required';
}
return null;
};
@override
Map<String, dynamic>? get customProperties => const {
'isCalculated': true,
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return _CalculatedWidget(
label: label,
hint: hint,
controller: controller,
colorScheme: colorScheme,
sourceKeys: sourceKeys,
operation: operation,
validate: validator,
);
}
}
class _CalculatedWidget extends StatefulWidget {
final String label;
final String hint;
final TextEditingController controller;
final ColorScheme colorScheme;
final List<String> sourceKeys;
final String operation;
final String? Function(String?)? validate;
const _CalculatedWidget({
required this.label,
required this.hint,
required this.controller,
required this.colorScheme,
required this.sourceKeys,
required this.operation,
required this.validate,
});
@override
State<_CalculatedWidget> createState() => _CalculatedWidgetState();
}
class _CalculatedWidgetState extends State<_CalculatedWidget> {
List<TextEditingController> _sources = const [];
@override
void didChangeDependencies() {
super.didChangeDependencies();
final scope = EntityFormScope.of(context);
if (scope != null) {
_sources = widget.sourceKeys
.map((k) => scope.controllers[k])
.whereType<TextEditingController>()
.toList();
for (final c in _sources) {
c.addListener(_recompute);
}
_recompute();
}
}
@override
void dispose() {
for (final c in _sources) {
c.removeListener(_recompute);
}
super.dispose();
}
void _recompute() {
String result = '';
if (widget.operation == 'Concatination') {
result = _sources
.map((c) => c.text.trim())
.where((s) => s.isNotEmpty)
.join(' ');
} else {
final nums =
_sources.map((c) => double.tryParse(c.text.trim()) ?? 0.0).toList();
if (nums.isEmpty) {
result = '';
} else {
double acc = nums.first;
for (int i = 1; i < nums.length; i++) {
switch (widget.operation) {
case 'Addition':
acc += nums[i];
break;
case 'sub':
acc -= nums[i];
break;
case 'mul':
acc *= nums[i];
break;
case 'div':
if (nums[i] != 0) acc /= nums[i];
break;
}
}
result = acc.toString();
}
}
if (widget.controller.text != result) {
widget.controller.text = result;
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return ModernTextField(
label: widget.label,
hint: widget.hint,
controller: widget.controller,
readOnly: true,
validator: widget.validate,
suffixIcon: const Icon(Icons.functions),
);
}
}

View File

@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'base_field.dart';
/// Read-only Data Grid field
/// - Fetches tabular data from an async loader
/// - Renders a DataTable
/// - Excluded from form submission
class DataGridField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final Future<List<Map<String, dynamic>>> Function() dataLoader;
DataGridField({
required this.fieldKey,
required this.label,
this.hint = '',
this.isRequired = false,
required this.dataLoader,
});
@override
String? Function(String?)? get validator => null;
@override
Map<String, dynamic>? get customProperties => const {
'excludeFromSubmit': true,
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return _DataGridFieldWidget(
colorScheme: colorScheme,
dataLoader: dataLoader,
);
}
}
class _DataGridFieldWidget extends StatefulWidget {
final ColorScheme colorScheme;
final Future<List<Map<String, dynamic>>> Function() dataLoader;
const _DataGridFieldWidget({
required this.colorScheme,
required this.dataLoader,
});
@override
State<_DataGridFieldWidget> createState() => _DataGridFieldWidgetState();
}
class _DataGridFieldWidgetState extends State<_DataGridFieldWidget> {
late Future<List<Map<String, dynamic>>> _future;
@override
void initState() {
super.initState();
_future = widget.dataLoader();
}
@override
Widget build(BuildContext context) {
final colorScheme = widget.colorScheme;
return FutureBuilder<List<Map<String, dynamic>>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const LinearProgressIndicator(minHeight: 2);
}
if (snapshot.hasError) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.error.withOpacity(0.3)),
),
child: Row(
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
snapshot.error.toString(),
style: TextStyle(color: colorScheme.error),
),
),
],
),
);
}
final data = snapshot.data ?? const <Map<String, dynamic>>[];
if (data.isEmpty) {
return Text('No data available',
style: TextStyle(color: colorScheme.onSurfaceVariant));
}
final columns = data.first.keys.toList();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 24,
headingRowColor: MaterialStateColor.resolveWith(
(_) => colorScheme.primaryContainer.withOpacity(0.3)),
dataRowColor:
MaterialStateColor.resolveWith((_) => colorScheme.surface),
dividerThickness: 0.5,
columns: columns
.map((k) => DataColumn(
label: Text(k,
style: const TextStyle(fontWeight: FontWeight.w600)),
))
.toList(),
rows: data
.map(
(row) => DataRow(
cells: columns
.map((k) => DataCell(Text(row[k]?.toString() ?? '')))
.toList(),
),
)
.toList(),
),
);
},
);
}
}

View File

@ -1,50 +1,54 @@
// import 'package:flutter/material.dart';
// import 'base_field.dart';
// import '../../../Reuseable/reusable_dropdown_field.dart';
import 'package:flutter/material.dart';
import 'base_field.dart';
import '../../../Reuseable/reusable_dropdown_field.dart';
// /// Dropdown selection field implementation
// class DropdownField extends BaseField {
// final String fieldKey;
// final String label;
// final String hint;
// final bool isRequired;
// final List<Map<String, dynamic>> options;
// final String valueKey;
// final String displayKey;
// DropdownField({
// required this.fieldKey,
// required this.label,
// required this.hint,
// this.isRequired = false,
// required this.options,
// this.valueKey = 'id',
// this.displayKey = 'name',
// });
// @override
// String? Function(String?)? get validator => (value) {
// if (isRequired && (value == null || value.isEmpty)) {
// return '$label is required';
// }
// return null;
// };
// @override
// Widget buildField({
// required TextEditingController controller,
// required ColorScheme colorScheme,
// VoidCallback? onChanged,
// }) {
// return ReusableDropdownField(
// controller: controller,
// label: label,
// hint: hint,
// items: options,
// valueKey: valueKey,
// displayKey: displayKey,
// onChanged: onChanged != null ? (_) => onChanged() : null,
// validator: validator,
// );
// }
// }
/// Dropdown selection field implementation
class DropdownField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final List<Map<String, dynamic>> options;
final String valueKey; // id field in option map
final String displayKey; // display field in option map
DropdownField({
required this.fieldKey,
required this.label,
required this.hint,
this.isRequired = false,
required this.options,
this.valueKey = 'id',
this.displayKey = 'name',
});
@override
String? Function(String?)? get validator => (value) {
if (isRequired && (value == null || value.isEmpty)) {
return '$label is required';
}
return null;
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return ReusableDropdownField(
label: label,
options: options,
valueField: valueKey, // id
uiField: displayKey, // label
value: controller.text.isNotEmpty ? controller.text : null,
onChanged: (val) {
controller.text = val ?? '';
if (onChanged != null) onChanged();
},
onSaved: (val) {
controller.text = val ?? '';
},
);
}
}

View File

@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import 'base_field.dart';
import '../../../shared/widgets/inputs/modern_text_field.dart';
/// Dynamic single-select dropdown (no Autocomplete)
/// - Opens a modal list with search
/// - Stores selected id in controller, displays label
class DynamicDropdownField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final Future<List<Map<String, dynamic>>> Function() optionsLoader;
final String valueKey;
final String displayKey;
DynamicDropdownField({
required this.fieldKey,
required this.label,
this.hint = '',
this.isRequired = false,
required this.optionsLoader,
required this.valueKey,
required this.displayKey,
});
@override
String? Function(String?)? get validator => (value) {
if (isRequired && (value == null || value.isEmpty)) {
return '$label is required';
}
return null;
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return _DynamicDropdownWidget(
label: label,
hint: hint,
controller: controller,
colorScheme: colorScheme,
optionsLoader: optionsLoader,
valueKey: valueKey,
displayKey: displayKey,
validate: validator,
onChanged: onChanged,
);
}
}
class _DynamicDropdownWidget extends StatefulWidget {
final String label;
final String hint;
final TextEditingController controller;
final ColorScheme colorScheme;
final Future<List<Map<String, dynamic>>> Function() optionsLoader;
final String valueKey;
final String displayKey;
final String? Function(String?)? validate;
final VoidCallback? onChanged;
const _DynamicDropdownWidget({
required this.label,
required this.hint,
required this.controller,
required this.colorScheme,
required this.optionsLoader,
required this.valueKey,
required this.displayKey,
required this.validate,
required this.onChanged,
});
@override
State<_DynamicDropdownWidget> createState() => _DynamicDropdownWidgetState();
}
class _DynamicDropdownWidgetState extends State<_DynamicDropdownWidget> {
List<Map<String, dynamic>> _options = const [];
String _uiLabel = '';
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final opts = await widget.optionsLoader();
setState(() {
_options = opts;
_loading = false;
});
if (widget.controller.text.isNotEmpty) {
final match = _options.firstWhere(
(o) =>
(o[widget.valueKey]?.toString() ?? '') == widget.controller.text,
orElse: () => const {},
);
setState(() {
_uiLabel = match.isNotEmpty
? (match[widget.displayKey]?.toString() ?? '')
: '';
});
}
} catch (_) {
setState(() => _loading = false);
}
}
Future<void> _openPicker() async {
final ColorScheme cs = widget.colorScheme;
String query = '';
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: cs.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
List<Map<String, dynamic>> filtered = _options;
return StatefulBuilder(
builder: (context, setSheetState) {
void applyFilter(String q) {
query = q;
setSheetState(() {
filtered = _options
.where((o) => (o[widget.displayKey]?.toString() ?? '')
.toLowerCase()
.contains(q.toLowerCase()))
.toList();
});
}
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: InputDecoration(
hintText: 'Search ${widget.label.toLowerCase()}',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
onChanged: applyFilter,
),
const SizedBox(height: 12),
Flexible(
child: filtered.isEmpty
? Center(
child: Text('No results',
style: TextStyle(color: cs.onSurfaceVariant)),
)
: ListView.separated(
shrinkWrap: true,
itemCount: filtered.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: cs.outline.withOpacity(0.08)),
itemBuilder: (context, index) {
final o = filtered[index];
final id = o[widget.valueKey]?.toString() ?? '';
final name =
o[widget.displayKey]?.toString() ?? '';
final isSelected = widget.controller.text == id;
return ListTile(
title: Text(name),
trailing: isSelected
? Icon(Icons.check, color: cs.primary)
: null,
onTap: () {
setState(() {
widget.controller.text = id;
_uiLabel = name;
});
widget.onChanged?.call();
Navigator.of(context).pop();
},
);
},
),
),
const SizedBox(height: 8),
],
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const LinearProgressIndicator(minHeight: 2);
}
return ModernTextField(
label: widget.label,
hint: widget.hint,
readOnly: true,
controller: TextEditingController(text: _uiLabel),
validator: (v) => widget.validate?.call(widget.controller.text),
onTap: _openPicker,
suffixIcon: const Icon(Icons.arrow_drop_down),
);
}
}

View File

@ -0,0 +1,253 @@
import 'package:flutter/material.dart';
import 'base_field.dart';
import '../../../shared/widgets/inputs/modern_text_field.dart';
/// Dynamic multi-select dropdown (no Autocomplete)
/// - Modal list with search and checkboxes
/// - Stores comma-separated selected labels in controller
class DynamicMultiSelectDropdownField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final Future<List<String>> Function() optionsLoader;
DynamicMultiSelectDropdownField({
required this.fieldKey,
required this.label,
this.hint = '',
this.isRequired = false,
required this.optionsLoader,
});
@override
String? Function(String?)? get validator => (value) {
if (isRequired && (value == null || value.isEmpty)) {
return '$label is required';
}
return null;
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return _DynamicMultiSelectWidget(
label: label,
hint: hint,
controller: controller,
colorScheme: colorScheme,
optionsLoader: optionsLoader,
validate: validator,
onChanged: onChanged,
);
}
}
class _DynamicMultiSelectWidget extends StatefulWidget {
final String label;
final String hint;
final TextEditingController controller;
final ColorScheme colorScheme;
final Future<List<String>> Function() optionsLoader;
final String? Function(String?)? validate;
final VoidCallback? onChanged;
const _DynamicMultiSelectWidget({
required this.label,
required this.hint,
required this.controller,
required this.colorScheme,
required this.optionsLoader,
required this.validate,
required this.onChanged,
});
@override
State<_DynamicMultiSelectWidget> createState() =>
_DynamicMultiSelectWidgetState();
}
class _DynamicMultiSelectWidgetState extends State<_DynamicMultiSelectWidget> {
List<String> _options = const [];
late final Set<String> _selected;
bool _loading = true;
@override
void initState() {
super.initState();
_selected = widget.controller.text.isEmpty
? <String>{}
: widget.controller.text
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toSet();
_load();
}
Future<void> _load() async {
try {
final opts = await widget.optionsLoader();
setState(() {
_options = opts;
_loading = false;
});
} catch (_) {
setState(() => _loading = false);
}
}
Future<void> _openPicker() async {
final ColorScheme cs = widget.colorScheme;
String query = '';
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: cs.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
List<String> filtered = _options;
return StatefulBuilder(
builder: (context, setSheetState) {
void applyFilter(String q) {
query = q;
setSheetState(() {
filtered = _options
.where((o) => o.toLowerCase().contains(q.toLowerCase()))
.toList();
});
}
void toggle(String value) {
setSheetState(() {
if (_selected.contains(value)) {
_selected.remove(value);
} else {
_selected.add(value);
}
});
}
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
left: 16,
right: 16,
top: 12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
decoration: InputDecoration(
hintText: 'Search ${widget.label.toLowerCase()}',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
onChanged: applyFilter,
),
const SizedBox(height: 12),
Flexible(
child: filtered.isEmpty
? Center(
child: Text('No results',
style: TextStyle(color: cs.onSurfaceVariant)),
)
: ListView.separated(
shrinkWrap: true,
itemCount: filtered.length,
separatorBuilder: (_, __) => Divider(
height: 1,
color: cs.outline.withOpacity(0.08)),
itemBuilder: (context, index) {
final name = filtered[index];
final isSelected = _selected.contains(name);
return CheckboxListTile(
title: Text(name),
value: isSelected,
onChanged: (_) => toggle(name),
);
},
),
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () {
setState(() {
widget.controller.text = '';
});
_selected.clear();
widget.onChanged?.call();
Navigator.of(context).pop();
},
child: const Text('Clear'),
),
const Spacer(),
ElevatedButton(
onPressed: () {
setState(() {
widget.controller.text = _selected.join(',');
});
widget.onChanged?.call();
Navigator.of(context).pop();
},
child: const Text('Done'),
),
],
),
const SizedBox(height: 8),
],
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const LinearProgressIndicator(minHeight: 2);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ModernTextField(
label: widget.label,
hint: widget.hint,
readOnly: true,
controller: TextEditingController(text: _selected.join(', ')),
validator: widget.validate,
onTap: _openPicker,
suffixIcon: const Icon(Icons.arrow_drop_down),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: _selected
.map((v) => Chip(
label: Text(v),
onDeleted: () {
setState(() {
_selected.remove(v);
widget.controller.text = _selected.join(',');
});
widget.onChanged?.call();
},
))
.toList(),
),
],
);
}
}

View File

@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import '../ui/entity_screens.dart';
import '../utils/entity_field_store.dart';
typedef UploadHandler = Future<dynamic> Function(
String entityId, String entityName, String fileName, dynamic bytes);
typedef CreateAndReturnId = Future<int> Function(Map<String, dynamic> data);
/// Universal wrapper: Create entity first, then upload files collected by shared upload fields
class EntityCreateWithUploads extends StatelessWidget {
final String title;
final List fields; // List<BaseField>
final CreateAndReturnId createAndReturnId;
final Map<String, UploadHandler>
uploadHandlersByFieldKey; // fieldKey -> uploader
final String entityName; // e.g., 'Adv1'
final bool isLoading;
final String? errorMessage;
const EntityCreateWithUploads({
super.key,
required this.title,
required this.fields,
required this.createAndReturnId,
required this.uploadHandlersByFieldKey,
required this.entityName,
this.isLoading = false,
this.errorMessage,
});
Future<void> _uploadAll(int id) async {
final store = EntityFieldStore.instance;
for (final entry in uploadHandlersByFieldKey.entries) {
final String key = entry.key;
final handler = entry.value;
final items = store.get<List<UploadItem>>(key) ?? const [];
for (final item in items) {
await handler(id.toString(), entityName, item.fileName, item.bytes);
}
}
}
@override
Widget build(BuildContext context) {
return EntityCreateScreen(
title: title,
fields: fields.cast(),
isLoading: isLoading,
errorMessage: errorMessage,
onSubmit: (data) async {
final id = await createAndReturnId(data);
await _uploadAll(id);
EntityFieldStore.instance.clear();
},
);
}
}

View File

@ -0,0 +1,208 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'base_field.dart';
import '../ui/entity_form.dart';
/// One-to-many subform builder
/// - Renders repeatable group of sub-fields
/// - Stores JSON array (as string) in controller
class OneToManyField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final List<BaseField> subFields;
OneToManyField({
required this.fieldKey,
required this.label,
this.hint = '',
this.isRequired = false,
required this.subFields,
});
@override
String? Function(String?)? get validator => (value) {
if (isRequired && (value == null || value.isEmpty)) {
return '$label is required';
}
return null;
};
@override
Map<String, dynamic>? get customProperties => const {
// Keep submission optional/safe until backend expects JSON
'excludeFromSubmit': true,
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return _OneToManyWidget(
label: label,
controller: controller,
colorScheme: colorScheme,
subFields: subFields,
validate: validator,
onChanged: onChanged,
);
}
}
class _OneToManyWidget extends StatefulWidget {
final String label;
final TextEditingController controller;
final ColorScheme colorScheme;
final List<BaseField> subFields;
final String? Function(String?)? validate;
final VoidCallback? onChanged;
const _OneToManyWidget({
required this.label,
required this.controller,
required this.colorScheme,
required this.subFields,
required this.validate,
required this.onChanged,
});
@override
State<_OneToManyWidget> createState() => _OneToManyWidgetState();
}
class _OneToManyWidgetState extends State<_OneToManyWidget> {
final List<Map<String, TextEditingController>> _rows = [];
@override
void initState() {
super.initState();
if (widget.controller.text.isNotEmpty) {
// Attempt to parse initial JSON array
try {
final decoded = const JsonDecoder().convert(widget.controller.text);
if (decoded is List) {
for (final item in decoded) {
_addRow(
prefill: (item as Map).map(
(k, v) => MapEntry(k.toString(), v?.toString() ?? '')));
}
}
} catch (_) {}
}
if (_rows.isEmpty) _addRow();
}
void _addRow({Map<String, String>? prefill}) {
final row = <String, TextEditingController>{};
for (final f in widget.subFields) {
row[f.fieldKey] = TextEditingController(text: prefill?[f.fieldKey] ?? '');
}
setState(() => _rows.add(row));
_syncToParent();
}
void _removeRow(int index) {
if (index < 0 || index >= _rows.length) return;
final removed = _rows.removeAt(index);
for (final c in removed.values) {
c.dispose();
}
setState(() {});
_syncToParent();
}
void _syncToParent() {
final list = <Map<String, String>>[];
for (final row in _rows) {
final map = <String, String>{};
row.forEach((k, c) => map[k] = c.text.trim());
list.add(map);
}
widget.controller.text = const JsonEncoder().convert(list);
widget.onChanged?.call();
EntityFormScope.of(context)?.notifyParent();
}
@override
Widget build(BuildContext context) {
final cs = widget.colorScheme;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.primary.withOpacity(0.15)),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(widget.label,
style: TextStyle(
color: cs.onSurface, fontWeight: FontWeight.w700)),
const Spacer(),
IconButton(
icon: Icon(Icons.add_circle, color: cs.primary),
tooltip: 'Add',
onPressed: () => _addRow(),
),
],
),
const SizedBox(height: 8),
..._rows.asMap().entries.map((entry) {
final index = entry.key;
final ctrls = entry.value;
return Card(
elevation: 0,
color: cs.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: cs.outline.withOpacity(0.12)),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
...widget.subFields.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: f.buildField(
controller: ctrls[f.fieldKey]!,
colorScheme: cs,
onChanged: _syncToParent,
),
)),
Align(
alignment: Alignment.centerRight,
child: IconButton(
icon: Icon(Icons.delete_outline, color: cs.error),
tooltip: 'Remove',
onPressed: () => _removeRow(index),
),
),
],
),
),
);
}),
if (widget.validate != null)
Builder(
builder: (context) {
final error = widget.validate!(widget.controller.text);
return error != null
? Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(error,
style: TextStyle(color: cs.error, fontSize: 12)),
)
: const SizedBox.shrink();
},
)
],
),
),
);
}
}

View File

@ -0,0 +1,322 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'base_field.dart';
import 'captcha_field.dart' as FCaptcha;
import 'custom_text_field.dart' as FText;
import 'date_field.dart' as FDate;
import 'datetime_field.dart' as FDateTime;
import 'dropdown_field.dart' as FDropdown;
import 'email_field.dart' as FEmail;
import 'number_field.dart' as FNumber;
import 'password_field.dart' as FPassword;
import 'phone_field.dart' as FPhone;
import 'switch_field.dart' as FSwitch;
import 'url_field.dart' as FUrl;
class OneToOneRelationField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final Map<String, dynamic> relationSchema;
OneToOneRelationField({
required this.fieldKey,
required this.label,
required this.hint,
required this.relationSchema,
this.isRequired = false,
});
@override
String? Function(String?)? get validator => (value) {
// If not required, allow empty
if (!isRequired) return null;
// Required: ensure at least one inner field has a non-empty value
if (value == null || value.isEmpty) {
return '$label is required';
}
try {
final decoded = json.decode(value);
if (decoded is! Map<String, dynamic>) {
return '$label is required';
}
final List fields = (relationSchema['fields'] as List?) ?? const [];
for (final f in fields) {
final String? path = f['path']?.toString();
if (path == null) continue;
final dynamic v = decoded[path];
if (v != null && v.toString().trim().isNotEmpty) {
return null; // at least one provided
}
}
return '$label is required';
} catch (_) {
// If invalid JSON and required, treat as empty
return '$label is required';
}
};
@override
Map<String, dynamic>? get customProperties => {
'isRelation': true,
'assignByJsonPaths': true,
'paths': (relationSchema['fields'] as List?)
?.map((f) => f['path'])
.where((p) => p != null)
.toList() ??
[],
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
final List fields = (relationSchema['fields'] as List?) ?? const [];
final bool boxed = relationSchema['box'] == true;
final String title = relationSchema['title']?.toString() ?? label;
Widget content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
fontSize: 16,
),
),
),
...fields.map<Widget>((f) {
final String type = f['type']?.toString() ?? 'text';
final String flabel = f['label']?.toString() ?? '';
final String fhint = f['hint']?.toString() ?? '';
final String path = f['path']?.toString() ?? '';
final bool requiredField = f['required'] == true;
final List<Map<String, dynamic>>? options =
(f['options'] as List?)?.cast<Map<String, dynamic>>();
final String valueKey = f['valueKey']?.toString() ?? 'id';
final String displayKey = f['displayKey']?.toString() ?? 'name';
final int? maxLength =
f['maxLength'] is int ? f['maxLength'] as int : null;
final int? decimalPlaces =
f['decimalPlaces'] is int ? f['decimalPlaces'] as int : null;
final double? min =
(f['min'] is num) ? (f['min'] as num).toDouble() : null;
final double? max =
(f['max'] is num) ? (f['max'] as num).toDouble() : null;
// Initialize sub field with existing value from parent controller JSON.
// Expect flat map { 'support.field': value }. Fallback to nested traversal.
String initialValue = '';
if (controller.text.isNotEmpty) {
try {
final decoded = json.decode(controller.text);
if (decoded is Map<String, dynamic>) {
if (decoded.containsKey(path)) {
initialValue = decoded[path]?.toString() ?? '';
} else {
final parts = path.split('.');
dynamic curr = decoded;
for (final p in parts) {
if (curr is Map && curr.containsKey(p)) {
curr = curr[p];
} else {
curr = null;
break;
}
}
if (curr != null) initialValue = curr.toString();
}
}
} catch (_) {}
}
void sync(String val) {
Map<String, dynamic> current = {};
if (controller.text.isNotEmpty) {
try {
final decoded = json.decode(controller.text);
if (decoded is Map<String, dynamic>) current = decoded;
} catch (_) {}
}
// Store flat path mapping
if (val.isEmpty) {
// remove the key when emptied
current.remove(path);
} else {
current[path] = val;
}
// If map becomes empty, clear controller to avoid failing validation when optional
controller.text = current.isEmpty ? '' : json.encode(current);
onChanged?.call();
}
final TextEditingController subController =
TextEditingController(text: initialValue);
Widget buildShared() {
switch (type) {
case 'number':
return FNumber.NumberField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
min: min,
max: max,
decimalPlaces: decimalPlaces,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'email':
return FEmail.EmailField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'phone':
return FPhone.PhoneField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'password':
return FPassword.PasswordField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'dropdown':
return FDropdown.DropdownField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
options: options ?? const [],
valueKey: valueKey,
displayKey: displayKey,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'date':
return FDate.DateField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'datetime':
return FDateTime.DateTimeField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'switch':
return FSwitch.SwitchField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'captcha':
return FCaptcha.CaptchaField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'url':
return FUrl.UrlField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
case 'text':
default:
return FText.CustomTextField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
maxLength: maxLength,
).buildField(
controller: subController,
colorScheme: colorScheme,
onChanged: () => sync(subController.text),
);
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: buildShared(),
);
}).toList(),
],
);
final Widget body = boxed
? Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: colorScheme.outline.withOpacity(0.4)),
),
child: content,
)
: content;
return Directionality(textDirection: TextDirection.ltr, child: body);
}
}

View File

@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'base_field.dart';
/// Static Multi-Select field
/// - Accepts a static list of display strings
/// - Stores selected values as a comma-separated string in controller
class StaticMultiSelectField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final List<String> options;
StaticMultiSelectField({
required this.fieldKey,
required this.label,
this.hint = '',
this.isRequired = false,
required this.options,
});
@override
String? Function(String?)? get validator => (value) {
if (isRequired && (value == null || value.isEmpty)) {
return '$label is required';
}
return null;
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
final Set<String> selected = controller.text.isEmpty
? <String>{}
: controller.text
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toSet();
void toggle(String value) {
if (selected.contains(value)) {
selected.remove(value);
} else {
selected.add(value);
}
controller.text = selected.join(',');
onChanged?.call();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(
color: colorScheme.onSurface, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: options.map((opt) {
final bool isSel = selected.contains(opt);
return FilterChip(
label: Text(opt),
selected: isSel,
onSelected: (_) => toggle(opt),
);
}).toList(),
),
if (validator != null)
Builder(
builder: (context) {
final error = validator!(controller.text);
return error != null
? Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(error,
style: TextStyle(
color: colorScheme.error, fontSize: 12)),
)
: const SizedBox.shrink();
},
)
],
);
}
}

View File

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'base_field.dart';
import '../ui/entity_form.dart';
/// Value List Picker
/// - Shows an icon button
/// - Loads list from API and displays as modal cards
/// - On selecting an item, fills multiple form fields based on mapping
class ValueListPickerField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final Future<List<Map<String, dynamic>>> Function() optionsLoader;
final Map<String, String> fillMappings; // sourceKey -> targetFieldKey in form
final IconData icon;
ValueListPickerField({
required this.fieldKey,
required this.label,
this.hint = '',
this.isRequired = false,
required this.optionsLoader,
required this.fillMappings,
this.icon = Icons.playlist_add_check,
});
@override
String? Function(String?)? get validator => null;
@override
Map<String, dynamic>? get customProperties => const {
'excludeFromSubmit': true, // helper-only, no direct submission
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return _ValueListPickerWidget(
label: label,
colorScheme: colorScheme,
optionsLoader: optionsLoader,
fillMappings: fillMappings,
icon: icon,
);
}
}
class _ValueListPickerWidget extends StatefulWidget {
final String label;
final ColorScheme colorScheme;
final Future<List<Map<String, dynamic>>> Function() optionsLoader;
final Map<String, String> fillMappings;
final IconData icon;
const _ValueListPickerWidget({
required this.label,
required this.colorScheme,
required this.optionsLoader,
required this.fillMappings,
required this.icon,
});
@override
State<_ValueListPickerWidget> createState() => _ValueListPickerWidgetState();
}
class _ValueListPickerWidgetState extends State<_ValueListPickerWidget> {
bool _loading = false;
Future<void> _open() async {
setState(() => _loading = true);
List<Map<String, dynamic>> options = const [];
try {
options = await widget.optionsLoader();
} finally {
setState(() => _loading = false);
}
final cs = widget.colorScheme;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: cs.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: options.isEmpty
? Center(
child: Text('No data',
style: TextStyle(color: cs.onSurfaceVariant)))
: ListView.separated(
itemCount: options.length,
separatorBuilder: (_, __) =>
Divider(height: 1, color: cs.outline.withOpacity(0.08)),
itemBuilder: (context, index) {
final o = options[index];
return ListTile(
leading: CircleAvatar(
child: Text(
((o['name'] ?? o['title'] ?? 'N') as String)
.substring(0, 1)
.toUpperCase())),
title: Text((o['name'] ?? o['title'] ?? '').toString()),
subtitle: Text(
(o['description'] ?? o['email'] ?? o['phone'] ?? '')
.toString()),
onTap: () {
final scope = EntityFormScope.of(context);
if (scope != null) {
widget.fillMappings
.forEach((sourceKey, targetFieldKey) {
final v = o[sourceKey];
final c = scope.controllers[targetFieldKey];
if (c != null) c.text = v?.toString() ?? '';
});
scope.notifyParent();
}
Navigator.of(context).pop();
},
);
},
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final cs = widget.colorScheme;
return Row(
children: [
Expanded(
child: Text(widget.label,
style:
TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600)),
),
IconButton(
tooltip: 'Open ${widget.label}',
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))
: Icon(widget.icon, color: cs.primary),
onPressed: _loading ? null : _open,
)
],
);
}
}

View File

@ -1,6 +1,394 @@
import 'package:base_project/core/providers/dynamic_theme_provider.dart';
// import 'package:base_project/core/providers/dynamic_theme_provider.dart';
// import 'package:flutter/material.dart';
// import 'package:provider/provider.dart';
// import '../fields/base_field.dart';
// import '../../../shared/widgets/buttons/modern_button.dart';
// import '../../../core/constants/ui_constants.dart';
// /// Reusable form component that dynamically renders fields based on field definitions
// /// This allows UI to be independent of field types and enables reusability
// class EntityForm extends StatefulWidget {
// final List<BaseField> fields;
// final Map<String, dynamic>? initialData;
// final Function(Map<String, dynamic>) onSubmit;
// final String submitButtonText;
// final bool isLoading;
// const EntityForm({
// super.key,
// required this.fields,
// this.initialData,
// required this.onSubmit,
// this.submitButtonText = 'Submit',
// this.isLoading = false,
// });
// @override
// State<EntityForm> createState() => _EntityFormState();
// }
// class _EntityFormState extends State<EntityForm> {
// final _formKey = GlobalKey<FormState>();
// final Map<String, TextEditingController> _controllers = {};
// final Map<String, BaseField> _fieldByKey = {};
// @override
// void initState() {
// super.initState();
// _initializeControllers();
// }
// void _initializeControllers() {
// for (final field in widget.fields) {
// _controllers[field.fieldKey] = TextEditingController(
// text: widget.initialData?[field.fieldKey]?.toString() ?? '',
// );
// _fieldByKey[field.fieldKey] = field;
// }
// }
// @override
// void dispose() {
// for (final controller in _controllers.values) {
// controller.dispose();
// }
// super.dispose();
// }
// @override
// Widget build(BuildContext context) {
// return Consumer<DynamicThemeProvider>(
// builder: (context, dynamicThemeProvider, child) {
// final colorScheme = dynamicThemeProvider.getCurrentColorScheme(
// Theme.of(context).brightness == Brightness.dark,
// );
// return Form(
// key: _formKey,
// child: Column(
// children: [
// // Dynamic field rendering
// ...widget.fields.map((field) => Padding(
// padding:
// const EdgeInsets.only(bottom: UIConstants.spacing16),
// child: field.buildField(
// controller: _controllers[field.fieldKey]!,
// colorScheme: colorScheme,
// onChanged: () => setState(() {}),
// ),
// )),
// const SizedBox(height: UIConstants.spacing24),
// // Submit button
// ModernButton(
// text: widget.submitButtonText,
// type: ModernButtonType.primary,
// size: ModernButtonSize.large,
// isLoading: widget.isLoading,
// onPressed: widget.isLoading ? null : _handleSubmit,
// ),
// ],
// ),
// );
// },
// );
// }
// void _handleSubmit() {
// if (_formKey.currentState!.validate()) {
// // Dynamic cross-field match for any password-confirm group
// final Map<String, String> passwordByGroup = {};
// final Map<String, String> confirmByGroup = {};
// for (final entry in _controllers.entries) {
// final key = entry.key;
// final field = _fieldByKey[key];
// final props = field?.customProperties ?? const {};
// final isPassword = props['isPassword'] == true;
// if (!isPassword) continue;
// final String? groupId = props['groupId'];
// if (groupId == null) continue;
// final bool isConfirm = props['isConfirm'] == true;
// if (isConfirm) {
// confirmByGroup[groupId] = entry.value.text;
// } else {
// passwordByGroup[groupId] = entry.value.text;
// }
// }
// for (final gid in confirmByGroup.keys) {
// final confirm = confirmByGroup[gid] ?? '';
// final pass = passwordByGroup[gid] ?? '';
// if (confirm.isNotEmpty && confirm != pass) {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(content: Text('Passwords do not match')),
// );
// return;
// }
// }
// final formData = <String, dynamic>{};
// for (final entry in _controllers.entries) {
// final key = entry.key;
// final field = _fieldByKey[key];
// final value = entry.value.text.trim();
// // Skip confirm entries for any password group
// final props = field?.customProperties ?? const {};
// final bool isPassword = props['isPassword'] == true;
// final bool isConfirm = props['isConfirm'] == true;
// if (isPassword && isConfirm) continue;
// formData[key] = value;
// }
// widget.onSubmit(formData);
// }
// }
// }
// SECOND WORKING CODE
// import 'dart:convert';
// import 'package:flutter/material.dart';
// import 'package:provider/provider.dart';
// import '../../../core/constants/ui_constants.dart';
// import '../../../core/providers/dynamic_theme_provider.dart';
// import '../../../shared/widgets/buttons/modern_button.dart';
// import '../fields/base_field.dart';
// /// Reusable form component that dynamically renders fields based on field definitions
// /// This allows UI to be independent of field types and enables reusability
// class EntityForm extends StatefulWidget {
// final List<BaseField> fields;
// final Map<String, dynamic>? initialData;
// final Function(Map<String, dynamic>) onSubmit;
// final String submitButtonText;
// final bool isLoading;
// const EntityForm({
// super.key,
// required this.fields,
// this.initialData,
// required this.onSubmit,
// this.submitButtonText = 'Submit',
// this.isLoading = false,
// });
// @override
// State<EntityForm> createState() => _EntityFormState();
// }
// class _EntityFormState extends State<EntityForm> {
// final _formKey = GlobalKey<FormState>();
// final Map<String, TextEditingController> _controllers = {};
// final Map<String, BaseField> _fieldByKey = {};
// late final Map<String, dynamic> _initialData;
// @override
// void initState() {
// super.initState();
// _initializeControllers();
// }
// void _initializeControllers() {
// _initialData = widget.initialData ?? const {};
// for (final field in widget.fields) {
// final props = field.customProperties ?? const {};
// final bool assignByJsonPaths = props['assignByJsonPaths'] == true;
// final List<dynamic>? paths = props['paths'] as List<dynamic>?;
// String initialText =
// widget.initialData?[field.fieldKey]?.toString() ?? '';
// if (assignByJsonPaths && paths != null) {
// final Map<String, dynamic> values = {};
// for (final p in paths) {
// if (p is String) {
// final v = _readValueByPath(_initialData, p);
// if (v != null) values[p] = v;
// }
// }
// if (values.isNotEmpty) {
// initialText = _encodeMap(values);
// }
// }
// _controllers[field.fieldKey] = TextEditingController(text: initialText);
// _fieldByKey[field.fieldKey] = field;
// }
// }
// @override
// void dispose() {
// for (final controller in _controllers.values) {
// controller.dispose();
// }
// super.dispose();
// }
// @override
// Widget build(BuildContext context) {
// return Consumer<DynamicThemeProvider>(
// builder: (context, dynamicThemeProvider, child) {
// final colorScheme = dynamicThemeProvider.getCurrentColorScheme(
// Theme.of(context).brightness == Brightness.dark,
// );
// return Form(
// key: _formKey,
// child: Column(
// children: [
// // Dynamic field rendering
// ...widget.fields.map((field) => Padding(
// padding:
// const EdgeInsets.only(bottom: UIConstants.spacing16),
// child: field.buildField(
// controller: _controllers[field.fieldKey]!,
// colorScheme: colorScheme,
// onChanged: () => setState(() {}),
// ),
// )),
// const SizedBox(height: UIConstants.spacing24),
// // Submit button
// ModernButton(
// text: widget.submitButtonText,
// type: ModernButtonType.primary,
// size: ModernButtonSize.large,
// isLoading: widget.isLoading,
// onPressed: widget.isLoading ? null : _handleSubmit,
// ),
// ],
// ),
// );
// },
// );
// }
// void _handleSubmit() {
// if (_formKey.currentState!.validate()) {
// // Dynamic cross-field match for any password-confirm group
// final Map<String, String> passwordByGroup = {};
// final Map<String, String> confirmByGroup = {};
// for (final entry in _controllers.entries) {
// final key = entry.key;
// final field = _fieldByKey[key];
// final props = field?.customProperties ?? const {};
// final isPassword = props['isPassword'] == true;
// if (!isPassword) continue;
// final String? groupId = props['groupId'];
// if (groupId == null) continue;
// final bool isConfirm = props['isConfirm'] == true;
// if (isConfirm) {
// confirmByGroup[groupId] = entry.value.text;
// } else {
// passwordByGroup[groupId] = entry.value.text;
// }
// }
// for (final gid in confirmByGroup.keys) {
// final confirm = confirmByGroup[gid] ?? '';
// final pass = passwordByGroup[gid] ?? '';
// if (confirm.isNotEmpty && confirm != pass) {
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(content: Text('Passwords do not match')),
// );
// return;
// }
// }
// final formData = <String, dynamic>{};
// for (final entry in _controllers.entries) {
// final key = entry.key;
// final field = _fieldByKey[key];
// final value = entry.value.text.trim();
// // Skip fields that are marked as non-submittable (e.g., DataGrid)
// final props = field?.customProperties ?? const {};
// final bool excludeFromSubmit = props['excludeFromSubmit'] == true;
// if (excludeFromSubmit) {
// continue;
// }
// // Skip confirm entries for any password group
// final bool isPassword = props['isPassword'] == true;
// final bool isConfirm = props['isConfirm'] == true;
// if (isPassword && isConfirm) continue;
// final bool assignByJsonPaths = props['assignByJsonPaths'] == true;
// if (assignByJsonPaths) {
// // If composite and empty -> skip adding base key
// if (value.isEmpty) {
// continue;
// }
// final Map<String, dynamic>? map = _tryDecodeMap(value);
// if (map != null) {
// map.forEach((path, v) {
// if (path is String) {
// _assignValueByPath(formData, path, v);
// }
// });
// continue;
// }
// // If not decodable, also skip to avoid sending invalid base key
// continue;
// }
// formData[key] = value;
// }
// widget.onSubmit(formData);
// }
// }
// dynamic _readValueByPath(Map<String, dynamic>? source, String path) {
// if (source == null) return null;
// final segments = path.split('.');
// dynamic current = source;
// for (final segment in segments) {
// if (current is Map<String, dynamic> && current.containsKey(segment)) {
// current = current[segment];
// } else {
// return null;
// }
// }
// return current;
// }
// void _assignValueByPath(
// Map<String, dynamic> target, String path, dynamic value) {
// final segments = path.split('.');
// Map<String, dynamic> current = target;
// for (int i = 0; i < segments.length; i++) {
// final seg = segments[i];
// final bool isLast = i == segments.length - 1;
// if (isLast) {
// current[seg] = value;
// } else {
// if (current[seg] is! Map<String, dynamic>) {
// current[seg] = <String, dynamic>{};
// }
// current = current[seg] as Map<String, dynamic>;
// }
// }
// }
// String _encodeMap(Map<String, dynamic> map) {
// return const JsonEncoder().convert(map);
// }
// Map<String, dynamic>? _tryDecodeMap(String value) {
// try {
// final decoded = const JsonDecoder().convert(value);
// if (decoded is Map<String, dynamic>) return decoded;
// return null;
// } catch (_) {
// return null;
// }
// }
// }
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/providers/dynamic_theme_provider.dart';
import '../fields/base_field.dart';
import '../../../shared/widgets/buttons/modern_button.dart';
import '../../../core/constants/ui_constants.dart';
@ -31,6 +419,7 @@ class _EntityFormState extends State<EntityForm> {
final _formKey = GlobalKey<FormState>();
final Map<String, TextEditingController> _controllers = {};
final Map<String, BaseField> _fieldByKey = {};
late final Map<String, dynamic> _initialData;
@override
void initState() {
@ -39,10 +428,26 @@ class _EntityFormState extends State<EntityForm> {
}
void _initializeControllers() {
_initialData = widget.initialData ?? const {};
for (final field in widget.fields) {
_controllers[field.fieldKey] = TextEditingController(
text: widget.initialData?[field.fieldKey]?.toString() ?? '',
);
final props = field.customProperties ?? const {};
final bool assignByJsonPaths = props['assignByJsonPaths'] == true;
final List<dynamic>? paths = props['paths'] as List<dynamic>?;
String initialText =
widget.initialData?[field.fieldKey]?.toString() ?? '';
if (assignByJsonPaths && paths != null) {
final Map<String, dynamic> values = {};
for (final p in paths) {
if (p is String) {
final v = _readValueByPath(_initialData, p);
if (v != null) values[p] = v;
}
}
if (values.isNotEmpty) {
initialText = _encodeMap(values);
}
}
_controllers[field.fieldKey] = TextEditingController(text: initialText);
_fieldByKey[field.fieldKey] = field;
}
}
@ -63,32 +468,36 @@ class _EntityFormState extends State<EntityForm> {
Theme.of(context).brightness == Brightness.dark,
);
return Form(
key: _formKey,
child: Column(
children: [
// Dynamic field rendering
...widget.fields.map((field) => Padding(
padding:
const EdgeInsets.only(bottom: UIConstants.spacing16),
child: field.buildField(
controller: _controllers[field.fieldKey]!,
colorScheme: colorScheme,
onChanged: () => setState(() {}),
),
)),
return EntityFormScope(
controllers: _controllers,
notifyParent: () => setState(() {}),
child: Form(
key: _formKey,
child: Column(
children: [
// Dynamic field rendering
...widget.fields.map((field) => Padding(
padding:
const EdgeInsets.only(bottom: UIConstants.spacing16),
child: field.buildField(
controller: _controllers[field.fieldKey]!,
colorScheme: colorScheme,
onChanged: () => setState(() {}),
),
)),
const SizedBox(height: UIConstants.spacing24),
const SizedBox(height: UIConstants.spacing24),
// Submit button
ModernButton(
text: widget.submitButtonText,
type: ModernButtonType.primary,
size: ModernButtonSize.large,
isLoading: widget.isLoading,
onPressed: widget.isLoading ? null : _handleSubmit,
),
],
// Submit button
ModernButton(
text: widget.submitButtonText,
type: ModernButtonType.primary,
size: ModernButtonSize.large,
isLoading: widget.isLoading,
onPressed: widget.isLoading ? null : _handleSubmit,
),
],
),
),
);
},
@ -132,15 +541,108 @@ class _EntityFormState extends State<EntityForm> {
final field = _fieldByKey[key];
final value = entry.value.text.trim();
// Skip confirm entries for any password group
// Skip fields that are marked as non-submittable (e.g., DataGrid)
final props = field?.customProperties ?? const {};
final bool excludeFromSubmit = props['excludeFromSubmit'] == true;
if (excludeFromSubmit) {
continue;
}
// Skip confirm entries for any password group
final bool isPassword = props['isPassword'] == true;
final bool isConfirm = props['isConfirm'] == true;
if (isPassword && isConfirm) continue;
final bool assignByJsonPaths = props['assignByJsonPaths'] == true;
if (assignByJsonPaths) {
// If composite and empty -> skip adding base key
if (value.isEmpty) {
continue;
}
final Map<String, dynamic>? map = _tryDecodeMap(value);
if (map != null) {
map.forEach((path, v) {
if (path is String) {
_assignValueByPath(formData, path, v);
}
});
continue;
}
// If not decodable, also skip to avoid sending invalid base key
continue;
}
formData[key] = value;
}
widget.onSubmit(formData);
}
}
dynamic _readValueByPath(Map<String, dynamic>? source, String path) {
if (source == null) return null;
final segments = path.split('.');
dynamic current = source;
for (final segment in segments) {
if (current is Map<String, dynamic> && current.containsKey(segment)) {
current = current[segment];
} else {
return null;
}
}
return current;
}
void _assignValueByPath(
Map<String, dynamic> target, String path, dynamic value) {
final segments = path.split('.');
Map<String, dynamic> current = target;
for (int i = 0; i < segments.length; i++) {
final seg = segments[i];
final bool isLast = i == segments.length - 1;
if (isLast) {
current[seg] = value;
} else {
if (current[seg] is! Map<String, dynamic>) {
current[seg] = <String, dynamic>{};
}
current = current[seg] as Map<String, dynamic>;
}
}
}
String _encodeMap(Map<String, dynamic> map) {
return const JsonEncoder().convert(map);
}
Map<String, dynamic>? _tryDecodeMap(String value) {
try {
final decoded = const JsonDecoder().convert(value);
if (decoded is Map<String, dynamic>) return decoded;
return null;
} catch (_) {
return null;
}
}
}
/// Inherited scope to provide access to the form's controllers for advanced shared fields
class EntityFormScope extends InheritedWidget {
final Map<String, TextEditingController> controllers;
final VoidCallback notifyParent;
const EntityFormScope({
super.key,
required this.controllers,
required this.notifyParent,
required Widget child,
}) : super(child: child);
static EntityFormScope? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<EntityFormScope>();
}
@override
bool updateShouldNotify(covariant EntityFormScope oldWidget) {
return oldWidget.controllers != controllers;
}
}

View File

@ -1,5 +1,4 @@
class ApiConstants {
static const baseUrl = 'http://localhost:9292';
// USER AUTH API'S //
static const loginEndpoint = "$baseUrl/token/session";
static const getOtpEndpoint = "$baseUrl/token/user/send_email";
@ -23,4 +22,6 @@ class ApiConstants {
static const uploadSystemParamImg = '$baseUrl/api/logos/upload?ref=test';
static const getSystemParameters = '$baseUrl/sysparam/getSysParams';
static const updateSystemParams = '$baseUrl/sysparam/updateSysParams';
static const baseUrl = 'http://localhost:9292';
}