Auto Task Assignment
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -482,7 +482,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
if (!canSendMessages) return;
|
||||
final content = _messageController.text.trim();
|
||||
if (content.isEmpty) return;
|
||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).stopTyping();
|
||||
|
||||
_maybeTypingController(widget.ticketId)?.stopTyping();
|
||||
|
||||
final message = await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.sendTicketMessage(ticketId: widget.ticketId, content: content);
|
||||
@@ -533,11 +535,11 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
bool canSendMessages,
|
||||
) {
|
||||
if (!canSendMessages) {
|
||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).stopTyping();
|
||||
_maybeTypingController(widget.ticketId)?.stopTyping();
|
||||
_clearMentions();
|
||||
return;
|
||||
}
|
||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).userTyping();
|
||||
_maybeTypingController(widget.ticketId)?.userTyping();
|
||||
final text = _messageController.text;
|
||||
final cursor = _messageController.selection.baseOffset;
|
||||
if (cursor < 0) {
|
||||
@@ -585,6 +587,18 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
// Safely obtain the typing controller for [ticketId].
|
||||
TypingIndicatorController? _maybeTypingController(String ticketId) {
|
||||
try {
|
||||
final controller = ref.read(typingIndicatorProvider(ticketId).notifier);
|
||||
return controller.mounted ? controller : null;
|
||||
} on StateError {
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isWhitespace(String char) {
|
||||
return char.trim().isEmpty;
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
),
|
||||
),
|
||||
TasQColumn<Ticket>(
|
||||
header: 'Assigned Agent',
|
||||
header: 'Filed by',
|
||||
cellBuilder: (context, ticket) =>
|
||||
Text(_assignedAgent(profileById, ticket.creatorId)),
|
||||
),
|
||||
@@ -232,7 +232,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
children: [
|
||||
Text(officeName),
|
||||
const SizedBox(height: 2),
|
||||
Text('Assigned: $assigned'),
|
||||
Text('Filed by: $assigned'),
|
||||
const SizedBox(height: 4),
|
||||
MonoText('ID ${ticket.id}'),
|
||||
const SizedBox(height: 2),
|
||||
|
||||
Reference in New Issue
Block a user