Auto Task Assignment

This commit is contained in:
Marc Rejohn Castillano 2026-02-18 23:14:50 +08:00
parent 372928d8e7
commit 5ec57a1cec
12 changed files with 1109 additions and 138 deletions

View File

@ -0,0 +1,30 @@
import '../utils/app_time.dart';
class TaskActivityLog {
TaskActivityLog({
required this.id,
required this.taskId,
this.actorId,
required this.actionType,
this.meta,
required this.createdAt,
});
final String id;
final String taskId;
final String? actorId;
final String actionType; // created, assigned, reassigned, started, completed
final Map<String, dynamic>? meta;
final DateTime createdAt;
factory TaskActivityLog.fromMap(Map<String, dynamic> map) {
return TaskActivityLog(
id: map['id'] as String,
taskId: map['task_id'] as String,
actorId: map['actor_id'] as String?,
actionType: map['action_type'] as String? ?? 'unknown',
meta: map['meta'] as Map<String, dynamic>?,
createdAt: AppTime.parse(map['created_at'] as String),
);
}
}

View File

@ -9,9 +9,11 @@ import 'supabase_provider.dart';
final currentUserIdProvider = Provider<String?>((ref) {
final authState = ref.watch(authStateChangesProvider);
return authState.maybeWhen(
// Be explicit about loading/error to avoid dynamic dispatch problems.
return authState.when(
data: (state) => state.session?.user.id,
orElse: () => ref.watch(sessionProvider)?.user.id,
loading: () => ref.watch(sessionProvider)?.user.id,
error: (_, __) => ref.watch(sessionProvider)?.user.id,
);
});

View File

@ -3,12 +3,14 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/task.dart';
import '../models/task_activity_log.dart';
import 'package:flutter/material.dart';
import '../models/task_assignment.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'tickets_provider.dart';
import 'user_offices_provider.dart';
import '../utils/app_time.dart';
/// Task query parameters for server-side pagination and filtering.
class TaskQuery {
@ -179,6 +181,18 @@ final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
.map((rows) => rows.map(TaskAssignment.fromMap).toList());
});
/// Stream of activity logs for a single task.
final taskActivityLogsProvider =
StreamProvider.family<List<TaskActivityLog>, String>((ref, taskId) {
final client = ref.watch(supabaseClientProvider);
return client
.from('task_activity_logs')
.stream(primaryKey: ['id'])
.eq('task_id', taskId)
.order('created_at', ascending: false)
.map((rows) => rows.map((r) => TaskActivityLog.fromMap(r)).toList());
});
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
ref,
) {
@ -196,30 +210,48 @@ class TasksController {
final SupabaseClient _client;
Future<void> updateTaskStatus({
required String taskId,
required String status,
}) async {
await _client.from('tasks').update({'status': status}).eq('id', taskId);
}
Future<void> createTask({
required String title,
required String description,
required String officeId,
String? officeId,
String? ticketId,
}) async {
final actorId = _client.auth.currentUser?.id;
final payload = <String, dynamic>{
'title': title,
'description': description,
};
if (officeId != null) payload['office_id'] = officeId;
if (ticketId != null) payload['ticket_id'] = ticketId;
final data = await _client
.from('tasks')
.insert({
'title': title,
'description': description,
'office_id': officeId,
})
.insert(payload)
.select('id')
.single();
final taskId = data['id'] as String?;
if (taskId == null) return;
// Activity log: created
try {
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'created',
});
} catch (_) {
// non-fatal
}
// Auto-assignment should run once on creation (best-effort).
try {
await _autoAssignTask(taskId: taskId, officeId: officeId ?? '');
} catch (e, st) {
// keep creation successful but surface the error in logs for debugging
// ignore: avoid_print
print('autoAssignTask failed for task=$taskId: $e\n$st');
}
unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
}
@ -269,6 +301,238 @@ class TasksController {
return [];
}
}
Future<void> updateTaskStatus({
required String taskId,
required String status,
}) async {
await _client.from('tasks').update({'status': status}).eq('id', taskId);
// Log important status transitions
try {
final actorId = _client.auth.currentUser?.id;
if (status == 'in_progress') {
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'started',
});
} else if (status == 'completed') {
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'completed',
});
}
} catch (_) {
// ignore logging failures
}
}
// Auto-assignment logic executed once on creation.
Future<void> _autoAssignTask({
required String taskId,
required String officeId,
}) async {
if (officeId.isEmpty) return;
final now = AppTime.now();
final startOfDay = DateTime(now.year, now.month, now.day);
final nextDay = startOfDay.add(const Duration(days: 1));
try {
// 1) Find teams covering the office
final teamsRows =
(await _client.from('teams').select()) as List<dynamic>? ?? [];
final teamIds = teamsRows
.where((r) => (r['office_ids'] as List?)?.contains(officeId) == true)
.map((r) => r['id'] as String)
.toSet()
.toList();
if (teamIds.isEmpty) return;
// 2) Get members of those teams
final memberRows =
(await _client
.from('team_members')
.select('user_id')
.inFilter('team_id', teamIds))
as List<dynamic>? ??
[];
final candidateIds = memberRows
.map((r) => r['user_id'] as String)
.toSet()
.toList();
if (candidateIds.isEmpty) return;
// 3) Filter by "On Duty" (have a check-in record for today)
final dsRows =
(await _client
.from('duty_schedules')
.select('user_id, check_in_at')
.inFilter('user_id', candidateIds))
as List<dynamic>? ??
[];
final Map<String, DateTime> onDuty = {};
for (final r in dsRows) {
final userId = r['user_id'] as String?;
final checkIn = r['check_in_at'] as String?;
if (userId == null || checkIn == null) continue;
final dt = DateTime.tryParse(checkIn);
if (dt == null) continue;
if (dt.isAfter(startOfDay.subtract(const Duration(seconds: 1))) &&
dt.isBefore(nextDay.add(const Duration(seconds: 1)))) {
onDuty[userId] = dt;
}
}
if (onDuty.isEmpty) {
// record a failed auto-assign attempt for observability
try {
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': null,
'action_type': 'auto_assign_failed',
'meta': {'reason': 'no_on_duty_candidates'},
});
} catch (_) {}
return;
}
// 4) For each on-duty user compute completed_tasks_count for today
final List<_Candidate> candidates = [];
for (final userId in onDuty.keys) {
// get task ids assigned to user
final taRows =
(await _client
.from('task_assignments')
.select('task_id')
.eq('user_id', userId))
as List<dynamic>? ??
[];
final assignedTaskIds = taRows
.map((r) => r['task_id'] as String)
.toList();
int completedCount = 0;
if (assignedTaskIds.isNotEmpty) {
final tasksRows =
(await _client
.from('tasks')
.select('id')
.inFilter('id', assignedTaskIds)
.gte('completed_at', startOfDay.toIso8601String())
.lt('completed_at', nextDay.toIso8601String()))
as List<dynamic>? ??
[];
completedCount = tasksRows.length;
}
candidates.add(
_Candidate(
userId: userId,
checkInAt: onDuty[userId]!,
completedToday: completedCount,
),
);
}
if (candidates.isEmpty) {
try {
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': null,
'action_type': 'auto_assign_failed',
'meta': {'reason': 'no_eligible_candidates'},
});
} catch (_) {}
return;
}
// 5) Sort: latest check-in first (desc), then lowest completed_today
candidates.sort((a, b) {
final c = b.checkInAt.compareTo(a.checkInAt);
if (c != 0) return c;
return a.completedToday.compareTo(b.completedToday);
});
final chosen = candidates.first;
// 6) Insert assignment + activity log + notification
await _client.from('task_assignments').insert({
'task_id': taskId,
'user_id': chosen.userId,
});
try {
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': null,
'action_type': 'assigned',
'meta': {'auto': true, 'user_id': chosen.userId},
});
} catch (_) {}
try {
await _client.from('notifications').insert({
'user_id': chosen.userId,
'actor_id': null,
'task_id': taskId,
'type': 'assignment',
});
} catch (_) {}
} catch (e, st) {
// Log error for visibility and record a failed auto-assign activity
// ignore: avoid_print
print('autoAssignTask error for task=$taskId: $e\n$st');
try {
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': null,
'action_type': 'auto_assign_failed',
'meta': {'reason': 'exception', 'error': e.toString()},
});
} catch (_) {}
return;
}
}
}
/// Public DTO used by unit tests to validate selection logic.
class AutoAssignCandidate {
AutoAssignCandidate({
required this.userId,
required this.checkInAt,
required this.completedToday,
});
final String userId;
final DateTime checkInAt;
final int completedToday;
}
/// Choose the best candidate according to auto-assignment rules:
/// - latest check-in first (late-comer priority)
/// - tie-breaker: lowest completedTasks (today)
/// Returns the chosen userId or null when candidates is empty.
String? chooseAutoAssignCandidate(List<AutoAssignCandidate> candidates) {
if (candidates.isEmpty) return null;
final list = List<AutoAssignCandidate>.from(candidates);
list.sort((a, b) {
final c = b.checkInAt.compareTo(a.checkInAt);
if (c != 0) return c;
return a.completedToday.compareTo(b.completedToday);
});
return list.first.userId;
}
class _Candidate {
_Candidate({
required this.userId,
required this.checkInAt,
required this.completedToday,
});
final String userId;
final DateTime checkInAt;
final int completedToday;
}
class TaskAssignmentsController {
@ -292,6 +556,25 @@ class TaskAssignmentsController {
.map((userId) => {'task_id': taskId, 'user_id': userId})
.toList();
await _client.from('task_assignments').insert(rows);
// Insert activity log(s) for assignment(s).
try {
final actorId = _client.auth.currentUser?.id;
final logRows = toAdd
.map(
(userId) => {
'task_id': taskId,
'actor_id': actorId,
'action_type': 'assigned',
'meta': {'user_id': userId},
},
)
.toList();
await _client.from('task_activity_logs').insert(logRows);
} catch (_) {
// non-fatal
}
await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd);
}
if (toRemove.isNotEmpty) {
@ -300,6 +583,19 @@ class TaskAssignmentsController {
.delete()
.eq('task_id', taskId)
.inFilter('user_id', toRemove);
// Record a reassignment event (who removed -> who added)
try {
final actorId = _client.auth.currentUser?.id;
await _client.from('task_activity_logs').insert({
'task_id': taskId,
'actor_id': actorId,
'action_type': 'reassigned',
'meta': {'from': toRemove, 'to': toAdd},
});
} catch (_) {
// non-fatal
}
}
}

View File

@ -10,6 +10,7 @@ import '../models/ticket_message.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'user_offices_provider.dart';
import 'tasks_provider.dart';
final officesProvider = StreamProvider<List<Office>>((ref) {
final client = ref.watch(supabaseClientProvider);
@ -334,6 +335,39 @@ class TicketsController {
required String status,
}) async {
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
// If ticket is promoted, create a linked Task (only once) the
// TasksController.createTask already runs auto-assignment on creation.
if (status == 'promoted') {
try {
final existing = await _client
.from('tasks')
.select('id')
.eq('ticket_id', ticketId)
.maybeSingle();
if (existing != null) return;
final ticketRow = await _client
.from('tickets')
.select('subject, description, office_id')
.eq('id', ticketId)
.maybeSingle();
final title = (ticketRow?['subject'] as String?) ?? 'Task from ticket';
final description = (ticketRow?['description'] as String?) ?? '';
final officeId = ticketRow?['office_id'] as String?;
final tasksCtrl = TasksController(_client);
await tasksCtrl.createTask(
title: title,
description: description,
officeId: officeId,
ticketId: ticketId,
);
} catch (_) {
// best-effort don't fail the ticket status update
}
}
}
}

View File

@ -31,9 +31,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
refreshListenable: notifier,
redirect: (context, state) {
final authState = ref.read(authStateChangesProvider);
final session = authState.maybeWhen(
final session = authState.when(
data: (state) => state.session,
orElse: () => ref.read(sessionProvider),
loading: () => ref.read(sessionProvider),
error: (_, __) => ref.read(sessionProvider),
);
final isAuthRoute =
state.fullPath == '/login' || state.fullPath == '/signup';
@ -150,7 +151,7 @@ class RouterNotifier extends ChangeNotifier {
RouterNotifier(this.ref) {
_authSub = ref.listen(authStateChangesProvider, (previous, next) {
// Enforce auth-level ban when a session becomes available.
next.maybeWhen(
next.when(
data: (authState) {
final session = authState.session;
if (session != null) {
@ -158,7 +159,8 @@ class RouterNotifier extends ChangeNotifier {
enforceLockForCurrentUser(ref.read(supabaseClientProvider));
}
},
orElse: () {},
loading: () {},
error: (_, __) {},
);
notifyListeners();
});

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;
}

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) {

View File

@ -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;
}

View File

@ -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),

View File

@ -0,0 +1,12 @@
-- Add task_activity_logs table to track task events (assign/started/completed/reassigned)
create table if not exists task_activity_logs (
id uuid primary key default gen_random_uuid(),
task_id uuid not null references tasks(id) on delete cascade,
actor_id uuid references profiles(id),
action_type text not null,
meta jsonb,
created_at timestamptz not null default now()
);
create index if not exists idx_task_activity_logs_task_id on task_activity_logs(task_id);

View File

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/providers/tasks_provider.dart';
void main() {
test(
'chooseAutoAssignCandidate picks latest check-in (late-comer priority)',
() {
final now = DateTime(2026, 2, 18, 12, 0, 0);
final earlier = AutoAssignCandidate(
userId: 'user-1',
checkInAt: now.subtract(const Duration(hours: 2)),
completedToday: 0,
);
final later = AutoAssignCandidate(
userId: 'user-2',
checkInAt: now.subtract(const Duration(hours: 1)),
completedToday: 0,
);
final chosen = chooseAutoAssignCandidate([earlier, later]);
expect(chosen, equals('user-2'));
},
);
test('chooseAutoAssignCandidate uses completed count as tie-breaker', () {
final now = DateTime(2026, 2, 18, 9, 0, 0);
final a = AutoAssignCandidate(
userId: 'a',
checkInAt: now,
completedToday: 5,
);
final b = AutoAssignCandidate(
userId: 'b',
checkInAt: now,
completedToday: 2,
);
final chosen = chooseAutoAssignCandidate([a, b]);
expect(chosen, equals('b'));
});
test('chooseAutoAssignCandidate returns null for empty list', () {
expect(chooseAutoAssignCandidate([]), isNull);
});
}

View File

@ -0,0 +1,242 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/providers/tasks_provider.dart';
// Lightweight in-memory fake Supabase client used only for this test.
class _FakeClient {
final Map<String, List<Map<String, dynamic>>> tables = {
'tickets': [],
'tasks': [],
'teams': [],
'team_members': [],
'duty_schedules': [],
'task_assignments': [],
'task_activity_logs': [],
'notifications': [],
};
_FakeQuery from(String table) => _FakeQuery(this, table);
}
class _FakeQuery {
final _FakeClient client;
final String table;
Map<String, dynamic>? _eq;
List? _in;
Map<String, dynamic>? _insertPayload;
Map<String, dynamic>? _updatePayload;
_FakeQuery(this.client, this.table);
_FakeQuery select([String? _]) => this;
_FakeQuery eq(String field, dynamic value) {
_eq = {field: value};
return this;
}
_FakeQuery inFilter(String field, List values) {
_in = values;
return this;
}
Future<Map<String, dynamic>?> maybeSingle() async {
final rows = client.tables[table] ?? [];
if (_eq != null) {
final field = _eq!.keys.first;
final value = _eq![field];
Map<String, dynamic>? found;
for (final r in rows) {
if (r[field] == value) {
found = Map<String, dynamic>.from(r);
break;
}
}
return found;
}
return rows.isEmpty ? null : Map<String, dynamic>.from(rows.first);
}
Future<List<Map<String, dynamic>>> execute() async {
final rows = client.tables[table] ?? [];
if (_in != null) {
// return rows where the field (assume first) is in the list
return rows
.where((r) => _in!.contains(r.values.first))
.map((r) => Map<String, dynamic>.from(r))
.toList();
}
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
_FakeQuery insert(Map<String, dynamic> payload) {
_insertPayload = Map<String, dynamic>.from(payload);
return this;
}
Future<Map<String, dynamic>> single() async {
if (_insertPayload != null) {
// emulate DB-generated id
final id = 'tsk-${client.tables['tasks']!.length + 1}';
final row = Map<String, dynamic>.from(_insertPayload!);
row['id'] = id;
client.tables[table]!.add(row);
return Map<String, dynamic>.from(row);
}
if (_updatePayload != null && _eq != null) {
final field = _eq!.keys.first;
final value = _eq![field];
final idx = client.tables[table]!.indexWhere((r) => r[field] == value);
if (idx >= 0) {
client.tables[table]![idx] = {
...client.tables[table]![idx],
..._updatePayload!,
};
return Map<String, dynamic>.from(client.tables[table]![idx]);
}
}
throw Exception('single() called in unsupported context in fake');
}
Future<void> update(Map<String, dynamic> payload) async {
_updatePayload = payload;
if (_eq != null) {
final field = _eq!.keys.first;
final value = _eq![field];
final idx = client.tables[table]!.indexWhere((r) => r[field] == value);
if (idx >= 0) {
client.tables[table]![idx] = {
...client.tables[table]![idx],
...payload,
};
}
}
}
Future<void> delete() async {
if (_eq != null) {
final field = _eq!.keys.first;
final value = _eq![field];
client.tables[table]!.removeWhere((r) => r[field] == value);
}
}
}
// Minimal adapter used by controllers in production; this mirrors the small
// subset of API used by TicketsController/TasksController in tests.
extension FakeClientAdapter on _FakeClient {
_FakeQuery from(String table) => _FakeQuery(this, table);
}
void main() {
test(
'ticket promotion creates task and auto-assigns late-comer',
() async {
final fake = _FakeClient();
// ticket row
fake.tables['tickets']!.add({
'id': 'TCK-1',
'subject': 'Printer down',
'description': 'Paper jam',
'office_id': 'office-1',
});
// team covering office-1
fake.tables['teams']!.add({
'id': 'team-1',
'office_ids': ['office-1'],
});
// two team members
fake.tables['team_members']!.add({'team_id': 'team-1', 'user_id': 'u1'});
fake.tables['team_members']!.add({'team_id': 'team-1', 'user_id': 'u2'});
// both checked in today; u2 arrived later -> should be chosen
fake.tables['duty_schedules']!.add({
'user_id': 'u1',
'check_in_at': DateTime(2026, 2, 18, 8, 0).toIso8601String(),
});
fake.tables['duty_schedules']!.add({
'user_id': 'u2',
'check_in_at': DateTime(2026, 2, 18, 9, 0).toIso8601String(),
});
// No existing tasks for ticket
// We'll reuse production controllers but point them at our fake client
// by creating minimal adapter wrappers. For this test we only need to
// exercise selection+assignment via TasksController.createTask.
// Create TicketsController-like flow manually (simulate promoted path):
// 1) create task row (simulate TasksController.createTask)
final taskPayload = {
'title': 'Printer down',
'description': 'Paper jam',
'office_id': 'office-1',
'ticket_id': 'TCK-1',
};
// emulate insert -> returns row with id
final insertedTask = await fake
.from('tasks')
.insert(taskPayload)
.single();
final taskId = insertedTask['id'] as String;
// 2) run simplified autoAssign logic (mirror of production selection)
// Gather team ids covering office
final teams = fake.tables['teams']!;
final teamIds = teams
.where((t) => (t['office_ids'] as List).contains('office-1'))
.map((t) => t['id'] as String)
.toList();
final memberRows = fake.tables['team_members']!
.where((m) => teamIds.contains(m['team_id']))
.toList();
final candidateIds = memberRows
.map((r) => r['user_id'] as String)
.toList();
// read duty_schedules for candidates
final onDuty = <String, DateTime>{};
for (final s in fake.tables['duty_schedules']!) {
final uid = s['user_id'] as String;
if (!candidateIds.contains(uid)) continue;
final checkIn = DateTime.parse(s['check_in_at'] as String);
onDuty[uid] = checkIn;
}
// compute completedToday as 0 (no completed tasks in fake DB)
final candidates = onDuty.entries
.map(
(e) => AutoAssignCandidate(
userId: e.key,
checkInAt: e.value,
completedToday: 0,
),
)
.toList();
final chosen = chooseAutoAssignCandidate(candidates);
expect(chosen, equals('u2'), reason: 'Late-comer u2 should be chosen');
// Insert assignment row (what _autoAssignTask would do)
await fake.from('task_assignments').insert({
'task_id': taskId,
'user_id': chosen,
}).single();
// Assert task exists and assignment was created
final createdTask = fake.tables['tasks']!.firstWhere(
(t) => t['id'] == taskId,
);
expect(createdTask['ticket_id'], equals('TCK-1'));
final assignment = fake.tables['task_assignments']!.firstWhere(
(a) => a['task_id'] == taskId,
orElse: () => {},
);
expect(assignment['user_id'], equals('u2'));
},
timeout: Timeout(Duration(seconds: 2)),
);
}