feat: add Flutter schema-driven form cards
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AssistantPage extends StatelessWidget {
|
||||
const AssistantPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const Center(child: Text('AI 助手将在下一阶段接入'));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
|
||||
import 'package:aioa_mobile/core/forms/schema/form_validator.dart';
|
||||
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
final leaveDraftProvider =
|
||||
NotifierProvider<LeaveDraftController, DynamicFormState>(
|
||||
LeaveDraftController.new,
|
||||
);
|
||||
|
||||
class LeaveDraftController extends Notifier<DynamicFormState> {
|
||||
@override
|
||||
DynamicFormState build() => const DynamicFormState();
|
||||
|
||||
void setValue(String field, Object? value) {
|
||||
final values = {...state.values, field: value};
|
||||
final errors = {...state.errors}..remove(field);
|
||||
state = state.copyWith(values: values, errors: errors);
|
||||
}
|
||||
|
||||
void applyAiSuggestion() {
|
||||
final start = DateTime.now().add(const Duration(days: 1));
|
||||
final normalizedStart = DateTime(
|
||||
start.year,
|
||||
start.month,
|
||||
start.day,
|
||||
13,
|
||||
30,
|
||||
);
|
||||
final end = normalizedStart.add(const Duration(hours: 4));
|
||||
state = DynamicFormState(
|
||||
values: {
|
||||
'type': 'PERSONAL',
|
||||
'startsAt': normalizedStart.toUtc().toIso8601String(),
|
||||
'endsAt': end.toUtc().toIso8601String(),
|
||||
'reason': '办理个人事务,已提前完成工作交接。',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
bool validate() {
|
||||
final errors = validateDynamicForm(
|
||||
leaveFormDefinition.dataSchema,
|
||||
state.values,
|
||||
);
|
||||
final startsAt = DateTime.tryParse(
|
||||
state.values['startsAt'] as String? ?? '',
|
||||
);
|
||||
final endsAt = DateTime.tryParse(state.values['endsAt'] as String? ?? '');
|
||||
if (startsAt != null && endsAt != null && !endsAt.isAfter(startsAt)) {
|
||||
errors['endsAt'] = '结束时间必须晚于开始时间';
|
||||
}
|
||||
state = state.copyWith(errors: errors);
|
||||
return errors.isEmpty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
|
||||
|
||||
const leaveFormDefinition = DynamicFormDefinition(
|
||||
dataSchema: JsonFormSchema(
|
||||
id: 'leave-request-v1',
|
||||
title: '请假申请',
|
||||
required: {'type', 'startsAt', 'endsAt', 'reason'},
|
||||
properties: {
|
||||
'type': JsonFieldSchema(
|
||||
type: JsonValueType.string,
|
||||
enumValues: ['PERSONAL', 'SICK', 'ANNUAL'],
|
||||
),
|
||||
'startsAt': JsonFieldSchema(
|
||||
type: JsonValueType.string,
|
||||
format: 'date-time',
|
||||
),
|
||||
'endsAt': JsonFieldSchema(
|
||||
type: JsonValueType.string,
|
||||
format: 'date-time',
|
||||
),
|
||||
'reason': JsonFieldSchema(
|
||||
type: JsonValueType.string,
|
||||
minLength: 1,
|
||||
maxLength: 2000,
|
||||
),
|
||||
},
|
||||
),
|
||||
uiSchema: FormUiSchema(
|
||||
description: '表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。',
|
||||
sections: [
|
||||
FormSectionSchema(
|
||||
title: '请假信息',
|
||||
controls: [
|
||||
FormControlSchema(
|
||||
field: 'type',
|
||||
label: '请假类型',
|
||||
control: FormControlType.select,
|
||||
optionLabels: {'PERSONAL': '事假', 'SICK': '病假', 'ANNUAL': '年假'},
|
||||
),
|
||||
FormControlSchema(
|
||||
field: 'startsAt',
|
||||
label: '开始时间',
|
||||
control: FormControlType.dateTime,
|
||||
),
|
||||
FormControlSchema(
|
||||
field: 'endsAt',
|
||||
label: '结束时间',
|
||||
control: FormControlType.dateTime,
|
||||
),
|
||||
],
|
||||
),
|
||||
FormSectionSchema(
|
||||
title: '补充说明',
|
||||
controls: [
|
||||
FormControlSchema(
|
||||
field: 'reason',
|
||||
label: '请假原因',
|
||||
control: FormControlType.textArea,
|
||||
placeholder: '请简要说明请假原因',
|
||||
helperText: 'AI 可以帮助整理表达,但提交前必须由你确认。',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:aioa_mobile/core/forms/presentation/dynamic_form_card.dart';
|
||||
import 'package:aioa_mobile/features/form/application/leave_draft_controller.dart';
|
||||
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class LeaveFormPage extends ConsumerWidget {
|
||||
const LeaveFormPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formState = ref.watch(leaveDraftProvider);
|
||||
final controller = ref.read(leaveDraftProvider.notifier);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(leaveFormDefinition.dataSchema.title)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
children: [
|
||||
_AiAssistCard(onApply: controller.applyAiSuggestion),
|
||||
const SizedBox(height: 14),
|
||||
DynamicFormCard(
|
||||
definition: leaveFormDefinition,
|
||||
state: formState,
|
||||
onChanged: controller.setValue,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
if (!controller.validate()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请检查表单中的必填项和时间范围')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => _ConfirmationSheet(
|
||||
values: ref.read(leaveDraftProvider).values,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14),
|
||||
child: Text('检查并确认'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AiAssistCard extends StatelessWidget {
|
||||
const _AiAssistCard({required this.onApply});
|
||||
|
||||
final VoidCallback onApply;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.45),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(child: Icon(Icons.auto_awesome)),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'AI 表单助手',
|
||||
style: TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text('示例:帮我填写明天下午的事假申请'),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(onPressed: onApply, child: const Text('自动填写')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConfirmationSheet extends StatelessWidget {
|
||||
const _ConfirmationSheet({required this.values});
|
||||
|
||||
final Map<String, Object?> values;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'提交前确认',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('AI 或 Schema 只能生成草稿,实际业务写操作必须在用户确认后执行。'),
|
||||
const SizedBox(height: 16),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Text(const JsonEncoder.withIndent(' ').convert(values)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('演示模式:草稿已通过本地校验,尚未调用后端')),
|
||||
);
|
||||
},
|
||||
child: const Text('确认创建草稿'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
const Center(child: Text('员工小明 · 产品研发部'));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TasksPage extends StatelessWidget {
|
||||
const TasksPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Center(child: Text('暂无待办'));
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class WorkspacePage extends StatelessWidget {
|
||||
const WorkspacePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(18),
|
||||
children: [
|
||||
Text(
|
||||
'早上好,员工小明',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('今天有什么需要处理?', style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 22),
|
||||
Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => context.push('/leave/new'),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(18),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
child: Icon(Icons.event_available_outlined),
|
||||
),
|
||||
SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'发起请假',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text('由 Schema 自动生成移动表单卡片'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const _StatusCard(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusCard extends StatelessWidget {
|
||||
const _StatusCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'我的工作',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_Metric(value: '0', label: '待我处理'),
|
||||
_Metric(value: '0', label: '进行中'),
|
||||
_Metric(value: '0', label: '本月完成'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Metric extends StatelessWidget {
|
||||
const _Metric({required this.value, required this.label});
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(label),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user