From 546c254326ae01c29e87a0cd255008cee24e26a8 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Tue, 24 Feb 2026 22:08:53 +0800 Subject: [PATCH] Allow task completion even if signatories are still empty, put an indicator for completed tasks with incomplete details. --- lib/models/task.dart | 13 ++++ lib/providers/tasks_provider.dart | 25 +++--- lib/screens/tasks/task_detail_screen.dart | 22 ++++++ lib/screens/tasks/tasks_list_screen.dart | 24 +++++- test/layout_smoke_test.dart | 92 ++++++++++++++++++++++- test/tasks_provider_test.dart | 59 +++++++++++++-- 6 files changed, 212 insertions(+), 23 deletions(-) diff --git a/lib/models/task.dart b/lib/models/task.dart index c505ab78..e6c9be4d 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -53,6 +53,19 @@ class Task { // JSON serialized rich text for action taken (Quill Delta JSON encoded) final String? actionTaken; + /// Helper that indicates whether a completed task still has missing + /// metadata such as signatories or action details. The parameter is used + /// by UI to surface a warning icon/banner when a task has been closed but + /// the user skipped filling out all fields. + bool get hasIncompleteDetails { + if (status != 'completed') return false; + bool empty(String? v) => v == null || v.trim().isEmpty; + return empty(requestedBy) || + empty(notedBy) || + empty(receivedBy) || + empty(actionTaken); + } + factory Task.fromMap(Map map) { return Task( id: map['id'] as String, diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 144f2bb8..29cced88 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -562,7 +562,9 @@ class TasksController { .from('tasks') // include all columns that must be non-null/empty before completing .select( - 'request_type, request_category, requested_by, noted_by, received_by, action_taken', + // signatories are not needed for validation; action_taken is still + // required so we include it alongside the type/category fields. + 'request_type, request_category, action_taken', ) .eq('id', taskId) .maybeSingle(); @@ -572,9 +574,6 @@ class TasksController { final rt = row['request_type']; final rc = row['request_category']; - final requested = row['requested_by']; - final noted = row['noted_by']; - final received = row['received_by']; final action = row['action_taken']; final missing = []; @@ -584,17 +583,13 @@ class TasksController { if (rc == null || (rc is String && rc.trim().isEmpty)) { missing.add('request category'); } - if (requested == null || - (requested is String && requested.trim().isEmpty)) { - missing.add('requested by'); - } - if (noted == null || (noted is String && noted.trim().isEmpty)) { - missing.add('noted by'); - } - if (received == null || - (received is String && received.trim().isEmpty)) { - missing.add('received by'); - } + // signatories are no longer required for completion; they can be + // filled in later. we still require action taken to document what + // was done. + + // if you want to enforce action taken you can uncomment this block, + // but current business rule only mandates request metadata. we keep + // action taken non-null for clarity. if (action == null || (action is String && action.trim().isEmpty)) { missing.add('action taken'); } diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index f84cd6a9..f78d28d6 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -426,6 +426,28 @@ class _TaskDetailScreenState extends ConsumerState const SizedBox(height: 12), Text(description), ], + + // warning banner for completed tasks with missing metadata + if (task.status == 'completed' && task.hasIncompleteDetails) ...[ + const SizedBox(height: 12), + Row( + children: [ + const Icon( + Icons.warning_amber_rounded, + color: Colors.orange, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Task completed but some details are still empty.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], const SizedBox(height: 16), // Collapsible tabbed details: Assignees / Type & Category / Signatories ExpansionTile( diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 5c7bc219..b82d9cf4 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -297,8 +297,21 @@ class _TasksListScreenState extends ConsumerState { ), TasQColumn( header: 'Status', - cellBuilder: (context, task) => + cellBuilder: (context, task) => Row( + mainAxisSize: MainAxisSize.min, + children: [ _StatusBadge(status: task.status), + if (task.status == 'completed' && + task.hasIncompleteDetails) ...[ + const SizedBox(width: 4), + const Icon( + Icons.warning_amber_rounded, + size: 16, + color: Colors.orange, + ), + ], + ], + ), ), TasQColumn( header: 'Timestamp', @@ -352,6 +365,15 @@ class _TasksListScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ _StatusBadge(status: task.status), + if (task.status == 'completed' && + task.hasIncompleteDetails) ...[ + const SizedBox(width: 4), + const Icon( + Icons.warning_amber_rounded, + size: 16, + color: Colors.orange, + ), + ], if (showTyping) ...[ const SizedBox(width: 6), TypingDots( diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index af10b015..922fc7a2 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -11,6 +11,8 @@ import 'package:tasq/models/task.dart'; import 'package:tasq/models/ticket.dart'; import 'package:tasq/models/user_office.dart'; import 'package:tasq/models/team.dart'; +import 'package:tasq/utils/app_time.dart'; +import 'package:go_router/go_router.dart'; import 'package:tasq/models/team_member.dart'; import 'package:tasq/providers/notifications_provider.dart'; import 'package:tasq/providers/profile_provider.dart'; @@ -23,6 +25,7 @@ import 'package:tasq/screens/tasks/tasks_list_screen.dart'; import 'package:tasq/screens/tickets/tickets_list_screen.dart'; import 'package:tasq/screens/tickets/ticket_detail_screen.dart'; import 'package:tasq/screens/teams/teams_screen.dart'; +import 'package:tasq/screens/shared/permissions_screen.dart'; import 'package:tasq/providers/teams_provider.dart'; import 'package:tasq/widgets/app_shell.dart'; @@ -40,6 +43,17 @@ class FakeNotificationsController implements NotificationsController { String? taskId, }) async {} + @override + Future createNotification({ + required List userIds, + required String type, + required String actorId, + Map? fields, + String? pushTitle, + String? pushBody, + Map? pushData, + }) async {} + @override Future markRead(String id) async {} @@ -48,6 +62,21 @@ class FakeNotificationsController implements NotificationsController { @override Future markReadForTask(String taskId) async {} + + @override + Future registerFcmToken(String token) async {} + + @override + Future unregisterFcmToken(String token) async {} + + @override + Future sendPush({ + List? tokens, + List? userIds, + required String title, + required String body, + Map? data, + }) async {} } // test doubles for controllers that allow us to intercept create operations @@ -228,6 +257,39 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('Completed task with missing details shows warning icon', ( + tester, + ) async { + await _setSurfaceSize(tester, const Size(1024, 800)); + AppTime.initialize(); + + // create a finished task with no signatories or actionTaken + final now = DateTime.now(); + final incompleteTask = Task.fromMap({ + 'id': 't1', + 'status': 'completed', + 'title': 'Incomplete', + 'description': '', + 'created_at': now.toIso8601String(), + 'priority': 1, + }); + + await _pumpScreen( + tester, + const TasksListScreen(), + overrides: [ + ...baseOverrides(), + tasksProvider.overrideWith((ref) => Stream.value([incompleteTask])), + ], + ); + await tester.pumpAndSettle(); + + // warning icon should appear next to status badge in both desktop and + // mobile rows; in this layout smoke test we just ensure at least one is + // present. + expect(find.byIcon(Icons.warning_amber_rounded), findsWidgets); + }); + testWidgets('Offices screen renders without layout exceptions', ( tester, ) async { @@ -262,15 +324,41 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('App shell shows Geofence test nav item for admin', ( + testWidgets('Permissions screen renders without layout exceptions', ( tester, ) async { await _setSurfaceSize(tester, const Size(1024, 800)); await _pumpScreen( tester, - const AppScaffold(child: SizedBox()), + const PermissionsScreen(), overrides: baseOverrides(), ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.takeException(), isNull); + }); + + testWidgets('App shell shows Geofence test nav item for admin', ( + tester, + ) async { + await _setSurfaceSize(tester, const Size(1024, 800)); + // AppScaffold needs a GoRouter state above it; create a minimal router. + await tester.pumpWidget( + ProviderScope( + overrides: baseOverrides(), + child: MaterialApp.router( + routerConfig: GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => + const AppScaffold(child: SizedBox()), + ), + ], + ), + ), + ), + ); await tester.pumpAndSettle(); expect(find.text('Geofence test'), findsOneWidget); }); diff --git a/test/tasks_provider_test.dart b/test/tasks_provider_test.dart index c9eb41f7..7d1c0524 100644 --- a/test/tasks_provider_test.dart +++ b/test/tasks_provider_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tasq/providers/tasks_provider.dart'; +import 'package:tasq/models/task.dart'; // Minimal fake supabase client similar to integration test work, // only implements the methods used by TasksController. @@ -101,7 +102,7 @@ void main() { ); }); - test('cannot complete when signatories or action taken missing', () async { + test('cannot complete when action taken is missing', () async { // insert a task that has the basic request metadata but nothing else final row = { 'id': 'tsk-3', @@ -111,13 +112,14 @@ void main() { }; fake.tables['tasks']!.add(row); - // still missing signatories/actionTaken + // missing actionTaken should still prevent completion even though + // signatories are not required. expect( () => controller.updateTaskStatus(taskId: 'tsk-3', status: 'completed'), throwsA(isA()), ); - // add signatories but actionTaken still missing + // add some signatories but no actionTaken yet await controller.updateTask( taskId: 'tsk-3', requestedBy: 'Alice', @@ -129,7 +131,9 @@ void main() { throwsA(isA()), ); - // add action taken (empty JSON string) + // once action taken is provided completion should succeed even if + // signatories remain empty (they already have values here, but the + // previous checks show they aren't required). await controller.updateTask(taskId: 'tsk-3', actionTaken: '{}'); await controller.updateTaskStatus(taskId: 'tsk-3', status: 'completed'); expect( @@ -143,11 +147,12 @@ void main() { final row = {'id': 'tsk-2', 'status': 'queued'}; fake.tables['tasks']!.add(row); - // update metadata via updateTask + // update metadata via updateTask including actionTaken await controller.updateTask( taskId: 'tsk-2', requestType: 'Repair', requestCategory: 'Hardware', + actionTaken: '{}', ); await controller.updateTaskStatus(taskId: 'tsk-2', status: 'completed'); @@ -156,5 +161,49 @@ void main() { 'completed', ); }); + + test('Task.hasIncompleteDetails flag works correctly', () { + final now = DateTime.now(); + final base = Task( + id: 'x', + ticketId: null, + taskNumber: null, + title: 't', + description: '', + officeId: null, + status: 'completed', + priority: 1, + queueOrder: null, + createdAt: now, + creatorId: null, + startedAt: null, + completedAt: now, + // leave all optional metadata null + ); + expect(base.hasIncompleteDetails, isTrue); + + final full = Task( + id: 'x', + ticketId: null, + taskNumber: null, + title: 't', + description: '', + officeId: null, + status: 'completed', + priority: 1, + queueOrder: null, + createdAt: now, + creatorId: null, + startedAt: null, + completedAt: now, + requestedBy: 'A', + notedBy: 'B', + receivedBy: 'C', + requestType: 'foo', + requestCategory: 'bar', + actionTaken: '{}', + ); + expect(full.hasIncompleteDetails, isFalse); + }); }); }