entityfield
This commit is contained in:
parent
f3acdf65c0
commit
1e8b611228
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user