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 'base_field.dart';
|
||||
// import '../../../Reuseable/reusable_dropdown_field.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'base_field.dart';
|
||||
import '../../../Reuseable/reusable_dropdown_field.dart';
|
||||
|
||||
// /// Dropdown selection field implementation
|
||||
// class DropdownField extends BaseField {
|
||||
// final String fieldKey;
|
||||
// final String label;
|
||||
// final String hint;
|
||||
// final bool isRequired;
|
||||
// final List<Map<String, dynamic>> options;
|
||||
// final String valueKey;
|
||||
// final String displayKey;
|
||||
/// Dropdown selection field implementation
|
||||
class DropdownField extends BaseField {
|
||||
final String fieldKey;
|
||||
final String label;
|
||||
final String hint;
|
||||
final bool isRequired;
|
||||
final List<Map<String, dynamic>> options;
|
||||
final String valueKey; // id field in option map
|
||||
final String displayKey; // display field in option map
|
||||
|
||||
// DropdownField({
|
||||
// required this.fieldKey,
|
||||
// required this.label,
|
||||
// required this.hint,
|
||||
// this.isRequired = false,
|
||||
// required this.options,
|
||||
// this.valueKey = 'id',
|
||||
// this.displayKey = 'name',
|
||||
// });
|
||||
DropdownField({
|
||||
required this.fieldKey,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
this.isRequired = false,
|
||||
required this.options,
|
||||
this.valueKey = 'id',
|
||||
this.displayKey = 'name',
|
||||
});
|
||||
|
||||
// @override
|
||||
// String? Function(String?)? get validator => (value) {
|
||||
// if (isRequired && (value == null || value.isEmpty)) {
|
||||
// return '$label is required';
|
||||
// }
|
||||
// return null;
|
||||
// };
|
||||
@override
|
||||
String? Function(String?)? get validator => (value) {
|
||||
if (isRequired && (value == null || value.isEmpty)) {
|
||||
return '$label is required';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// @override
|
||||
// Widget buildField({
|
||||
// required TextEditingController controller,
|
||||
// required ColorScheme colorScheme,
|
||||
// VoidCallback? onChanged,
|
||||
// }) {
|
||||
// return ReusableDropdownField(
|
||||
// controller: controller,
|
||||
// label: label,
|
||||
// hint: hint,
|
||||
// items: options,
|
||||
// valueKey: valueKey,
|
||||
// displayKey: displayKey,
|
||||
// onChanged: onChanged != null ? (_) => onChanged() : null,
|
||||
// validator: validator,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
@override
|
||||
Widget buildField({
|
||||
required TextEditingController controller,
|
||||
required ColorScheme colorScheme,
|
||||
VoidCallback? onChanged,
|
||||
}) {
|
||||
return ReusableDropdownField(
|
||||
label: label,
|
||||
options: options,
|
||||
valueField: valueKey, // id
|
||||
uiField: displayKey, // label
|
||||
value: controller.text.isNotEmpty ? controller.text : null,
|
||||
onChanged: (val) {
|
||||
controller.text = val ?? '';
|
||||
if (onChanged != null) onChanged();
|
||||
},
|
||||
onSaved: (val) {
|
||||
controller.text = val ?? '';
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:provider/provider.dart';
|
||||
import '../../../core/providers/dynamic_theme_provider.dart';
|
||||
import '../fields/base_field.dart';
|
||||
import '../../../shared/widgets/buttons/modern_button.dart';
|
||||
import '../../../core/constants/ui_constants.dart';
|
||||
@ -31,6 +419,7 @@ class _EntityFormState extends State<EntityForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final Map<String, TextEditingController> _controllers = {};
|
||||
final Map<String, BaseField> _fieldByKey = {};
|
||||
late final Map<String, dynamic> _initialData;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -39,10 +428,26 @@ class _EntityFormState extends State<EntityForm> {
|
||||
}
|
||||
|
||||
void _initializeControllers() {
|
||||
_initialData = widget.initialData ?? const {};
|
||||
for (final field in widget.fields) {
|
||||
_controllers[field.fieldKey] = TextEditingController(
|
||||
text: widget.initialData?[field.fieldKey]?.toString() ?? '',
|
||||
);
|
||||
final props = field.customProperties ?? const {};
|
||||
final bool assignByJsonPaths = props['assignByJsonPaths'] == true;
|
||||
final List<dynamic>? paths = props['paths'] as List<dynamic>?;
|
||||
String initialText =
|
||||
widget.initialData?[field.fieldKey]?.toString() ?? '';
|
||||
if (assignByJsonPaths && paths != null) {
|
||||
final Map<String, dynamic> values = {};
|
||||
for (final p in paths) {
|
||||
if (p is String) {
|
||||
final v = _readValueByPath(_initialData, p);
|
||||
if (v != null) values[p] = v;
|
||||
}
|
||||
}
|
||||
if (values.isNotEmpty) {
|
||||
initialText = _encodeMap(values);
|
||||
}
|
||||
}
|
||||
_controllers[field.fieldKey] = TextEditingController(text: initialText);
|
||||
_fieldByKey[field.fieldKey] = field;
|
||||
}
|
||||
}
|
||||
@ -63,7 +468,10 @@ class _EntityFormState extends State<EntityForm> {
|
||||
Theme.of(context).brightness == Brightness.dark,
|
||||
);
|
||||
|
||||
return Form(
|
||||
return EntityFormScope(
|
||||
controllers: _controllers,
|
||||
notifyParent: () => setState(() {}),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
@ -90,6 +498,7 @@ class _EntityFormState extends State<EntityForm> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -132,15 +541,108 @@ class _EntityFormState extends State<EntityForm> {
|
||||
final field = _fieldByKey[key];
|
||||
final value = entry.value.text.trim();
|
||||
|
||||
// Skip confirm entries for any password group
|
||||
// Skip fields that are marked as non-submittable (e.g., DataGrid)
|
||||
final props = field?.customProperties ?? const {};
|
||||
final bool excludeFromSubmit = props['excludeFromSubmit'] == true;
|
||||
if (excludeFromSubmit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip confirm entries for any password group
|
||||
final bool isPassword = props['isPassword'] == true;
|
||||
final bool isConfirm = props['isConfirm'] == true;
|
||||
if (isPassword && isConfirm) continue;
|
||||
|
||||
final bool assignByJsonPaths = props['assignByJsonPaths'] == true;
|
||||
if (assignByJsonPaths) {
|
||||
// If composite and empty -> skip adding base key
|
||||
if (value.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final Map<String, dynamic>? map = _tryDecodeMap(value);
|
||||
if (map != null) {
|
||||
map.forEach((path, v) {
|
||||
if (path is String) {
|
||||
_assignValueByPath(formData, path, v);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// If not decodable, also skip to avoid sending invalid base key
|
||||
continue;
|
||||
}
|
||||
|
||||
formData[key] = value;
|
||||
}
|
||||
widget.onSubmit(formData);
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _readValueByPath(Map<String, dynamic>? source, String path) {
|
||||
if (source == null) return null;
|
||||
final segments = path.split('.');
|
||||
dynamic current = source;
|
||||
for (final segment in segments) {
|
||||
if (current is Map<String, dynamic> && current.containsKey(segment)) {
|
||||
current = current[segment];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
void _assignValueByPath(
|
||||
Map<String, dynamic> target, String path, dynamic value) {
|
||||
final segments = path.split('.');
|
||||
Map<String, dynamic> current = target;
|
||||
for (int i = 0; i < segments.length; i++) {
|
||||
final seg = segments[i];
|
||||
final bool isLast = i == segments.length - 1;
|
||||
if (isLast) {
|
||||
current[seg] = value;
|
||||
} else {
|
||||
if (current[seg] is! Map<String, dynamic>) {
|
||||
current[seg] = <String, dynamic>{};
|
||||
}
|
||||
current = current[seg] as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _encodeMap(Map<String, dynamic> map) {
|
||||
return const JsonEncoder().convert(map);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _tryDecodeMap(String value) {
|
||||
try {
|
||||
final decoded = const JsonDecoder().convert(value);
|
||||
if (decoded is Map<String, dynamic>) return decoded;
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inherited scope to provide access to the form's controllers for advanced shared fields
|
||||
class EntityFormScope extends InheritedWidget {
|
||||
final Map<String, TextEditingController> controllers;
|
||||
final VoidCallback notifyParent;
|
||||
|
||||
const EntityFormScope({
|
||||
super.key,
|
||||
required this.controllers,
|
||||
required this.notifyParent,
|
||||
required Widget child,
|
||||
}) : super(child: child);
|
||||
|
||||
static EntityFormScope? of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<EntityFormScope>();
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant EntityFormScope oldWidget) {
|
||||
return oldWidget.controllers != controllers;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
class ApiConstants {
|
||||
static const baseUrl = 'http://localhost:9292';
|
||||
// USER AUTH API'S //
|
||||
static const loginEndpoint = "$baseUrl/token/session";
|
||||
static const getOtpEndpoint = "$baseUrl/token/user/send_email";
|
||||
@ -23,4 +22,6 @@ class ApiConstants {
|
||||
static const uploadSystemParamImg = '$baseUrl/api/logos/upload?ref=test';
|
||||
static const getSystemParameters = '$baseUrl/sysparam/getSysParams';
|
||||
static const updateSystemParams = '$baseUrl/sysparam/updateSysParams';
|
||||
|
||||
static const baseUrl = 'http://localhost:9292';
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user