Added Type, Category and Signatories on task

This commit is contained in:
2026-02-21 14:33:22 +08:00
parent d32449d096
commit 8d31a629ac
14 changed files with 1178 additions and 48 deletions
+8 -16
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:tasq/models/notification_item.dart';
import 'package:tasq/models/office.dart';
@@ -13,12 +12,9 @@ import 'package:tasq/models/team.dart';
import 'package:tasq/models/team_member.dart';
import 'package:tasq/providers/notifications_provider.dart';
import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/providers/tasks_provider.dart';
import 'package:tasq/providers/tickets_provider.dart';
import 'package:tasq/providers/typing_provider.dart';
import 'package:tasq/providers/user_offices_provider.dart';
import 'package:tasq/providers/supabase_provider.dart';
import 'package:tasq/screens/admin/offices_screen.dart';
import 'package:tasq/screens/admin/user_management_screen.dart';
import 'package:tasq/screens/tasks/tasks_list_screen.dart';
@@ -28,6 +24,8 @@ import 'package:tasq/screens/teams/teams_screen.dart';
import 'package:tasq/providers/teams_provider.dart';
import 'package:tasq/widgets/app_shell.dart';
// (Noop typing controller removed — use provider overrides when needed.)
// Test double for NotificationsController so widget tests don't initialize
// a real Supabase client.
class FakeNotificationsController implements NotificationsController {
@@ -50,10 +48,6 @@ class FakeNotificationsController implements NotificationsController {
Future<void> markReadForTask(String taskId) async {}
}
SupabaseClient _fakeSupabaseClient() {
return SupabaseClient('http://localhost', 'test-key');
}
void main() {
final now = DateTime(2026, 2, 10, 12, 0, 0);
final office = Office(id: 'office-1', name: 'HQ');
@@ -84,6 +78,9 @@ void main() {
creatorId: 'user-2',
startedAt: null,
completedAt: null,
requestType: null,
requestTypeOther: null,
requestCategory: null,
);
final notification = NotificationItem(
id: 'N-1',
@@ -99,7 +96,6 @@ void main() {
List<Override> baseOverrides() {
return [
supabaseClientProvider.overrideWithValue(_fakeSupabaseClient()),
currentProfileProvider.overrideWith((ref) => Stream.value(admin)),
profilesProvider.overrideWith((ref) => Stream.value([admin, tech])),
@@ -107,22 +103,17 @@ void main() {
notificationsProvider.overrideWith((ref) => Stream.value([notification])),
ticketsProvider.overrideWith((ref) => Stream.value([ticket])),
tasksProvider.overrideWith((ref) => Stream.value([task])),
tasksControllerProvider.overrideWith((ref) => TasksController(null)),
userOfficesProvider.overrideWith(
(ref) =>
Stream.value([UserOffice(userId: 'user-1', officeId: 'office-1')]),
),
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
ticketMessagesProvider.overrideWith((ref, id) => const Stream.empty()),
isAdminProvider.overrideWith((ref) => true),
notificationsControllerProvider.overrideWithValue(
FakeNotificationsController(),
),
typingIndicatorProvider.overrideWithProvider(
AutoDisposeStateNotifierProvider.family<
TypingIndicatorController,
TypingIndicatorState,
String
>((ref, id) => TypingIndicatorController(_fakeSupabaseClient(), id)),
),
];
}
@@ -135,6 +126,7 @@ void main() {
(ref) => Stream.value(const <UserOffice>[]),
),
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
ticketMessagesProvider.overrideWith((ref, id) => const Stream.empty()),
isAdminProvider.overrideWith((ref) => true),
];
}
+203
View File
@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tasq/models/task.dart';
import 'package:tasq/models/profile.dart';
import 'package:tasq/screens/tasks/task_detail_screen.dart';
import 'package:tasq/providers/tasks_provider.dart';
import 'package:tasq/providers/profile_provider.dart';
import 'package:tasq/providers/notifications_provider.dart';
import 'package:tasq/providers/tickets_provider.dart';
import 'package:tasq/utils/app_time.dart';
// Fake controller to capture updates
class FakeTasksController extends TasksController {
FakeTasksController() : super(null);
String? lastStatus;
Map<String, dynamic>? lastUpdates;
@override
Future<void> createTask({
required String title,
required String description,
String? officeId,
String? ticketId,
String? requestType,
String? requestTypeOther,
String? requestCategory,
}) async {}
@override
Future<void> updateTask({
required String taskId,
String? requestType,
String? requestTypeOther,
String? requestCategory,
String? status,
String? requestedBy,
String? notedBy,
String? receivedBy,
}) async {
final m = <String, dynamic>{};
if (requestType != null) {
m['requestType'] = requestType;
}
if (requestTypeOther != null) {
m['requestTypeOther'] = requestTypeOther;
}
if (requestCategory != null) {
m['requestCategory'] = requestCategory;
}
if (requestedBy != null) {
m['requestedBy'] = requestedBy;
}
if (notedBy != null) {
m['notedBy'] = notedBy;
}
if (receivedBy != null) {
m['receivedBy'] = receivedBy;
}
if (status != null) {
m['status'] = status;
}
lastUpdates = m;
}
@override
Future<void> updateTaskStatus({
required String taskId,
required String status,
}) async {
lastStatus = status;
}
}
// lightweight notifications controller stub used in widget tests
class _FakeNotificationsController implements NotificationsController {
@override
Future<void> createMentionNotifications({
required List<String> userIds,
required String actorId,
required int messageId,
String? ticketId,
String? taskId,
}) async {}
@override
Future<void> markRead(String id) async {}
@override
Future<void> markReadForTicket(String ticketId) async {}
@override
Future<void> markReadForTask(String taskId) async {}
}
void main() {
testWidgets('details badges show when metadata present', (tester) async {
AppTime.initialize();
// initial task without metadata
final emptyTask = Task(
id: 'tsk-1',
ticketId: null,
title: 'No metadata',
description: '',
officeId: 'office-1',
status: 'queued',
priority: 1,
queueOrder: null,
createdAt: DateTime.now(),
creatorId: 'u1',
startedAt: null,
completedAt: null,
requestType: null,
requestTypeOther: null,
requestCategory: null,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
tasksProvider.overrideWith((ref) => Stream.value([emptyTask])),
taskAssignmentsProvider.overrideWith((ref) => const Stream.empty()),
currentProfileProvider.overrideWith(
(ref) => Stream.value(
Profile(id: 'u1', role: 'admin', fullName: 'Admin'),
),
),
notificationsControllerProvider.overrideWithValue(
_FakeNotificationsController(),
),
notificationsProvider.overrideWith((ref) => const Stream.empty()),
ticketsProvider.overrideWith((ref) => const Stream.empty()),
officesProvider.overrideWith((ref) => const Stream.empty()),
profilesProvider.overrideWith(
(ref) => Stream.value(const <Profile>[]),
),
taskMessagesProvider.overrideWith((ref, id) => const Stream.empty()),
],
child: MaterialApp(home: TaskDetailScreen(taskId: 'tsk-1')),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// metadata absent; editor controls may still show 'Type'/'Category' labels but
// there should be no actual values present.
expect(find.text('Repair'), findsNothing);
expect(find.text('Hardware'), findsNothing);
});
testWidgets('badges show when metadata provided', (tester) async {
AppTime.initialize();
final task = Task(
id: 'tsk-1',
ticketId: null,
title: 'Has metadata',
description: '',
officeId: 'office-1',
status: 'queued',
priority: 1,
queueOrder: null,
createdAt: DateTime.now(),
creatorId: 'u1',
startedAt: null,
completedAt: null,
requestType: 'Repair',
requestTypeOther: null,
requestCategory: 'Hardware',
);
await tester.pumpWidget(
ProviderScope(
overrides: [
tasksProvider.overrideWith((ref) => Stream.value([task])),
taskAssignmentsProvider.overrideWith((ref) => const Stream.empty()),
currentProfileProvider.overrideWith(
(ref) => Stream.value(
Profile(id: 'u1', role: 'admin', fullName: 'Admin'),
),
),
notificationsControllerProvider.overrideWithValue(
_FakeNotificationsController(),
),
notificationsProvider.overrideWith((ref) => const Stream.empty()),
ticketsProvider.overrideWith((ref) => const Stream.empty()),
officesProvider.overrideWith((ref) => const Stream.empty()),
profilesProvider.overrideWith(
(ref) => Stream.value(const <Profile>[]),
),
taskMessagesProvider.overrideWith((ref, id) => const Stream.empty()),
],
child: MaterialApp(home: TaskDetailScreen(taskId: 'tsk-1')),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
// the selected values should be visible
expect(find.text('Repair'), findsOneWidget);
expect(find.text('Hardware'), findsOneWidget);
});
}
+123
View File
@@ -0,0 +1,123 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tasq/providers/tasks_provider.dart';
// Minimal fake supabase client similar to integration test work,
// only implements the methods used by TasksController.
class _FakeClient {
final Map<String, List<Map<String, dynamic>>> tables = {
'tasks': [],
'task_activity_logs': [],
};
_FakeQuery from(String table) => _FakeQuery(this, table);
}
class _FakeQuery {
final _FakeClient client;
final String table;
Map<String, dynamic>? _eq;
Map<String, dynamic>? _insertPayload;
Map<String, dynamic>? _updatePayload;
_FakeQuery(this.client, this.table);
_FakeQuery select([String? _]) => this;
Future<Map<String, dynamic>?> maybeSingle() async {
final rows = client.tables[table] ?? [];
if (_eq != null) {
final field = _eq!.keys.first;
final value = _eq![field];
for (final r in rows) {
if (r[field] == value) {
return Map<String, dynamic>.from(r);
}
}
return null;
}
return rows.isEmpty ? null : Map<String, dynamic>.from(rows.first);
}
_FakeQuery insert(Map<String, dynamic> payload) {
_insertPayload = Map<String, dynamic>.from(payload);
return this;
}
Future<Map<String, dynamic>> single() async {
if (_insertPayload != null) {
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);
}
throw Exception('unexpected single() call');
}
_FakeQuery update(Map<String, dynamic> payload) {
_updatePayload = payload;
// don't apply yet; wait for eq to know which row
return this;
}
_FakeQuery eq(String field, dynamic value) {
_eq = {field: value};
// apply update payload now that we know which row to target
if (_updatePayload != null) {
final idx = client.tables[table]!.indexWhere((r) => r[field] == value);
if (idx >= 0) {
client.tables[table]![idx] = {
...client.tables[table]![idx],
..._updatePayload!,
};
}
// clear payload after applying so subsequent eq calls don't reapply
_updatePayload = null;
}
return this;
}
}
void main() {
group('TasksController business rules', () {
late _FakeClient fake;
late TasksController controller;
setUp(() {
fake = _FakeClient();
controller = TasksController(fake as dynamic);
// ignore: avoid_dynamic_calls
// note: controller expects SupabaseClient; using dynamic bypass.
});
test('cannot complete a task without request details', () async {
// insert a task with no metadata
final row = {'id': 'tsk-1', 'status': 'queued'};
fake.tables['tasks']!.add(row);
expect(
() => controller.updateTaskStatus(taskId: 'tsk-1', status: 'completed'),
throwsA(isA<Exception>()),
);
});
test('completing after adding metadata succeeds', () async {
// insert a task
final row = {'id': 'tsk-2', 'status': 'queued'};
fake.tables['tasks']!.add(row);
// update metadata via updateTask
await controller.updateTask(
taskId: 'tsk-2',
requestType: 'Repair',
requestCategory: 'Hardware',
);
await controller.updateTaskStatus(taskId: 'tsk-2', status: 'completed');
expect(
fake.tables['tasks']!.firstWhere((t) => t['id'] == 'tsk-2')['status'],
'completed',
);
});
});
}