feat: add Flutter schema-driven form cards
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DynamicFormCard extends StatelessWidget {
|
||||
const DynamicFormCard({
|
||||
super.key,
|
||||
required this.definition,
|
||||
required this.state,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final DynamicFormDefinition definition;
|
||||
final DynamicFormState state;
|
||||
final void Function(String field, Object? value) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
definition.uiSchema.description,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (final section in definition.uiSchema.sections) ...[
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
section.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (
|
||||
var index = 0;
|
||||
index < section.controls.length;
|
||||
index++
|
||||
) ...[
|
||||
_buildControl(context, section.controls[index]),
|
||||
if (index != section.controls.length - 1)
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControl(BuildContext context, FormControlSchema control) {
|
||||
final field = definition.dataSchema.properties[control.field];
|
||||
if (field == null) {
|
||||
return Text(
|
||||
'未识别字段:${control.field}',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
);
|
||||
}
|
||||
final required = definition.dataSchema.required.contains(control.field);
|
||||
final label = required ? '${control.label} *' : control.label;
|
||||
final error = state.errors[control.field];
|
||||
return switch (control.control) {
|
||||
FormControlType.text || FormControlType.textArea => TextFormField(
|
||||
key: ValueKey(control.field),
|
||||
initialValue: state.values[control.field] as String?,
|
||||
minLines: control.control == FormControlType.textArea ? 3 : 1,
|
||||
maxLines: control.control == FormControlType.textArea ? 6 : 1,
|
||||
maxLength: field.maxLength,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: control.placeholder,
|
||||
helperText: control.helperText,
|
||||
errorText: error,
|
||||
),
|
||||
onChanged: (value) => onChanged(control.field, value),
|
||||
),
|
||||
FormControlType.select => DropdownButtonFormField<String>(
|
||||
key: ValueKey(control.field),
|
||||
initialValue: state.values[control.field] as String?,
|
||||
decoration: InputDecoration(labelText: label, errorText: error),
|
||||
items: field.enumValues
|
||||
.map(
|
||||
(value) => DropdownMenuItem(
|
||||
value: value,
|
||||
child: Text(control.optionLabels[value] ?? value),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) => onChanged(control.field, value),
|
||||
),
|
||||
FormControlType.dateTime => _DateTimeControl(
|
||||
label: label,
|
||||
value: state.values[control.field] as String?,
|
||||
errorText: error,
|
||||
onChanged: (value) => onChanged(control.field, value),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _DateTimeControl extends StatelessWidget {
|
||||
const _DateTimeControl({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.errorText,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
final String? errorText;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final parsed = value == null ? null : DateTime.tryParse(value!);
|
||||
final display = parsed == null
|
||||
? '请选择日期和时间'
|
||||
: DateFormat('yyyy年MM月dd日 HH:mm').format(parsed.toLocal());
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => _pick(context, parsed),
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
errorText: errorText,
|
||||
suffixIcon: const Icon(Icons.calendar_month_outlined),
|
||||
),
|
||||
child: Text(
|
||||
display,
|
||||
style: TextStyle(
|
||||
color: parsed == null ? Theme.of(context).hintColor : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pick(BuildContext context, DateTime? current) async {
|
||||
final now = DateTime.now();
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: current?.toLocal() ?? now,
|
||||
firstDate: now.subtract(const Duration(days: 30)),
|
||||
lastDate: now.add(const Duration(days: 730)),
|
||||
);
|
||||
if (date == null || !context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(current?.toLocal() ?? now),
|
||||
);
|
||||
if (time == null) return;
|
||||
onChanged(
|
||||
DateTime(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
time.hour,
|
||||
time.minute,
|
||||
).toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
enum JsonValueType { string, number, boolean }
|
||||
|
||||
enum FormControlType { text, textArea, select, dateTime }
|
||||
|
||||
class DynamicFormDefinition {
|
||||
const DynamicFormDefinition({
|
||||
required this.dataSchema,
|
||||
required this.uiSchema,
|
||||
});
|
||||
|
||||
final JsonFormSchema dataSchema;
|
||||
final FormUiSchema uiSchema;
|
||||
}
|
||||
|
||||
class JsonFormSchema {
|
||||
const JsonFormSchema({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.properties,
|
||||
this.required = const {},
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final Map<String, JsonFieldSchema> properties;
|
||||
final Set<String> required;
|
||||
}
|
||||
|
||||
class JsonFieldSchema {
|
||||
const JsonFieldSchema({
|
||||
required this.type,
|
||||
this.format,
|
||||
this.enumValues = const [],
|
||||
this.minLength,
|
||||
this.maxLength,
|
||||
});
|
||||
|
||||
final JsonValueType type;
|
||||
final String? format;
|
||||
final List<String> enumValues;
|
||||
final int? minLength;
|
||||
final int? maxLength;
|
||||
}
|
||||
|
||||
class FormUiSchema {
|
||||
const FormUiSchema({required this.description, required this.sections});
|
||||
|
||||
final String description;
|
||||
final List<FormSectionSchema> sections;
|
||||
}
|
||||
|
||||
class FormSectionSchema {
|
||||
const FormSectionSchema({required this.title, required this.controls});
|
||||
|
||||
final String title;
|
||||
final List<FormControlSchema> controls;
|
||||
}
|
||||
|
||||
class FormControlSchema {
|
||||
const FormControlSchema({
|
||||
required this.field,
|
||||
required this.label,
|
||||
required this.control,
|
||||
this.placeholder,
|
||||
this.helperText,
|
||||
this.optionLabels = const {},
|
||||
});
|
||||
|
||||
final String field;
|
||||
final String label;
|
||||
final FormControlType control;
|
||||
final String? placeholder;
|
||||
final String? helperText;
|
||||
final Map<String, String> optionLabels;
|
||||
}
|
||||
|
||||
class DynamicFormState {
|
||||
const DynamicFormState({this.values = const {}, this.errors = const {}});
|
||||
|
||||
final Map<String, Object?> values;
|
||||
final Map<String, String> errors;
|
||||
|
||||
DynamicFormState copyWith({
|
||||
Map<String, Object?>? values,
|
||||
Map<String, String>? errors,
|
||||
}) {
|
||||
return DynamicFormState(
|
||||
values: values ?? this.values,
|
||||
errors: errors ?? this.errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
|
||||
|
||||
Map<String, String> validateDynamicForm(
|
||||
JsonFormSchema schema,
|
||||
Map<String, Object?> values,
|
||||
) {
|
||||
final errors = <String, String>{};
|
||||
for (final entry in schema.properties.entries) {
|
||||
final key = entry.key;
|
||||
final value = values[key];
|
||||
final field = entry.value;
|
||||
final empty = value == null || (value is String && value.trim().isEmpty);
|
||||
if (schema.required.contains(key) && empty) {
|
||||
errors[key] = '此项为必填项';
|
||||
continue;
|
||||
}
|
||||
if (empty) continue;
|
||||
if (value is String) {
|
||||
if (field.minLength != null && value.trim().length < field.minLength!) {
|
||||
errors[key] = '至少输入 ${field.minLength} 个字符';
|
||||
} else if (field.maxLength != null && value.length > field.maxLength!) {
|
||||
errors[key] = '最多输入 ${field.maxLength} 个字符';
|
||||
} else if (field.enumValues.isNotEmpty &&
|
||||
!field.enumValues.contains(value)) {
|
||||
errors[key] = '选项无效';
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
Reference in New Issue
Block a user