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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user