Initial commit
This commit is contained in:
@@ -0,0 +1,822 @@
|
||||
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/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 '../../widgets/responsive_body.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: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: 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: 8),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_buildStatusChip(context, task, canUpdateStatus),
|
||||
Text('Office: $officeName'),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 4),
|
||||
bottomRight: Radius.circular(isMe ? 4 : 16),
|
||||
),
|
||||
),
|
||||
child: _buildMentionText(message.content, textColor, profiles),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTatSection(Task task) {
|
||||
final animateQueue = task.status == 'queued';
|
||||
final animateExecution = task.startedAt != null && task.completedAt == null;
|
||||
|
||||
if (!animateQueue && !animateExecution) {
|
||||
return _buildTatContent(task, DateTime.now());
|
||||
}
|
||||
|
||||
return StreamBuilder<int>(
|
||||
stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
|
||||
builder: (context, snapshot) {
|
||||
return _buildTatContent(task, DateTime.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)}'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
ref.read(typingIndicatorProvider(typingChannelId).notifier).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) {
|
||||
ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping();
|
||||
_clearMentions();
|
||||
return;
|
||||
}
|
||||
ref.read(typingIndicatorProvider(typingChannelId).notifier).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 = [];
|
||||
});
|
||||
}
|
||||
|
||||
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(12),
|
||||
),
|
||||
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 = Chip(
|
||||
label: Text(task.status.toUpperCase()),
|
||||
backgroundColor: _statusColor(context, task.status),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusTextColor(context, task.status),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Color _statusColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'queued' => Colors.blueGrey.shade200,
|
||||
'in_progress' => Colors.blue.shade300,
|
||||
'completed' => Colors.green.shade300,
|
||||
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
};
|
||||
}
|
||||
|
||||
Color _statusTextColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'queued' => Colors.blueGrey.shade900,
|
||||
'in_progress' => Colors.blue.shade900,
|
||||
'completed' => Colors.green.shade900,
|
||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
Reference in New Issue
Block a user