entityfield

This commit is contained in:
string 2025-09-23 08:57:16 +05:30
parent f3acdf65c0
commit 1e8b611228
2 changed files with 675 additions and 28 deletions

View File

@ -0,0 +1,651 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/constants/ui_constants.dart';
import '../../../core/providers/dynamic_theme_provider.dart';
import '../ui/entity_form.dart';
import 'autocomplete_dropdown_field.dart' as FAutoDropdown;
import 'autocomplete_multiselect_field.dart' as FAutoMultiSelect;
import 'base_field.dart';
import 'captcha_field.dart' as FCaptcha;
import 'checkbox_field.dart' as FCheckbox;
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;
/// A generic field that shows an Insert button to create a related entity inline.
///
/// Usage:
/// - Provide `fieldKey` for where to store the selected/created value
/// - Provide `label` for display
/// - Provide `relationTitle` for the modal header
/// - Either provide `fieldSchema` (preferred, like OneToOne/OneToMany) or `formFieldsBuilder`
/// - Provide `onCreate` callback which receives form data and returns created entity map
/// - Configure `valueKey` to pick what to write back (e.g., 'id') into the parent controller
/// - Optionally set `displayKey` to show a summary of the selected value
class RelatedEntityInsertField extends BaseField {
final String fieldKey;
final String label;
final String hint;
final bool isRequired;
final String relationTitle;
final Future<Map<String, dynamic>> Function(Map<String, dynamic> formData)
onCreate;
final List<Map<String, dynamic>>? fieldSchema;
final List<BaseField> Function()? formFieldsBuilder;
final String valueKey;
final String? displayKey;
RelatedEntityInsertField({
required this.fieldKey,
required this.label,
this.hint = '',
this.isRequired = false,
required this.relationTitle,
required this.onCreate,
this.fieldSchema,
this.formFieldsBuilder,
this.valueKey = 'id',
this.displayKey,
}) : assert(fieldSchema != null || formFieldsBuilder != null,
'Either fieldSchema or formFieldsBuilder must be provided');
@override
String? Function(String?)? get validator => (value) {
if (!isRequired) return null;
if (value == null || value.trim().isEmpty) return '$label is required';
return null;
};
@override
Map<String, dynamic>? get customProperties => {
'isRelationInsert': true,
'relationTitle': relationTitle,
'valueKey': valueKey,
'displayKey': displayKey,
};
@override
Widget buildField({
required TextEditingController controller,
required ColorScheme colorScheme,
VoidCallback? onChanged,
}) {
return _RelatedEntityInsertWidget(
label: label,
hint: hint,
controller: controller,
colorScheme: colorScheme,
relationTitle: relationTitle,
onCreate: onCreate,
fieldSchema: fieldSchema,
formFieldsBuilder: formFieldsBuilder,
valueKey: valueKey,
displayKey: displayKey,
validate: validator,
onChanged: onChanged,
);
}
/// Static helper to open the insert dialog anywhere (e.g., in list actions)
/// Returns the written value (e.g., created id as string) or null if cancelled
static Future<String?> showInsertDialog({
required BuildContext context,
required String relationTitle,
required Future<Map<String, dynamic>> Function(Map<String, dynamic>)
onCreate,
List<Map<String, dynamic>>? fieldSchema,
List<BaseField> Function()? formFieldsBuilder,
String valueKey = 'id',
String? displayKey,
}) async {
final TextEditingController ctrl = TextEditingController();
bool isSubmitting = false;
final List<BaseField> fields = (formFieldsBuilder != null)
? formFieldsBuilder()
: _fieldsFromSchemaStatic(fieldSchema ?? const []);
final Map<String, dynamic>? created =
await showDialog<Map<String, dynamic>>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return Consumer<DynamicThemeProvider>(
builder: (context, theme, child) {
final cs = theme.getCurrentColorScheme(
Theme.of(dialogContext).brightness == Brightness.dark);
return StatefulBuilder(
builder: (context, setLocal) {
return AlertDialog(
backgroundColor: cs.surface,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(UIConstants.radius16)),
title: Text(
relationTitle,
style: TextStyle(
color: cs.onSurface, fontWeight: FontWeight.w700),
),
content: SingleChildScrollView(
child: EntityForm(
fields: fields,
onSubmit: (formData) async {
if (isSubmitting) return;
setLocal(() => isSubmitting = true);
try {
final res = await onCreate(formData);
if (!Navigator.of(dialogContext).mounted) return;
Navigator.of(dialogContext).pop(res);
} catch (_) {
setLocal(() => isSubmitting = false);
}
},
submitButtonText: 'Create',
isLoading: isSubmitting,
),
),
actions: [
TextButton(
onPressed: isSubmitting
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
],
);
},
);
},
);
},
);
if (created != null) {
ctrl.text = (created[valueKey])?.toString() ?? '';
return ctrl.text.isEmpty ? null : ctrl.text;
}
return null;
}
// Static schema → fields mapper for the dialog helper
static List<BaseField> _fieldsFromSchemaStatic(
List<Map<String, dynamic>> schema) {
return schema.map<BaseField>((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;
switch (type) {
case 'number':
return FNumber.NumberField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
min: min,
max: max,
decimalPlaces: decimalPlaces,
);
case 'email':
return FEmail.EmailField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'phone':
return FPhone.PhoneField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'password':
return FPassword.PasswordField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'dropdown':
return FDropdown.DropdownField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
options: options ?? const [],
valueKey: valueKey,
displayKey: displayKey,
);
case 'date':
return FDate.DateField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'datetime':
return FDateTime.DateTimeField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'switch':
return FSwitch.SwitchField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'checkbox':
return FCheckbox.CheckboxField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'captcha':
return FCaptcha.CaptchaField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'url':
return FUrl.UrlField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'autocomplete_dropdown':
return FAutoDropdown.AutocompleteDropdownField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
optionsLoader: () async => options ?? [],
valueKey: valueKey,
displayKey: displayKey,
);
case 'autocomplete_multiselect':
return FAutoMultiSelect.AutocompleteMultiSelectField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
optionsLoader: () async => options ?? [],
valueKey: valueKey,
displayKey: displayKey,
);
case 'text':
default:
return FText.CustomTextField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
maxLength: maxLength,
);
}
}).toList();
}
}
class _RelatedEntityInsertWidget extends StatefulWidget {
final String label;
final String hint;
final TextEditingController controller;
final ColorScheme colorScheme;
final String relationTitle;
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) onCreate;
final List<Map<String, dynamic>>? fieldSchema;
final List<BaseField> Function()? formFieldsBuilder;
final String valueKey;
final String? displayKey;
final String? Function(String?)? validate;
final VoidCallback? onChanged;
const _RelatedEntityInsertWidget({
required this.label,
required this.hint,
required this.controller,
required this.colorScheme,
required this.relationTitle,
required this.onCreate,
this.fieldSchema,
this.formFieldsBuilder,
required this.valueKey,
required this.displayKey,
required this.validate,
required this.onChanged,
});
@override
State<_RelatedEntityInsertWidget> createState() =>
_RelatedEntityInsertWidgetState();
}
class _RelatedEntityInsertWidgetState
extends State<_RelatedEntityInsertWidget> {
bool _creating = false;
@override
Widget build(BuildContext context) {
final cs = widget.colorScheme;
final String selectedDisplay = _computeDisplay();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.label.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
widget.label,
style: TextStyle(
fontWeight: FontWeight.w700,
color: cs.onSurface,
fontSize: 14),
),
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: cs.primary.withOpacity(0.15)),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
cs.surface,
cs.surface.withOpacity(0.97),
],
),
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
children: [
Expanded(
child: Text(
selectedDisplay.isEmpty
? (widget.hint.isEmpty ? 'No selection' : widget.hint)
: selectedDisplay,
style: TextStyle(
color: cs.onSurface.withOpacity(0.75),
fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _creating ? null : _openInsertDialog,
icon: Icon(Icons.add_rounded, color: cs.onPrimary, size: 18),
label: Text('Insert',
style: TextStyle(
color: cs.onPrimary, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: cs.primary,
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
],
),
),
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,
fontWeight: FontWeight.w600)),
)
: const SizedBox.shrink();
},
)
],
);
}
String _computeDisplay() {
if (widget.displayKey == null || widget.displayKey!.isEmpty) {
return widget.controller.text.trim();
}
return widget.controller.text.trim();
}
Future<void> _openInsertDialog() async {
setState(() => _creating = true);
try {
final List<BaseField> fields = (widget.formFieldsBuilder != null)
? widget.formFieldsBuilder!()
: _fieldsFromSchema(widget.fieldSchema ?? const []);
final result = await showDialog<Map<String, dynamic>>(
context: context,
barrierDismissible: false,
builder: (context) {
bool isSubmitting = false;
return StatefulBuilder(
builder: (context, setLocal) {
return AlertDialog(
backgroundColor: Theme.of(context).colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)),
title: Text(
widget.relationTitle,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
),
content: SingleChildScrollView(
child: EntityForm(
fields: fields,
onSubmit: (formData) async {
if (isSubmitting) return;
setLocal(() => isSubmitting = true);
try {
final created = await widget.onCreate(formData);
if (!mounted) return;
Navigator.of(context).pop(created);
} catch (_) {
setLocal(() => isSubmitting = false);
}
},
submitButtonText: 'Create',
isLoading: isSubmitting,
),
),
actions: [
TextButton(
onPressed:
isSubmitting ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
);
},
);
},
);
if (result != null) {
final dynamic value = result[widget.valueKey];
final String textValue = value?.toString() ?? '';
widget.controller.text = textValue;
widget.onChanged?.call();
EntityFormScope.of(context)?.notifyParent();
}
} finally {
if (mounted) setState(() => _creating = false);
}
}
List<BaseField> _fieldsFromSchema(List<Map<String, dynamic>> schema) {
return schema.map<BaseField>((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;
switch (type) {
case 'number':
return FNumber.NumberField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
min: min,
max: max,
decimalPlaces: decimalPlaces,
);
case 'email':
return FEmail.EmailField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'phone':
return FPhone.PhoneField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'password':
return FPassword.PasswordField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'dropdown':
return FDropdown.DropdownField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
options: options ?? const [],
valueKey: valueKey,
displayKey: displayKey,
);
case 'date':
return FDate.DateField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'datetime':
return FDateTime.DateTimeField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'switch':
return FSwitch.SwitchField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'checkbox':
return FCheckbox.CheckboxField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'captcha':
return FCaptcha.CaptchaField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'url':
return FUrl.UrlField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
);
case 'autocomplete_dropdown':
return FAutoDropdown.AutocompleteDropdownField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
optionsLoader: () async => options ?? [],
valueKey: valueKey,
displayKey: displayKey,
);
case 'autocomplete_multiselect':
return FAutoMultiSelect.AutocompleteMultiSelectField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
optionsLoader: () async => options ?? [],
valueKey: valueKey,
displayKey: displayKey,
);
case 'text':
default:
return FText.CustomTextField(
fieldKey: path,
label: flabel,
hint: fhint,
isRequired: requiredField,
maxLength: maxLength,
);
}
}).toList();
}
}

View File

@ -1,6 +1,6 @@
import 'package:base_project/core/providers/dynamic_theme_provider.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/providers/dynamic_theme_provider.dart';
import '../../../core/constants/ui_constants.dart';
import '../../../shared/widgets/app_bar/modern_app_bar.dart';
import 'entity_card.dart';
@ -22,6 +22,9 @@ class EntityList extends StatefulWidget {
final String title;
final Function()? onAddNew;
final List<Map<String, dynamic>> displayFields;
// Optional: header insert action (e.g., Childform/Insert Support)
final Function()? onInsertAction;
final String? insertActionLabel;
const EntityList({
super.key,
@ -39,6 +42,8 @@ class EntityList extends StatefulWidget {
required this.title,
this.onAddNew,
this.displayFields = const [],
this.onInsertAction,
this.insertActionLabel,
});
@override
@ -97,13 +102,21 @@ class _EntityListState extends State<EntityList> with TickerProviderStateMixin {
title: widget.title,
showBackButton: true,
actions: [
if (widget.onInsertAction != null)
TextButton(
onPressed: widget.onInsertAction,
child: Text(
widget.insertActionLabel ?? 'Unknown',
style: TextStyle(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
if (widget.onAddNew != null)
IconButton(
onPressed: widget.onAddNew,
icon: Icon(
Icons.add_rounded,
color: colorScheme.primary,
),
icon: Icon(Icons.add_rounded, color: colorScheme.primary),
tooltip: 'Add New',
),
],
@ -114,9 +127,7 @@ class _EntityListState extends State<EntityList> with TickerProviderStateMixin {
_buildSearchBar(colorScheme),
// Content
Expanded(
child: _buildContent(colorScheme),
),
Expanded(child: _buildContent(colorScheme)),
],
),
);
@ -136,10 +147,7 @@ class _EntityListState extends State<EntityList> with TickerProviderStateMixin {
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surface,
colorScheme.surface.withOpacity(0.95),
],
colors: [colorScheme.surface, colorScheme.surface.withOpacity(0.95)],
),
borderRadius: BorderRadius.circular(UIConstants.radius16),
border: Border.all(
@ -234,10 +242,7 @@ class _EntityListState extends State<EntityList> with TickerProviderStateMixin {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: colorScheme.primary,
strokeWidth: 3,
),
CircularProgressIndicator(color: colorScheme.primary, strokeWidth: 3),
const SizedBox(height: UIConstants.spacing16),
Text(
'Loading ${widget.title.toLowerCase()}...',
@ -292,10 +297,7 @@ class _EntityListState extends State<EntityList> with TickerProviderStateMixin {
const SizedBox(height: UIConstants.spacing24),
ElevatedButton.icon(
onPressed: widget.onRefresh,
icon: Icon(
Icons.refresh_rounded,
color: colorScheme.onPrimary,
),
icon: Icon(Icons.refresh_rounded, color: colorScheme.onPrimary),
label: Text(
'Retry',
style: TextStyle(
@ -365,10 +367,7 @@ class _EntityListState extends State<EntityList> with TickerProviderStateMixin {
const SizedBox(height: UIConstants.spacing24),
ElevatedButton.icon(
onPressed: widget.onAddNew,
icon: Icon(
Icons.add_rounded,
color: colorScheme.onPrimary,
),
icon: Icon(Icons.add_rounded, color: colorScheme.onPrimary),
label: Text(
'Add ${widget.title}',
style: TextStyle(
@ -454,10 +453,7 @@ class _EntityListState extends State<EntityList> with TickerProviderStateMixin {
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surface,
colorScheme.surface.withOpacity(0.95),
],
colors: [colorScheme.surface, colorScheme.surface.withOpacity(0.95)],
),
borderRadius: BorderRadius.circular(UIConstants.radius20),
border: Border.all(