Auto Task Assignment

This commit is contained in:
2026-02-18 23:14:50 +08:00
parent 372928d8e7
commit 5ec57a1cec
12 changed files with 1109 additions and 138 deletions
+378 -110
View File
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/profile.dart';
import '../../models/task.dart';
import '../../models/task_assignment.dart';
import '../../models/task_activity_log.dart';
import '../../models/ticket.dart';
import '../../models/ticket_message.dart';
import '../../providers/notifications_provider.dart';
@@ -162,125 +163,195 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
),
);
final messagesCard = Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
// Tabbed area: Chat + Activity
final tabbedCard = Card(
child: DefaultTabController(
length: 2,
child: Column(
children: [
Expanded(
child: messagesAsync.when(
data: (messages) => _buildMessages(
context,
messages,
profilesAsync.valueOrNull ?? [],
),
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text('Failed to load messages: $error'),
),
Material(
color: Theme.of(context).colorScheme.surface,
child: TabBar(
labelColor: Theme.of(context).colorScheme.onSurface,
indicatorColor: Theme.of(context).colorScheme.primary,
tabs: const [
Tab(text: 'Chat'),
Tab(text: 'Activity'),
],
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (typingState.userIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_typingLabel(
typingState.userIds,
profilesAsync,
),
style: Theme.of(
context,
).textTheme.labelSmall,
),
const SizedBox(width: 8),
TypingDots(
size: 8,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
),
),
if (_mentionQuery != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildMentionList(profilesAsync),
),
if (!canSendMessages)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Messaging is disabled for completed tasks.',
style: Theme.of(context).textTheme.labelMedium,
),
),
Row(
SizedBox(height: 8),
Expanded(
child: TabBarView(
children: [
// Chat tab (existing messages UI)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: 'Message...',
),
textInputAction: TextInputAction.send,
enabled: canSendMessages,
onChanged: (_) => _handleComposerChanged(
child: messagesAsync.when(
data: (messages) => _buildMessages(
context,
messages,
profilesAsync.valueOrNull ?? [],
ref.read(currentUserIdProvider),
canSendMessages,
typingChannelId,
),
onSubmitted: (_) => _handleSendMessage(
task,
profilesAsync.valueOrNull ?? [],
ref.read(currentUserIdProvider),
canSendMessages,
typingChannelId,
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, _) => Center(
child: Text(
'Failed to load messages: $error',
),
),
),
),
const SizedBox(width: 12),
IconButton(
tooltip: 'Send',
onPressed: canSendMessages
? () => _handleSendMessage(
task,
profilesAsync.valueOrNull ?? [],
ref.read(currentUserIdProvider),
canSendMessages,
typingChannelId,
)
: null,
icon: const Icon(Icons.send),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
0,
8,
0,
12,
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
if (typingState.userIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: 6,
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_typingLabel(
typingState.userIds,
profilesAsync,
),
style: Theme.of(
context,
).textTheme.labelSmall,
),
const SizedBox(width: 8),
TypingDots(
size: 8,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
),
),
if (_mentionQuery != null)
Padding(
padding: const EdgeInsets.only(
bottom: 8,
),
child: _buildMentionList(
profilesAsync,
),
),
if (!canSendMessages)
Padding(
padding: const EdgeInsets.only(
bottom: 8,
),
child: Text(
'Messaging is disabled for completed tasks.',
style: Theme.of(
context,
).textTheme.labelMedium,
),
),
Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
hintText: 'Message...',
),
textInputAction:
TextInputAction.send,
enabled: canSendMessages,
onChanged: (_) =>
_handleComposerChanged(
profilesAsync.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
),
canSendMessages,
typingChannelId,
),
onSubmitted: (_) =>
_handleSendMessage(
task,
profilesAsync.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
),
canSendMessages,
typingChannelId,
),
),
),
const SizedBox(width: 12),
IconButton(
tooltip: 'Send',
onPressed: canSendMessages
? () => _handleSendMessage(
task,
profilesAsync.valueOrNull ??
[],
ref.read(
currentUserIdProvider,
),
canSendMessages,
typingChannelId,
)
: null,
icon: const Icon(Icons.send),
),
],
),
],
),
),
),
],
),
],
),
),
// Activity tab
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: _buildActivityTab(
task,
assignments,
messagesAsync,
profilesAsync.valueOrNull ?? [],
),
),
],
),
),
],
@@ -293,7 +364,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
children: [
Expanded(flex: 2, child: detailsCard),
const SizedBox(width: 16),
Expanded(flex: 3, child: messagesCard),
Expanded(flex: 3, child: tabbedCard),
],
);
}
@@ -302,7 +373,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
children: [
detailsCard,
const SizedBox(height: 12),
Expanded(child: messagesCard),
Expanded(child: tabbedCard),
],
);
},
@@ -401,6 +472,155 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
);
}
Widget _buildActivityTab(
Task task,
List<TaskAssignment> assignments,
AsyncValue<List<TicketMessage>> messagesAsync,
List<Profile> profiles,
) {
final logsAsync = ref.watch(taskActivityLogsProvider(task.id));
final logs = logsAsync.valueOrNull ?? <TaskActivityLog>[];
final profileById = {for (final p in profiles) p.id: p};
// Find the latest assignment (by createdAt)
final assignedForTask =
assignments.where((a) => a.taskId == task.id).toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final latestAssignment = assignedForTask.isEmpty
? null
: assignedForTask.last;
DateTime? firstMessageByAssignee;
if (latestAssignment != null) {
messagesAsync.when(
data: (messages) {
final byAssignee =
messages
.where((m) => m.senderId == latestAssignment.userId)
.where((m) => m.createdAt.isAfter(latestAssignment.createdAt))
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
if (byAssignee.isNotEmpty) {
firstMessageByAssignee = byAssignee.first.createdAt;
}
},
loading: () {},
error: (err, stack) {},
);
}
DateTime? startedByAssignee;
for (final l in logs) {
if (l.actionType == 'started' && latestAssignment != null) {
if (l.actorId == latestAssignment.userId &&
l.createdAt.isAfter(latestAssignment.createdAt)) {
startedByAssignee = l.createdAt;
break;
}
}
}
Duration? responseDuration;
DateTime? responseAt;
if (latestAssignment != null) {
final assignedAt = latestAssignment.createdAt;
final candidates = <DateTime>[];
if (firstMessageByAssignee != null) {
candidates.add(firstMessageByAssignee!);
}
if (startedByAssignee != null) {
candidates.add(startedByAssignee);
}
if (candidates.isNotEmpty) {
candidates.sort();
responseAt = candidates.first;
responseDuration = responseAt.difference(assignedAt);
}
}
// Render timeline (oldest -> newest)
final timeline = <Widget>[];
if (logs.isEmpty) {
timeline.add(const Text('No activity yet.'));
} else {
final chronological = List.from(logs.reversed);
for (final l in chronological) {
final actorName = l.actorId == null
? 'System'
: (profileById[l.actorId]?.fullName ?? l.actorId!);
switch (l.actionType) {
case 'created':
timeline.add(_activityRow('Task created', actorName, l.createdAt));
break;
case 'assigned':
final meta = l.meta ?? {};
final userId = meta['user_id'] as String?;
final auto = meta['auto'] == true;
final name = userId == null
? 'Unknown'
: (profileById[userId]?.fullName ?? userId);
timeline.add(
_activityRow(
auto ? 'Auto-assigned to $name' : 'Assigned to $name',
actorName,
l.createdAt,
),
);
break;
case 'reassigned':
final meta = l.meta ?? {};
final to = (meta['to'] as List?) ?? [];
final toNames = to
.map((id) => profileById[id]?.fullName ?? id)
.join(', ');
timeline.add(
_activityRow('Reassigned to $toNames', actorName, l.createdAt),
);
break;
case 'started':
timeline.add(_activityRow('Task started', actorName, l.createdAt));
break;
case 'completed':
timeline.add(
_activityRow('Task completed', actorName, l.createdAt),
);
break;
default:
timeline.add(_activityRow(l.actionType, actorName, l.createdAt));
}
}
}
if (responseDuration != null) {
final assigneeName =
profileById[latestAssignment!.userId]?.fullName ??
latestAssignment.userId;
timeline.add(const SizedBox(height: 12));
timeline.add(
Row(
children: [
const Icon(Icons.timer, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
'Response Time: ${_formatDuration(responseDuration)} ($assigneeName responded at ${responseAt!.toLocal().toString()})',
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: timeline,
),
);
}
Widget _buildTatSection(Task task) {
final animateQueue = task.status == 'queued';
final animateExecution = task.startedAt != null && task.completedAt == null;
@@ -439,6 +659,36 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
);
}
Widget _activityRow(String title, String actor, DateTime at) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.circle,
size: 12,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
Text(
'$actor${at.toLocal()}',
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
);
}
Duration? _safeDuration(Duration? duration) {
if (duration == null) {
return null;
@@ -579,7 +829,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
if (content.isEmpty) {
return;
}
ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping();
// Safely stop typing — controller may have been auto-disposed by Riverpod.
final typingController = _maybeTypingController(typingChannelId);
typingController?.stopTyping();
final message = await ref
.read(ticketsControllerProvider)
.sendTaskMessage(
@@ -620,11 +874,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
String typingChannelId,
) {
if (!canSendMessages) {
ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping();
_maybeTypingController(typingChannelId)?.stopTyping();
_clearMentions();
return;
}
ref.read(typingIndicatorProvider(typingChannelId).notifier).userTyping();
_maybeTypingController(typingChannelId)?.userTyping();
final text = _messageController.text;
final cursor = _messageController.selection.baseOffset;
if (cursor < 0) {
@@ -672,6 +926,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
});
}
// Safely obtain the typing controller for [channelId].
// Returns null if the provider has been disposed or is not mounted.
TypingIndicatorController? _maybeTypingController(String channelId) {
try {
final controller = ref.read(typingIndicatorProvider(channelId).notifier);
return controller.mounted ? controller : null;
} on StateError {
// provider was disposed concurrently
return null;
} catch (_) {
return null;
}
}
bool _isWhitespace(String char) {
return char.trim().isEmpty;
}
+30 -4
View File
@@ -6,6 +6,7 @@ import '../../models/notification_item.dart';
import '../../models/office.dart';
import '../../models/profile.dart';
import '../../models/task.dart';
import '../../models/task_assignment.dart';
import '../../models/ticket.dart';
import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart';
@@ -55,6 +56,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
final profileAsync = ref.watch(currentProfileProvider);
final notificationsAsync = ref.watch(notificationsProvider);
final profilesAsync = ref.watch(profilesProvider);
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
final canCreate = profileAsync.maybeWhen(
data: (profile) =>
@@ -102,6 +104,22 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
];
final staffOptions = _staffOptions(profilesAsync.valueOrNull);
final statusOptions = _taskStatusOptions(tasks);
// derive latest assignee per task from task assignments stream
final assignments =
assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
final assignmentsByTask = <String, TaskAssignment>{};
for (final a in assignments) {
final current = assignmentsByTask[a.taskId];
if (current == null || a.createdAt.isAfter(current.createdAt)) {
assignmentsByTask[a.taskId] = a;
}
}
final latestAssigneeByTaskId = <String, String?>{};
for (final entry in assignmentsByTask.entries) {
latestAssigneeByTaskId[entry.key] = entry.value.userId;
}
final filteredTasks = _applyTaskFilters(
tasks,
ticketById: ticketById,
@@ -110,6 +128,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
status: _selectedStatus,
assigneeId: _selectedAssigneeId,
dateRange: _selectedDateRange,
latestAssigneeByTaskId: latestAssigneeByTaskId,
);
final summaryDashboard = _StatusSummaryRow(
counts: _taskStatusCounts(filteredTasks),
@@ -249,8 +268,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
),
TasQColumn<Task>(
header: 'Assigned Agent',
cellBuilder: (context, task) =>
Text(_assignedAgent(profileById, task.creatorId)),
cellBuilder: (context, task) {
final assigneeId = latestAssigneeByTaskId[task.id];
return Text(_assignedAgent(profileById, assigneeId));
},
),
TasQColumn<Task>(
header: 'Status',
@@ -271,7 +292,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
final officeName = officeId == null
? 'Unassigned office'
: (officeById[officeId]?.name ?? officeId);
final assigned = _assignedAgent(profileById, task.creatorId);
final assigned = _assignedAgent(
profileById,
latestAssigneeByTaskId[task.id],
);
final subtitle = _buildSubtitle(officeName, task.status);
final hasMention = _hasTaskMention(notificationsAsync, task);
final typingState = ref.watch(
@@ -558,6 +582,7 @@ List<Task> _applyTaskFilters(
required String? status,
required String? assigneeId,
required DateTimeRange? dateRange,
required Map<String, String?> latestAssigneeByTaskId,
}) {
final query = subjectQuery.trim().toLowerCase();
return tasks.where((task) {
@@ -577,7 +602,8 @@ List<Task> _applyTaskFilters(
if (status != null && task.status != status) {
return false;
}
if (assigneeId != null && task.creatorId != assigneeId) {
final currentAssignee = latestAssigneeByTaskId[task.id];
if (assigneeId != null && currentAssignee != assigneeId) {
return false;
}
if (dateRange != null) {