dattype
This commit is contained in:
parent
13eca99151
commit
bc02a06d56
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
132
base_project/lib/BuilderField/shared/fields/data_grid_field.dart
Normal file
132
base_project/lib/BuilderField/shared/fields/data_grid_field.dart
Normal 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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,50 +1,54 @@
|
|||||||
// import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
// import 'base_field.dart';
|
import 'base_field.dart';
|
||||||
// import '../../../Reuseable/reusable_dropdown_field.dart';
|
import '../../../Reuseable/reusable_dropdown_field.dart';
|
||||||
|
|
||||||
// /// Dropdown selection field implementation
|
/// Dropdown selection field implementation
|
||||||
// class DropdownField extends BaseField {
|
class DropdownField extends BaseField {
|
||||||
// final String fieldKey;
|
final String fieldKey;
|
||||||
// final String label;
|
final String label;
|
||||||
// final String hint;
|
final String hint;
|
||||||
// final bool isRequired;
|
final bool isRequired;
|
||||||
// final List<Map<String, dynamic>> options;
|
final List<Map<String, dynamic>> options;
|
||||||
// final String valueKey;
|
final String valueKey; // id field in option map
|
||||||
// final String displayKey;
|
final String displayKey; // display field in option map
|
||||||
|
|
||||||
// DropdownField({
|
DropdownField({
|
||||||
// required this.fieldKey,
|
required this.fieldKey,
|
||||||
// required this.label,
|
required this.label,
|
||||||
// required this.hint,
|
required this.hint,
|
||||||
// this.isRequired = false,
|
this.isRequired = false,
|
||||||
// required this.options,
|
required this.options,
|
||||||
// this.valueKey = 'id',
|
this.valueKey = 'id',
|
||||||
// this.displayKey = 'name',
|
this.displayKey = 'name',
|
||||||
// });
|
});
|
||||||
|
|
||||||
// @override
|
@override
|
||||||
// String? Function(String?)? get validator => (value) {
|
String? Function(String?)? get validator => (value) {
|
||||||
// if (isRequired && (value == null || value.isEmpty)) {
|
if (isRequired && (value == null || value.isEmpty)) {
|
||||||
// return '$label is required';
|
return '$label is required';
|
||||||
// }
|
}
|
||||||
// return null;
|
return null;
|
||||||
// };
|
};
|
||||||
|
|
||||||
// @override
|
@override
|
||||||
// Widget buildField({
|
Widget buildField({
|
||||||
// required TextEditingController controller,
|
required TextEditingController controller,
|
||||||
// required ColorScheme colorScheme,
|
required ColorScheme colorScheme,
|
||||||
// VoidCallback? onChanged,
|
VoidCallback? onChanged,
|
||||||
// }) {
|
}) {
|
||||||
// return ReusableDropdownField(
|
return ReusableDropdownField(
|
||||||
// controller: controller,
|
label: label,
|
||||||
// label: label,
|
options: options,
|
||||||
// hint: hint,
|
valueField: valueKey, // id
|
||||||
// items: options,
|
uiField: displayKey, // label
|
||||||
// valueKey: valueKey,
|
value: controller.text.isNotEmpty ? controller.text : null,
|
||||||
// displayKey: displayKey,
|
onChanged: (val) {
|
||||||
// onChanged: onChanged != null ? (_) => onChanged() : null,
|
controller.text = val ?? '';
|
||||||
// validator: validator,
|
if (onChanged != null) onChanged();
|
||||||
// );
|
},
|
||||||
// }
|
onSaved: (val) {
|
||||||
// }
|
controller.text = val ?? '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../../core/providers/dynamic_theme_provider.dart';
|
||||||
import '../fields/base_field.dart';
|
import '../fields/base_field.dart';
|
||||||
import '../../../shared/widgets/buttons/modern_button.dart';
|
import '../../../shared/widgets/buttons/modern_button.dart';
|
||||||
import '../../../core/constants/ui_constants.dart';
|
import '../../../core/constants/ui_constants.dart';
|
||||||
@ -31,6 +419,7 @@ class _EntityFormState extends State<EntityForm> {
|
|||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final Map<String, TextEditingController> _controllers = {};
|
final Map<String, TextEditingController> _controllers = {};
|
||||||
final Map<String, BaseField> _fieldByKey = {};
|
final Map<String, BaseField> _fieldByKey = {};
|
||||||
|
late final Map<String, dynamic> _initialData;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -39,10 +428,26 @@ class _EntityFormState extends State<EntityForm> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _initializeControllers() {
|
void _initializeControllers() {
|
||||||
|
_initialData = widget.initialData ?? const {};
|
||||||
for (final field in widget.fields) {
|
for (final field in widget.fields) {
|
||||||
_controllers[field.fieldKey] = TextEditingController(
|
final props = field.customProperties ?? const {};
|
||||||
text: widget.initialData?[field.fieldKey]?.toString() ?? '',
|
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;
|
_fieldByKey[field.fieldKey] = field;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -63,32 +468,36 @@ class _EntityFormState extends State<EntityForm> {
|
|||||||
Theme.of(context).brightness == Brightness.dark,
|
Theme.of(context).brightness == Brightness.dark,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Form(
|
return EntityFormScope(
|
||||||
key: _formKey,
|
controllers: _controllers,
|
||||||
child: Column(
|
notifyParent: () => setState(() {}),
|
||||||
children: [
|
child: Form(
|
||||||
// Dynamic field rendering
|
key: _formKey,
|
||||||
...widget.fields.map((field) => Padding(
|
child: Column(
|
||||||
padding:
|
children: [
|
||||||
const EdgeInsets.only(bottom: UIConstants.spacing16),
|
// Dynamic field rendering
|
||||||
child: field.buildField(
|
...widget.fields.map((field) => Padding(
|
||||||
controller: _controllers[field.fieldKey]!,
|
padding:
|
||||||
colorScheme: colorScheme,
|
const EdgeInsets.only(bottom: UIConstants.spacing16),
|
||||||
onChanged: () => setState(() {}),
|
child: field.buildField(
|
||||||
),
|
controller: _controllers[field.fieldKey]!,
|
||||||
)),
|
colorScheme: colorScheme,
|
||||||
|
onChanged: () => setState(() {}),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
|
||||||
const SizedBox(height: UIConstants.spacing24),
|
const SizedBox(height: UIConstants.spacing24),
|
||||||
|
|
||||||
// Submit button
|
// Submit button
|
||||||
ModernButton(
|
ModernButton(
|
||||||
text: widget.submitButtonText,
|
text: widget.submitButtonText,
|
||||||
type: ModernButtonType.primary,
|
type: ModernButtonType.primary,
|
||||||
size: ModernButtonSize.large,
|
size: ModernButtonSize.large,
|
||||||
isLoading: widget.isLoading,
|
isLoading: widget.isLoading,
|
||||||
onPressed: widget.isLoading ? null : _handleSubmit,
|
onPressed: widget.isLoading ? null : _handleSubmit,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -132,15 +541,108 @@ class _EntityFormState extends State<EntityForm> {
|
|||||||
final field = _fieldByKey[key];
|
final field = _fieldByKey[key];
|
||||||
final value = entry.value.text.trim();
|
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 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 isPassword = props['isPassword'] == true;
|
||||||
final bool isConfirm = props['isConfirm'] == true;
|
final bool isConfirm = props['isConfirm'] == true;
|
||||||
if (isPassword && isConfirm) continue;
|
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;
|
formData[key] = value;
|
||||||
}
|
}
|
||||||
widget.onSubmit(formData);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
class ApiConstants {
|
class ApiConstants {
|
||||||
static const baseUrl = 'http://localhost:9292';
|
|
||||||
// USER AUTH API'S //
|
// USER AUTH API'S //
|
||||||
static const loginEndpoint = "$baseUrl/token/session";
|
static const loginEndpoint = "$baseUrl/token/session";
|
||||||
static const getOtpEndpoint = "$baseUrl/token/user/send_email";
|
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 uploadSystemParamImg = '$baseUrl/api/logos/upload?ref=test';
|
||||||
static const getSystemParameters = '$baseUrl/sysparam/getSysParams';
|
static const getSystemParameters = '$baseUrl/sysparam/getSysParams';
|
||||||
static const updateSystemParams = '$baseUrl/sysparam/updateSysParams';
|
static const updateSystemParams = '$baseUrl/sysparam/updateSysParams';
|
||||||
|
|
||||||
|
static const baseUrl = 'http://localhost:9292';
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user