tasq/lib/screens/tasks/task_detail_screen.dart

1161 lines
39 KiB
Dart

import 'package:flutter/material.dart';
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';
import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../providers/typing_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/app_breakpoints.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/task_assignment_section.dart';
import '../../widgets/typing_dots.dart';
class TaskDetailScreen extends ConsumerStatefulWidget {
const TaskDetailScreen({super.key, required this.taskId});
final String taskId;
@override
ConsumerState<TaskDetailScreen> createState() => _TaskDetailScreenState();
}
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
final _messageController = TextEditingController();
static const List<String> _statusOptions = [
'queued',
'in_progress',
'completed',
];
String? _mentionQuery;
int? _mentionStart;
List<Profile> _mentionResults = [];
@override
void initState() {
super.initState();
Future.microtask(
() => ref
.read(notificationsControllerProvider)
.markReadForTask(widget.taskId),
);
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tasksAsync = ref.watch(tasksProvider);
final ticketsAsync = ref.watch(ticketsProvider);
final officesAsync = ref.watch(officesProvider);
final profileAsync = ref.watch(currentProfileProvider);
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
final taskMessagesAsync = ref.watch(taskMessagesProvider(widget.taskId));
final profilesAsync = ref.watch(profilesProvider);
final task = _findTask(tasksAsync, widget.taskId);
if (task == null) {
return const ResponsiveBody(
child: Center(child: Text('Task not found.')),
);
}
final ticketId = task.ticketId;
final typingChannelId = task.id;
final ticket = ticketId == null
? null
: _findTicket(ticketsAsync, ticketId);
final officeById = {
for (final office in officesAsync.valueOrNull ?? []) office.id: office,
};
final officeId = ticket?.officeId ?? task.officeId;
final officeName = officeId == null
? 'Unassigned office'
: (officeById[officeId]?.name ?? officeId);
final description = ticket?.description ?? task.description;
final canAssign = profileAsync.maybeWhen(
data: (profile) => profile != null && _canAssignStaff(profile.role),
orElse: () => false,
);
final showAssign = canAssign && task.status != 'completed';
final assignments = assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
final canUpdateStatus = _canUpdateStatus(
profileAsync.valueOrNull,
assignments,
task.id,
);
final typingState = ref.watch(typingIndicatorProvider(typingChannelId));
final canSendMessages = task.status != 'completed';
final messagesAsync = _mergeMessages(
taskMessagesAsync,
ticketId == null ? null : ref.watch(ticketMessagesProvider(ticketId)),
);
return ResponsiveBody(
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= AppBreakpoints.desktop;
final detailsContent = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: Alignment.center,
child: Text(
task.title.isNotEmpty ? task.title : 'Task ${task.id}',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.center,
child: Text(
_createdByLabel(profilesAsync, task, ticket),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_buildStatusChip(context, task, canUpdateStatus),
_MetaBadge(label: 'Office', value: officeName),
_MetaBadge(label: 'Task ID', value: task.id, isMono: true),
],
),
if (description.isNotEmpty) ...[
const SizedBox(height: 12),
Text(description),
],
const SizedBox(height: 12),
_buildTatSection(task),
const SizedBox(height: 16),
TaskAssignmentSection(taskId: task.id, canAssign: showAssign),
],
);
final detailsCard = Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(child: detailsContent),
),
);
// Tabbed area: Chat + Activity
final tabbedCard = Card(
child: DefaultTabController(
length: 2,
child: Column(
children: [
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'),
],
),
),
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: messagesAsync.when(
data: (messages) => _buildMessages(
context,
messages,
profilesAsync.valueOrNull ?? [],
),
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, _) => Center(
child: Text(
'Failed to load messages: $error',
),
),
),
),
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 ?? [],
),
),
],
),
),
],
),
),
);
if (isWide) {
return Row(
children: [
Expanded(flex: 2, child: detailsCard),
const SizedBox(width: 16),
Expanded(flex: 3, child: tabbedCard),
],
);
}
return Column(
children: [
detailsCard,
const SizedBox(height: 12),
Expanded(child: tabbedCard),
],
);
},
),
);
}
String _createdByLabel(
AsyncValue<List<Profile>> profilesAsync,
Task task,
Ticket? ticket,
) {
final creatorId = task.creatorId ?? ticket?.creatorId;
if (creatorId == null || creatorId.isEmpty) {
return 'Created by: Unknown';
}
final profile = profilesAsync.valueOrNull
?.where((item) => item.id == creatorId)
.firstOrNull;
final name = profile?.fullName.isNotEmpty == true
? profile!.fullName
: creatorId;
return 'Created by: $name';
}
Widget _buildMessages(
BuildContext context,
List<TicketMessage> messages,
List<Profile> profiles,
) {
if (messages.isEmpty) {
return const Center(child: Text('No messages yet.'));
}
final profileById = {for (final profile in profiles) profile.id: profile};
final currentUserId = ref.read(currentUserIdProvider);
return ListView.builder(
reverse: true,
padding: const EdgeInsets.fromLTRB(0, 16, 0, 72),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final isMe = currentUserId != null && message.senderId == currentUserId;
final senderName = message.senderId == null
? 'System'
: profileById[message.senderId]?.fullName ?? message.senderId!;
final bubbleColor = isMe
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest;
final textColor = isMe
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSurface;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Column(
crossAxisAlignment: isMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (!isMe)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
senderName,
style: Theme.of(context).textTheme.labelSmall,
),
),
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(maxWidth: 520),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(
AppSurfaces.of(context).cardRadius,
),
topRight: Radius.circular(
AppSurfaces.of(context).cardRadius,
),
bottomLeft: Radius.circular(
isMe ? AppSurfaces.of(context).cardRadius : 4,
),
bottomRight: Radius.circular(
isMe ? 4 : AppSurfaces.of(context).cardRadius,
),
),
),
child: _buildMentionText(message.content, textColor, profiles),
),
],
),
);
},
);
}
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;
if (!animateQueue && !animateExecution) {
return _buildTatContent(task, AppTime.now());
}
return StreamBuilder<int>(
stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
builder: (context, snapshot) {
return _buildTatContent(task, AppTime.now());
},
);
}
Widget _buildTatContent(Task task, DateTime now) {
final queueDuration = task.status == 'queued'
? now.difference(task.createdAt)
: _safeDuration(task.startedAt?.difference(task.createdAt));
final executionDuration = task.status == 'queued'
? null
: task.startedAt == null
? null
: task.completedAt == null
? now.difference(task.startedAt!)
: task.completedAt!.difference(task.startedAt!);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Queue duration: ${_formatDuration(queueDuration)}'),
const SizedBox(height: 8),
Text('Task execution time: ${_formatDuration(executionDuration)}'),
],
);
}
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;
}
return duration.isNegative ? Duration.zero : duration;
}
String _formatDuration(Duration? duration) {
if (duration == null) {
return 'Pending';
}
if (duration.inSeconds < 60) {
return 'Less than a minute';
}
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}
Widget _buildMentionText(
String text,
Color baseColor,
List<Profile> profiles,
) {
final mentionColor = Theme.of(context).colorScheme.primary;
final spans = _mentionSpans(text, baseColor, mentionColor, profiles);
return RichText(
text: TextSpan(
children: spans,
style: TextStyle(color: baseColor),
),
);
}
List<TextSpan> _mentionSpans(
String text,
Color baseColor,
Color mentionColor,
List<Profile> profiles,
) {
final mentionLabels = profiles
.map(
(profile) => profile.fullName.isEmpty ? profile.id : profile.fullName,
)
.where((label) => label.isNotEmpty)
.map(_escapeRegExp)
.toList();
final pattern = mentionLabels.isEmpty
? r'@\S+'
: '@(?:${mentionLabels.join('|')})';
final matches = RegExp(pattern, caseSensitive: false).allMatches(text);
if (matches.isEmpty) {
return [
TextSpan(
text: text,
style: TextStyle(color: baseColor),
),
];
}
final spans = <TextSpan>[];
var lastIndex = 0;
for (final match in matches) {
if (match.start > lastIndex) {
spans.add(
TextSpan(
text: text.substring(lastIndex, match.start),
style: TextStyle(color: baseColor),
),
);
}
spans.add(
TextSpan(
text: text.substring(match.start, match.end),
style: TextStyle(color: mentionColor, fontWeight: FontWeight.w700),
),
);
lastIndex = match.end;
}
if (lastIndex < text.length) {
spans.add(
TextSpan(
text: text.substring(lastIndex),
style: TextStyle(color: baseColor),
),
);
}
return spans;
}
String _escapeRegExp(String value) {
return value.replaceAllMapped(
RegExp(r'[\\^$.*+?()[\]{}|]'),
(match) => '\\${match[0]}',
);
}
AsyncValue<List<TicketMessage>> _mergeMessages(
AsyncValue<List<TicketMessage>> taskMessages,
AsyncValue<List<TicketMessage>>? ticketMessages,
) {
if (ticketMessages == null) {
return taskMessages;
}
return taskMessages.when(
data: (taskData) => ticketMessages.when(
data: (ticketData) {
final byId = <int, TicketMessage>{
for (final message in taskData) message.id: message,
for (final message in ticketData) message.id: message,
};
final merged = byId.values.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return AsyncValue.data(merged);
},
loading: () => const AsyncLoading<List<TicketMessage>>(),
error: (error, stackTrace) =>
AsyncError<List<TicketMessage>>(error, stackTrace),
),
loading: () => const AsyncLoading<List<TicketMessage>>(),
error: (error, stackTrace) =>
AsyncError<List<TicketMessage>>(error, stackTrace),
);
}
Future<void> _handleSendMessage(
Task task,
List<Profile> profiles,
String? currentUserId,
bool canSendMessages,
String typingChannelId,
) async {
if (!canSendMessages) return;
final content = _messageController.text.trim();
if (content.isEmpty) {
return;
}
// Safely stop typing — controller may have been auto-disposed by Riverpod.
final typingController = _maybeTypingController(typingChannelId);
typingController?.stopTyping();
final message = await ref
.read(ticketsControllerProvider)
.sendTaskMessage(
taskId: task.id,
ticketId: task.ticketId,
content: content,
);
final mentionUserIds = _extractMentionedUserIds(
content,
profiles,
currentUserId,
);
if (mentionUserIds.isNotEmpty && currentUserId != null) {
await ref
.read(notificationsControllerProvider)
.createMentionNotifications(
userIds: mentionUserIds,
actorId: currentUserId,
ticketId: task.ticketId,
taskId: task.id,
messageId: message.id,
);
}
ref.invalidate(taskMessagesProvider(task.id));
if (task.ticketId != null) {
ref.invalidate(ticketMessagesProvider(task.ticketId!));
}
if (mounted) {
_messageController.clear();
_clearMentions();
}
}
void _handleComposerChanged(
List<Profile> profiles,
String? currentUserId,
bool canSendMessages,
String typingChannelId,
) {
if (!canSendMessages) {
_maybeTypingController(typingChannelId)?.stopTyping();
_clearMentions();
return;
}
_maybeTypingController(typingChannelId)?.userTyping();
final text = _messageController.text;
final cursor = _messageController.selection.baseOffset;
if (cursor < 0) {
_clearMentions();
return;
}
final textBeforeCursor = text.substring(0, cursor);
final atIndex = textBeforeCursor.lastIndexOf('@');
if (atIndex == -1) {
_clearMentions();
return;
}
if (atIndex > 0 && !_isWhitespace(textBeforeCursor[atIndex - 1])) {
_clearMentions();
return;
}
final query = textBeforeCursor.substring(atIndex + 1);
if (query.contains(RegExp(r'\s'))) {
_clearMentions();
return;
}
final normalizedQuery = query.toLowerCase();
final candidates = profiles.where((profile) {
if (profile.id == currentUserId) {
return false;
}
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
return label.toLowerCase().contains(normalizedQuery);
}).toList();
setState(() {
_mentionQuery = query;
_mentionStart = atIndex;
_mentionResults = candidates.take(6).toList();
});
}
void _clearMentions() {
if (_mentionQuery == null && _mentionResults.isEmpty) {
return;
}
setState(() {
_mentionQuery = null;
_mentionStart = null;
_mentionResults = [];
});
}
// 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;
}
List<String> _extractMentionedUserIds(
String content,
List<Profile> profiles,
String? currentUserId,
) {
final lower = content.toLowerCase();
final mentioned = <String>{};
for (final profile in profiles) {
if (profile.id == currentUserId) continue;
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
if (label.isEmpty) continue;
final token = '@${label.toLowerCase()}';
if (lower.contains(token)) {
mentioned.add(profile.id);
}
}
return mentioned.toList();
}
Widget _buildMentionList(AsyncValue<List<Profile>> profilesAsync) {
if (_mentionResults.isEmpty) {
return const SizedBox.shrink();
}
return Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(
AppSurfaces.of(context).compactCardRadius,
),
),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _mentionResults.length,
separatorBuilder: (context, index) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final profile = _mentionResults[index];
final label = profile.fullName.isEmpty
? profile.id
: profile.fullName;
return ListTile(
dense: true,
title: Text(label),
onTap: () => _insertMention(profile),
);
},
),
);
}
void _insertMention(Profile profile) {
final start = _mentionStart;
if (start == null) {
_clearMentions();
return;
}
final text = _messageController.text;
final cursor = _messageController.selection.baseOffset;
final end = cursor < 0 ? text.length : cursor;
final label = profile.fullName.isEmpty ? profile.id : profile.fullName;
final mentionText = '@$label ';
final updated = text.replaceRange(start, end, mentionText);
final newCursor = start + mentionText.length;
_messageController.text = updated;
_messageController.selection = TextSelection.collapsed(offset: newCursor);
_clearMentions();
}
String _typingLabel(
Set<String> userIds,
AsyncValue<List<Profile>> profilesAsync,
) {
final profileById = {
for (final profile in profilesAsync.valueOrNull ?? [])
profile.id: profile,
};
final names = userIds
.map((id) => profileById[id]?.fullName ?? id)
.where((name) => name.isNotEmpty)
.toList();
if (names.isEmpty) {
return 'Someone is typing...';
}
if (names.length == 1) {
return '${names.first} is typing...';
}
if (names.length == 2) {
return '${names[0]} and ${names[1]} are typing...';
}
return '${names[0]}, ${names[1]} and others are typing...';
}
Task? _findTask(AsyncValue<List<Task>> tasksAsync, String taskId) {
return tasksAsync.maybeWhen(
data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull,
orElse: () => null,
);
}
Ticket? _findTicket(AsyncValue<List<Ticket>> ticketsAsync, String ticketId) {
return ticketsAsync.maybeWhen(
data: (tickets) =>
tickets.where((ticket) => ticket.id == ticketId).firstOrNull,
orElse: () => null,
);
}
bool _canAssignStaff(String role) {
return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
}
Widget _buildStatusChip(
BuildContext context,
Task task,
bool canUpdateStatus,
) {
final chip = StatusPill(
label: task.status.toUpperCase(),
isEmphasized: task.status != 'queued',
);
if (!canUpdateStatus) {
return chip;
}
return PopupMenuButton<String>(
onSelected: (value) async {
await ref
.read(tasksControllerProvider)
.updateTaskStatus(taskId: task.id, status: value);
ref.invalidate(tasksProvider);
},
itemBuilder: (context) => _statusOptions
.map(
(status) => PopupMenuItem(
value: status,
child: Text(_statusMenuLabel(status)),
),
)
.toList(),
child: chip,
);
}
String _statusMenuLabel(String status) {
return switch (status) {
'queued' => 'Queued',
'in_progress' => 'In progress',
'completed' => 'Completed',
_ => status,
};
}
bool _canUpdateStatus(
Profile? profile,
List<TaskAssignment> assignments,
String taskId,
) {
if (profile == null) {
return false;
}
final isGlobal =
profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
if (isGlobal) {
return true;
}
return assignments.any(
(assignment) =>
assignment.taskId == taskId && assignment.userId == profile.id,
);
}
}
class _MetaBadge extends StatelessWidget {
const _MetaBadge({required this.label, required this.value, this.isMono});
final String label;
final String value;
final bool? isMono;
@override
Widget build(BuildContext context) {
final border = Theme.of(context).colorScheme.outlineVariant;
final background = Theme.of(context).colorScheme.surfaceContainerLow;
final textStyle = Theme.of(context).textTheme.labelSmall;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: textStyle),
const SizedBox(width: 6),
if (isMono == true)
MonoText(value, style: textStyle)
else
Text(value, style: textStyle),
],
),
);
}
}
extension _FirstOrNull<T> on Iterable<T> {
T? get firstOrNull => isEmpty ? null : first;
}