feat: complete leave approval MVP
This commit is contained in:
@@ -1,8 +1,280 @@
|
||||
import 'package:aioa_mobile/features/tasks/application/approval_task_controller.dart';
|
||||
import 'package:aioa_mobile/features/tasks/application/notification_controller.dart';
|
||||
import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart';
|
||||
import 'package:aioa_mobile/features/tasks/data/notification_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class TasksPage extends StatelessWidget {
|
||||
class TasksPage extends ConsumerWidget {
|
||||
const TasksPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Center(child: Text('暂无待办'));
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final notifications = ref.watch(notificationProvider);
|
||||
final tasks = ref.watch(approvalTaskProvider);
|
||||
final unread =
|
||||
notifications.value?.where((item) => !item.isRead).length ?? 0;
|
||||
final taskCount = tasks.value?.length ?? 0;
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('待办与通知'),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(text: taskCount == 0 ? '待办' : '待办 ($taskCount)'),
|
||||
Tab(text: unread == 0 ? '通知' : '通知 ($unread)'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_ApprovalTaskList(tasks: tasks),
|
||||
_NotificationList(notifications: notifications),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ApprovalTaskList extends ConsumerWidget {
|
||||
const _ApprovalTaskList({required this.tasks});
|
||||
|
||||
final AsyncValue<List<ApprovalTaskItem>> tasks;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => tasks.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('待办加载失败:$error'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: ref.read(approvalTaskProvider.notifier).refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) return const Center(child: Text('暂无待办'));
|
||||
return RefreshIndicator(
|
||||
onRefresh: ref.read(approvalTaskProvider.notifier).refresh,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) =>
|
||||
_ApprovalTaskCard(task: items[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _ApprovalTaskCard extends ConsumerWidget {
|
||||
const _ApprovalTaskCard({required this.task});
|
||||
|
||||
final ApprovalTaskItem task;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final request = task.leaveRequest;
|
||||
final formatter = DateFormat('MM-dd HH:mm');
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(child: Icon(Icons.assignment_ind_outlined)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
Text(_typeLabel(request.type)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${formatter.format(request.startsAt.toLocal())} — ${formatter.format(request.endsAt.toLocal())}',
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(request.reason),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _decide(context, ref, approved: false),
|
||||
child: const Text('驳回'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => _decide(context, ref, approved: true),
|
||||
child: const Text('批准'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _decide(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required bool approved,
|
||||
}) async {
|
||||
final comment = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => _DecisionDialog(approved: approved),
|
||||
);
|
||||
if (comment == null || !context.mounted) return;
|
||||
final error = await ref
|
||||
.read(approvalTaskProvider.notifier)
|
||||
.decide(task: task, approved: approved, comment: comment);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error ?? (approved ? '已批准' : '已驳回'))),
|
||||
);
|
||||
}
|
||||
|
||||
String _typeLabel(String type) => switch (type) {
|
||||
'PERSONAL' => '事假',
|
||||
'SICK' => '病假',
|
||||
'ANNUAL' => '年假',
|
||||
_ => type,
|
||||
};
|
||||
}
|
||||
|
||||
class _DecisionDialog extends StatefulWidget {
|
||||
const _DecisionDialog({required this.approved});
|
||||
|
||||
final bool approved;
|
||||
|
||||
@override
|
||||
State<_DecisionDialog> createState() => _DecisionDialogState();
|
||||
}
|
||||
|
||||
class _DecisionDialogState extends State<_DecisionDialog> {
|
||||
final controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
title: Text(widget.approved ? '批准申请' : '驳回申请'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
maxLength: 1000,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.approved ? '审批意见(选填)' : '驳回原因',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: Text(widget.approved ? '确认批准' : '确认驳回'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _NotificationList extends ConsumerWidget {
|
||||
const _NotificationList({required this.notifications});
|
||||
|
||||
final AsyncValue<List<AppNotification>> notifications;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => notifications.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('通知加载失败:$error'),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: ref.read(notificationProvider.notifier).refresh,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (items) {
|
||||
if (items.isEmpty) return const Center(child: Text('暂无通知'));
|
||||
return RefreshIndicator(
|
||||
onRefresh: ref.read(notificationProvider.notifier).refresh,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Card(
|
||||
color: item.isRead
|
||||
? null
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer.withValues(alpha: 0.35),
|
||||
child: ListTile(
|
||||
leading: Icon(_icon(item.type)),
|
||||
title: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontWeight: item.isRead ? FontWeight.w500 : FontWeight.w700,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${item.body}\n${DateFormat('MM-dd HH:mm').format(item.createdAt.toLocal())}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: item.isRead ? null : const Badge(),
|
||||
onTap: item.isRead
|
||||
? null
|
||||
: () => ref
|
||||
.read(notificationProvider.notifier)
|
||||
.markRead(item.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
IconData _icon(String type) => switch (type) {
|
||||
'APPROVAL_TASK_ASSIGNED' => Icons.assignment_outlined,
|
||||
'LEAVE_APPROVED' => Icons.check_circle_outline,
|
||||
'LEAVE_REJECTED' => Icons.cancel_outlined,
|
||||
_ => Icons.notifications_none,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user