feat: add Flutter schema-driven form cards

This commit is contained in:
selfrelease
2026-07-18 09:13:27 +08:00
parent e13b6c7778
commit 2105fe3bac
85 changed files with 3141 additions and 3 deletions
@@ -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;
}