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(),
);
}
}