Added saving indicator when assigning a task

This commit is contained in:
Marc Rejohn Castillano 2026-02-24 21:49:25 +08:00
parent 5979a04254
commit 354b27aad1
2 changed files with 343 additions and 55 deletions

View File

@ -5,6 +5,7 @@ import '../models/profile.dart';
import '../providers/profile_provider.dart';
import '../providers/tasks_provider.dart';
import '../theme/app_surfaces.dart';
import '../utils/snackbar.dart';
class TaskAssignmentSection extends ConsumerWidget {
const TaskAssignmentSection({
@ -39,36 +40,11 @@ class TaskAssignmentSection extends ConsumerWidget {
.toList();
final assignedIds = assignedForTask.map((a) => a.userId).toSet();
final activeTaskIds = tasks
.where(
(task) => task.status == 'queued' || task.status == 'in_progress',
)
.map((task) => task.id)
.toSet();
final activeAssignmentsByUser = <String, Set<String>>{};
for (final assignment in assignments) {
if (!activeTaskIds.contains(assignment.taskId)) {
continue;
}
activeAssignmentsByUser
.putIfAbsent(assignment.userId, () => <String>{})
.add(assignment.taskId);
}
bool isVacant(String userId) {
final active = activeAssignmentsByUser[userId];
if (active == null || active.isEmpty) {
return true;
}
return active.length == 1 && active.contains(taskId);
}
final eligibleStaff = itStaff
.where(
(profile) => isVacant(profile.id) || assignedIds.contains(profile.id),
)
.toList();
// With concurrent assignments allowed we no longer restrict the
// eligible list based on whether the staff member already has an active
// task. All IT staff can be assigned to any number of tasks at once. Keep
// the original sort order derived from itStaff rather than filtering.
final eligibleStaff = List<Profile>.from(itStaff);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -137,6 +113,9 @@ class TaskAssignmentSection extends ConsumerWidget {
Set<String> assignedIds,
String? taskTicketId,
) async {
// If there are no IT staff at all we still need to bail out. We don't
// consider vacancy anymore because everyone is eligible, so the only
// reason for the dialog to be unusable is an empty staff list.
if (eligibleStaff.isEmpty && assignedIds.isEmpty) {
await showDialog<void>(
context: context,
@ -144,7 +123,7 @@ class TaskAssignmentSection extends ConsumerWidget {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Assign IT Staff'),
content: const Text('No vacant IT staff available.'),
content: const Text('No IT staff available.'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
@ -161,6 +140,7 @@ class TaskAssignmentSection extends ConsumerWidget {
await showDialog<void>(
context: context,
builder: (dialogContext) {
var isSaving = false;
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
@ -187,7 +167,9 @@ class TaskAssignmentSection extends ConsumerWidget {
horizontal: 12,
vertical: 2,
),
onChanged: (value) {
onChanged: isSaving
? null
: (value) {
setState(() {
if (value == true) {
selection.add(staff.id);
@ -203,11 +185,17 @@ class TaskAssignmentSection extends ConsumerWidget {
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
onPressed: isSaving
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
onPressed: isSaving
? null
: () async {
setState(() => isSaving = true);
try {
await ref
.read(taskAssignmentsControllerProvider)
.replaceAssignments(
@ -216,11 +204,40 @@ class TaskAssignmentSection extends ConsumerWidget {
newUserIds: selection.toList(),
currentUserIds: assignedIds.toList(),
);
if (context.mounted) {
showSuccessSnackBar(
context,
'Assignment saved successfully',
);
}
if (context.mounted) {
Navigator.of(dialogContext).pop();
}
} catch (e) {
if (context.mounted) {
showErrorSnackBar(
context,
'Failed to save assignment',
);
}
} finally {
if (context.mounted) {
setState(() => isSaving = false);
}
}
},
child: const Text('Save'),
child: isSaving
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(
Theme.of(context).colorScheme.onPrimary,
),
),
)
: const Text('Save'),
),
],
);

View File

@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';
import 'package:tasq/models/profile.dart';
import 'package:tasq/models/task.dart';
import 'package:tasq/models/task_assignment.dart';
import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/providers/tasks_provider.dart';
import 'package:tasq/utils/snackbar.dart';
import 'package:tasq/utils/app_time.dart';
import 'package:tasq/widgets/task_assignment_section.dart';
void main() {
setUp(() {
AppTime.initialize();
});
testWidgets(
'allows assigning IT staff to a task even when they are already on another active task',
(tester) async {
// prepare two it_staff profiles
final profile1 = Profile(
id: 'u1',
fullName: 'User One',
role: 'it_staff',
);
final profile2 = Profile(
id: 'u2',
fullName: 'User Two',
role: 'it_staff',
);
// create two active tasks; staff u1 is already assigned to the first
final now = DateTime.now();
final task1 = Task.fromMap({
'id': 't1',
'status': 'queued',
'title': 'First task',
'description': '',
'created_at': now.toIso8601String(),
'priority': 1,
});
final task2 = Task.fromMap({
'id': 't2',
'status': 'queued',
'title': 'Second task',
'description': '',
'created_at': now.toIso8601String(),
'priority': 1,
});
final assignment1 = TaskAssignment(
taskId: 't1',
userId: 'u1',
createdAt: now,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
profilesProvider.overrideWith(
(ref) => Stream.value([profile1, profile2]),
),
tasksProvider.overrideWith((ref) => Stream.value([task1, task2])),
taskAssignmentsProvider.overrideWith(
(ref) => Stream.value([assignment1]),
),
],
child: MaterialApp(
home: Scaffold(
body: TaskAssignmentSection(taskId: 't2', canAssign: true),
),
),
),
);
// allow provider streams to deliver their first value
await tester.pumpAndSettle();
// open the assignment dialog
await tester.tap(find.text('Assign'));
await tester.pumpAndSettle();
// both IT staff users should be listed even though u1 is already busy
expect(find.byType(CheckboxListTile), findsNWidgets(2));
expect(find.widgetWithText(CheckboxListTile, 'User One'), findsOneWidget);
expect(find.widgetWithText(CheckboxListTile, 'User Two'), findsOneWidget);
},
);
testWidgets('dialog shows message when there are no IT staff at all', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
profilesProvider.overrideWith(
(ref) => Stream.value(const <Profile>[]),
),
tasksProvider.overrideWith(
(ref) => Stream.value(<Task>[
Task.fromMap({
'id': 't',
'status': 'queued',
'title': 't',
'description': '',
'created_at': DateTime.now().toIso8601String(),
'priority': 1,
}),
]),
),
taskAssignmentsProvider.overrideWith(
(ref) => Stream.value(const <TaskAssignment>[]),
),
],
child: MaterialApp(
home: Scaffold(
body: TaskAssignmentSection(taskId: 't', canAssign: true),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Assign'));
await tester.pumpAndSettle();
expect(find.text('No IT staff available.'), findsOneWidget);
});
testWidgets('save button shows spinner and snack on success', (tester) async {
final profile = Profile(id: 'u1', fullName: 'User One', role: 'it_staff');
final task = Task.fromMap({
'id': 't',
'status': 'queued',
'title': 't',
'description': '',
'created_at': DateTime.now().toIso8601String(),
'priority': 1,
});
final completer = Completer<void>();
await tester.pumpWidget(
ProviderScope(
overrides: [
profilesProvider.overrideWith((ref) => Stream.value([profile])),
tasksProvider.overrideWith((ref) => Stream.value([task])),
taskAssignmentsProvider.overrideWith(
(ref) => Stream.value(const <TaskAssignment>[]),
),
taskAssignmentsControllerProvider.overrideWith(
(ref) => _DelayedController(completer),
),
],
child: MaterialApp(
home: Scaffold(
body: TaskAssignmentSection(taskId: 't', canAssign: true),
),
),
),
);
await tester.pumpAndSettle();
// open dialog and select the only staff
await tester.tap(find.text('Assign'));
await tester.pumpAndSettle();
await tester.tap(find.byType(CheckboxListTile));
await tester.pump();
// tap save, spinner should appear
await tester.tap(find.text('Save'));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// resolve future and let dialog close
completer.complete();
await tester.pumpAndSettle();
// snackbar with success message should be visible
expect(find.text('Assignment saved successfully'), findsOneWidget);
});
testWidgets('error snackbar shows when save fails', (tester) async {
final profile = Profile(id: 'u1', fullName: 'User One', role: 'it_staff');
final task = Task.fromMap({
'id': 't',
'status': 'queued',
'title': 't',
'description': '',
'created_at': DateTime.now().toIso8601String(),
'priority': 1,
});
await tester.pumpWidget(
ProviderScope(
overrides: [
profilesProvider.overrideWith((ref) => Stream.value([profile])),
tasksProvider.overrideWith((ref) => Stream.value([task])),
taskAssignmentsProvider.overrideWith(
(ref) => Stream.value(const <TaskAssignment>[]),
),
taskAssignmentsControllerProvider.overrideWith(
(ref) => _FailingController(),
),
],
child: MaterialApp(
home: Scaffold(
body: TaskAssignmentSection(taskId: 't', canAssign: true),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Assign'));
await tester.pumpAndSettle();
await tester.tap(find.byType(CheckboxListTile));
await tester.pump();
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Failed to save assignment'), findsOneWidget);
});
}
// helper fakes used by the tests below
class _DelayedController implements TaskAssignmentsController {
_DelayedController(this._completer);
final Completer<void> _completer;
@override
Future<void> replaceAssignments({
required String taskId,
required String? ticketId,
required List<String> newUserIds,
required List<String> currentUserIds,
}) {
return _completer.future;
}
@override
Future<void> removeAssignment({
required String taskId,
required String userId,
}) {
// not needed for these tests
return Future.value();
}
}
class _FailingController implements TaskAssignmentsController {
@override
Future<void> replaceAssignments({
required String taskId,
required String? ticketId,
required List<String> newUserIds,
required List<String> currentUserIds,
}) async {
throw Exception('server error');
}
@override
Future<void> removeAssignment({
required String taskId,
required String userId,
}) {
return Future.value();
}
}