From 8d31a629acc21c0f0401ef6d510b18daff020d45 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 21 Feb 2026 14:33:22 +0800 Subject: [PATCH] Added Type, Category and Signatories on task --- lib/models/task.dart | 23 + lib/providers/tasks_provider.dart | 97 +++- lib/screens/tasks/task_detail_screen.dart | 508 +++++++++++++++++- lib/screens/tasks/tasks_list_screen.dart | 99 +++- lib/screens/workforce/workforce_screen.dart | 19 +- lib/utils/supabase_response.dart | 7 +- pubspec.lock | 64 +++ pubspec.yaml | 1 + ...21090000_add_request_metadata_to_tasks.sql | 6 + ...91500_convert_request_metadata_to_enum.sql | 25 + ...0221103000_add_clients_and_task_people.sql | 27 + test/layout_smoke_test.dart | 24 +- test/task_detail_screen_test.dart | 203 +++++++ test/tasks_provider_test.dart | 123 +++++ 14 files changed, 1178 insertions(+), 48 deletions(-) create mode 100644 supabase/migrations/20260221090000_add_request_metadata_to_tasks.sql create mode 100644 supabase/migrations/20260221091500_convert_request_metadata_to_enum.sql create mode 100644 supabase/migrations/20260221103000_add_clients_and_task_people.sql create mode 100644 test/task_detail_screen_test.dart create mode 100644 test/tasks_provider_test.dart diff --git a/lib/models/task.dart b/lib/models/task.dart index e06d79b3..7ea60791 100644 --- a/lib/models/task.dart +++ b/lib/models/task.dart @@ -14,6 +14,13 @@ class Task { required this.creatorId, required this.startedAt, required this.completedAt, + // new optional metadata fields + this.requestedBy, + this.notedBy, + this.receivedBy, + this.requestType, + this.requestTypeOther, + this.requestCategory, }); final String id; @@ -29,6 +36,16 @@ class Task { final DateTime? startedAt; final DateTime? completedAt; + // Optional client/user metadata + final String? requestedBy; + final String? notedBy; + final String? receivedBy; + + /// Optional request metadata added later in lifecycle. + final String? requestType; + final String? requestTypeOther; + final String? requestCategory; + factory Task.fromMap(Map map) { return Task( id: map['id'] as String, @@ -47,6 +64,12 @@ class Task { completedAt: map['completed_at'] == null ? null : AppTime.parse(map['completed_at'] as String), + requestType: map['request_type'] as String?, + requestTypeOther: map['request_type_other'] as String?, + requestCategory: map['request_category'] as String?, + requestedBy: map['requested_by'] as String?, + notedBy: map['noted_by'] as String?, + receivedBy: map['received_by'] as String?, ); } } diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 8a9d47b1..09f502b0 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -209,21 +209,41 @@ final tasksControllerProvider = Provider((ref) { class TasksController { TasksController(this._client); - final SupabaseClient _client; + // _client is declared dynamic allowing test doubles that mimic only the + // subset of methods used by this class. In production it will be a + // SupabaseClient instance. + final dynamic _client; Future createTask({ required String title, required String description, String? officeId, String? ticketId, + // optional request metadata when creating a task + String? requestType, + String? requestTypeOther, + String? requestCategory, }) async { final actorId = _client.auth.currentUser?.id; final payload = { 'title': title, 'description': description, }; - if (officeId != null) payload['office_id'] = officeId; - if (ticketId != null) payload['ticket_id'] = ticketId; + if (officeId != null) { + payload['office_id'] = officeId; + } + if (ticketId != null) { + payload['ticket_id'] = ticketId; + } + if (requestType != null) { + payload['request_type'] = requestType; + } + if (requestTypeOther != null) { + payload['request_type_other'] = requestTypeOther; + } + if (requestCategory != null) { + payload['request_category'] = requestCategory; + } final data = await _client .from('tasks') @@ -291,7 +311,7 @@ class TasksController { .from('profiles') .select('id, role') .inFilter('role', roles); - final rows = data as List; + final rows = data; final ids = rows .map((row) => row['id'] as String?) .whereType() @@ -303,10 +323,36 @@ class TasksController { } } + /// Update only the status of a task. + /// + /// Before marking a task as **completed** we enforce that the + /// request type/category metadata have been provided. This protects the + /// business rule that details must be specified before closing. Future updateTaskStatus({ required String taskId, required String status, }) async { + if (status == 'completed') { + // fetch current metadata to validate + try { + final row = await _client + .from('tasks') + .select('request_type, request_category') + .eq('id', taskId) + .maybeSingle(); + final rt = row is Map ? row['request_type'] : null; + final rc = row is Map ? row['request_category'] : null; + if (rt == null || rc == null) { + throw Exception( + 'Request type and category must be set before completing a task.', + ); + } + } catch (e) { + // rethrow so callers can handle + rethrow; + } + } + await _client.from('tasks').update({'status': status}).eq('id', taskId); // Log important status transitions @@ -330,6 +376,49 @@ class TasksController { } } + /// Update arbitrary fields on a task row. + /// + /// Primarily used to set request metadata after creation or during + /// status transitions. + Future updateTask({ + required String taskId, + String? requestType, + String? requestTypeOther, + String? requestCategory, + String? status, + String? requestedBy, + String? notedBy, + String? receivedBy, + }) async { + final payload = {}; + if (requestType != null) { + payload['request_type'] = requestType; + } + if (requestTypeOther != null) { + payload['request_type_other'] = requestTypeOther; + } + if (requestCategory != null) { + payload['request_category'] = requestCategory; + } + if (requestedBy != null) { + payload['requested_by'] = requestedBy; + } + if (notedBy != null) { + payload['noted_by'] = notedBy; + } + // `performed_by` is derived from task assignments; we don't persist it here. + if (receivedBy != null) { + payload['received_by'] = receivedBy; + } + if (status != null) { + payload['status'] = status; + } + if (payload.isEmpty) { + return; + } + await _client.from('tasks').update(payload).eq('id', taskId); + } + // Auto-assignment logic executed once on creation. Future _autoAssignTask({ required String taskId, diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index ade9e562..9ad1cdff 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -8,6 +8,9 @@ import '../../models/task_activity_log.dart'; import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; import '../../providers/notifications_provider.dart'; +import 'dart:async'; +import '../../providers/supabase_provider.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; @@ -21,6 +24,17 @@ import '../../theme/app_surfaces.dart'; import '../../widgets/task_assignment_section.dart'; import '../../widgets/typing_dots.dart'; +// Local request metadata options (kept consistent with other screens) +const List requestTypeOptions = [ + 'Install', + 'Repair', + 'Upgrade', + 'Replace', + 'Other', +]; + +const List requestCategoryOptions = ['Software', 'Hardware', 'Network']; + class TaskDetailScreen extends ConsumerStatefulWidget { const TaskDetailScreen({super.key, required this.taskId}); @@ -32,6 +46,13 @@ class TaskDetailScreen extends ConsumerStatefulWidget { class _TaskDetailScreenState extends ConsumerState { final _messageController = TextEditingController(); + // Controllers for editable signatories + final _requestedController = TextEditingController(); + final _notedController = TextEditingController(); + final _receivedController = TextEditingController(); + Timer? _requestedDebounce; + Timer? _notedDebounce; + Timer? _receivedDebounce; static const List _statusOptions = [ 'queued', 'in_progress', @@ -54,6 +75,12 @@ class _TaskDetailScreenState extends ConsumerState { @override void dispose() { _messageController.dispose(); + _requestedController.dispose(); + _notedController.dispose(); + _receivedController.dispose(); + _requestedDebounce?.cancel(); + _notedDebounce?.cancel(); + _receivedDebounce?.cancel(); super.dispose(); } @@ -149,10 +176,480 @@ class _TaskDetailScreenState extends ConsumerState { const SizedBox(height: 12), Text(description), ], - const SizedBox(height: 12), - _buildTatSection(task), const SizedBox(height: 16), - TaskAssignmentSection(taskId: task.id, canAssign: showAssign), + // Tabbed details: Assignees / Type & Category / Signatories + DefaultTabController( + length: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TabBar( + labelColor: Theme.of(context).colorScheme.onSurface, + indicatorColor: Theme.of(context).colorScheme.primary, + tabs: const [ + Tab(text: 'Assignees'), + Tab(text: 'Type & Category'), + Tab(text: 'Signatories'), + ], + ), + const SizedBox(height: 8), + SizedBox( + height: isWide ? 360 : 300, + child: TabBarView( + children: [ + // Assignees + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TaskAssignmentSection( + taskId: task.id, + canAssign: showAssign, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.bottomRight, + child: _buildTatSection(task), + ), + ], + ), + ), + ), + + // Type & Category + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!canUpdateStatus) ...[ + _MetaBadge( + label: 'Type', + value: task.requestType ?? 'None', + ), + const SizedBox(height: 8), + _MetaBadge( + label: 'Category', + value: task.requestCategory ?? 'None', + ), + ] else ...[ + const Text('Type'), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: task.requestType, + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + for (final t in requestTypeOptions) + DropdownMenuItem( + value: t, + child: Text(t), + ), + ], + onChanged: (v) => ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + requestType: v, + ), + ), + if (task.requestType == 'Other') ...[ + const SizedBox(height: 8), + TextFormField( + initialValue: task.requestTypeOther, + decoration: const InputDecoration( + hintText: 'Details', + ), + onChanged: (text) => ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + requestTypeOther: text.isEmpty + ? null + : text, + ), + ), + ], + const SizedBox(height: 8), + const Text('Category'), + const SizedBox(height: 6), + DropdownButtonFormField( + initialValue: task.requestCategory, + items: [ + const DropdownMenuItem( + value: null, + child: Text('None'), + ), + for (final c in requestCategoryOptions) + DropdownMenuItem( + value: c, + child: Text(c), + ), + ], + onChanged: (v) => ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + requestCategory: v, + ), + ), + ], + const SizedBox(height: 12), + ], + ), + ), + ), + + // Signatories (editable) + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Requested by', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: + TextFieldConfiguration( + controller: _requestedController, + decoration: const InputDecoration( + hintText: 'Requester name or id', + ), + onChanged: (v) { + _requestedDebounce?.cancel(); + _requestedDebounce = Timer( + const Duration(milliseconds: 700), + () async { + final name = v.trim(); + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + requestedBy: name.isEmpty + ? null + : name, + ); + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({'name': name}); + } catch (_) {} + } + }, + ); + }, + ), + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => n.toLowerCase().contains( + pattern.toLowerCase(), + ), + ) + .toList(); + try { + final clientRows = await ref + .read(supabaseClientProvider) + .from('clients') + .select('name') + .ilike('name', '%$pattern%'); + final clientNames = + (clientRows as List?) + ?.map( + (r) => r['name'] as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: (context, suggestion) => + ListTile(title: Text(suggestion)), + onSuggestionSelected: (suggestion) async { + _requestedDebounce?.cancel(); + _requestedController.text = suggestion; + await ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + requestedBy: suggestion.isEmpty + ? null + : suggestion, + ); + try { + if (suggestion.isNotEmpty) { + await ref + .read(supabaseClientProvider) + .from('clients') + .upsert({'name': suggestion}); + } + } catch (_) {} + }, + ), + + const SizedBox(height: 12), + Text( + 'Noted by (Supervisor/Senior)', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: + TextFieldConfiguration( + controller: _notedController, + decoration: const InputDecoration( + hintText: 'Supervisor/Senior', + ), + onChanged: (v) { + _notedDebounce?.cancel(); + _notedDebounce = Timer( + const Duration(milliseconds: 700), + () async { + final name = v.trim(); + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + notedBy: name.isEmpty + ? null + : name, + ); + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({'name': name}); + } catch (_) {} + } + }, + ); + }, + ), + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => n.toLowerCase().contains( + pattern.toLowerCase(), + ), + ) + .toList(); + try { + final clientRows = await ref + .read(supabaseClientProvider) + .from('clients') + .select('name') + .ilike('name', '%$pattern%'); + final clientNames = + (clientRows as List?) + ?.map( + (r) => r['name'] as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: (context, suggestion) => + ListTile(title: Text(suggestion)), + onSuggestionSelected: (suggestion) async { + _notedDebounce?.cancel(); + _notedController.text = suggestion; + await ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + notedBy: suggestion.isEmpty + ? null + : suggestion, + ); + try { + if (suggestion.isNotEmpty) { + await ref + .read(supabaseClientProvider) + .from('clients') + .upsert({'name': suggestion}); + } + } catch (_) {} + }, + ), + + const SizedBox(height: 12), + Text( + 'Received by', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + const SizedBox(height: 6), + TypeAheadFormField( + textFieldConfiguration: + TextFieldConfiguration( + controller: _receivedController, + decoration: const InputDecoration( + hintText: 'Receiver name or id', + ), + onChanged: (v) { + _receivedDebounce?.cancel(); + _receivedDebounce = Timer( + const Duration(milliseconds: 700), + () async { + final name = v.trim(); + await ref + .read( + tasksControllerProvider, + ) + .updateTask( + taskId: task.id, + receivedBy: name.isEmpty + ? null + : name, + ); + if (name.isNotEmpty) { + try { + await ref + .read( + supabaseClientProvider, + ) + .from('clients') + .upsert({'name': name}); + } catch (_) {} + } + }, + ); + }, + ), + suggestionsCallback: (pattern) async { + final profiles = + ref + .watch(profilesProvider) + .valueOrNull ?? + []; + final fromProfiles = profiles + .map( + (p) => p.fullName.isEmpty + ? p.id + : p.fullName, + ) + .where( + (n) => n.toLowerCase().contains( + pattern.toLowerCase(), + ), + ) + .toList(); + try { + final clientRows = await ref + .read(supabaseClientProvider) + .from('clients') + .select('name') + .ilike('name', '%$pattern%'); + final clientNames = + (clientRows as List?) + ?.map( + (r) => r['name'] as String, + ) + .whereType() + .toList() ?? + []; + final merged = { + ...fromProfiles, + ...clientNames, + }.toList(); + return merged; + } catch (_) { + return fromProfiles; + } + }, + itemBuilder: (context, suggestion) => + ListTile(title: Text(suggestion)), + onSuggestionSelected: (suggestion) async { + _receivedDebounce?.cancel(); + _receivedController.text = suggestion; + await ref + .read(tasksControllerProvider) + .updateTask( + taskId: task.id, + receivedBy: suggestion.isEmpty + ? null + : suggestion, + ); + try { + if (suggestion.isNotEmpty) { + await ref + .read(supabaseClientProvider) + .from('clients') + .upsert({'name': suggestion}); + } + } catch (_) {} + }, + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), ], ); @@ -630,7 +1127,10 @@ class _TaskDetailScreenState extends ConsumerState { } return StreamBuilder( - stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick), + stream: Stream.periodic( + const Duration(seconds: 1), + (tick) => tick, + ).asBroadcastStream(), builder: (context, snapshot) { return _buildTatContent(task, AppTime.now()); }, diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 555c5c81..421dc5b9 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -20,6 +20,21 @@ import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; +// request metadata options used in task creation/editing dialogs +const List _requestTypeOptions = [ + 'Install', + 'Repair', + 'Upgrade', + 'Replace', + 'Other', +]; + +const List _requestCategoryOptions = [ + 'Software', + 'Hardware', + 'Network', +]; + class TasksListScreen extends ConsumerStatefulWidget { const TasksListScreen({super.key}); @@ -402,6 +417,9 @@ class _TasksListScreenState extends ConsumerState { final titleController = TextEditingController(); final descriptionController = TextEditingController(); String? selectedOfficeId; + String? selectedRequestType; + String? requestTypeOther; + String? selectedRequestCategory; await showDialog( context: context, @@ -438,21 +456,71 @@ class _TasksListScreenState extends ConsumerState { return const Text('No offices available.'); } selectedOfficeId ??= offices.first.id; - return DropdownButtonFormField( - initialValue: selectedOfficeId, - decoration: const InputDecoration( - labelText: 'Office', - ), - items: offices - .map( - (office) => DropdownMenuItem( - value: office.id, - child: Text(office.name), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: selectedOfficeId, + decoration: const InputDecoration( + labelText: 'Office', + ), + items: offices + .map( + (office) => DropdownMenuItem( + value: office.id, + child: Text(office.name), + ), + ) + .toList(), + onChanged: (value) => + setState(() => selectedOfficeId = value), + ), + const SizedBox(height: 12), + // optional request metadata inputs + DropdownButtonFormField( + initialValue: selectedRequestType, + decoration: const InputDecoration( + labelText: 'Request type (optional)', + ), + items: _requestTypeOptions + .map( + (t) => DropdownMenuItem( + value: t, + child: Text(t), + ), + ) + .toList(), + onChanged: (value) => + setState(() => selectedRequestType = value), + ), + if (selectedRequestType == 'Other') ...[ + const SizedBox(height: 8), + TextField( + decoration: const InputDecoration( + labelText: 'Please specify', ), - ) - .toList(), - onChanged: (value) => - setState(() => selectedOfficeId = value), + onChanged: (v) => requestTypeOther = v, + ), + ], + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedRequestCategory, + decoration: const InputDecoration( + labelText: 'Request category (optional)', + ), + items: _requestCategoryOptions + .map( + (t) => DropdownMenuItem( + value: t, + child: Text(t), + ), + ) + .toList(), + onChanged: (value) => setState( + () => selectedRequestCategory = value, + ), + ), + ], ); }, loading: () => const Align( @@ -484,6 +552,9 @@ class _TasksListScreenState extends ConsumerState { title: title, description: description, officeId: officeId, + requestType: selectedRequestType, + requestTypeOther: requestTypeOther, + requestCategory: selectedRequestCategory, ); if (context.mounted) { Navigator.of(dialogContext).pop(); diff --git a/lib/screens/workforce/workforce_screen.dart b/lib/screens/workforce/workforce_screen.dart index a183f625..047e7fda 100644 --- a/lib/screens/workforce/workforce_screen.dart +++ b/lib/screens/workforce/workforce_screen.dart @@ -477,7 +477,7 @@ class _ScheduleTile extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( - value: selectedId, + initialValue: selectedId, items: [ for (final profile in staff) DropdownMenuItem( @@ -515,7 +515,7 @@ class _ScheduleTile extends ConsumerWidget { ), const SizedBox(height: 12), DropdownButtonFormField( - value: selectedTargetShiftId, + initialValue: selectedTargetShiftId, items: [ for (final s in recipientShifts) DropdownMenuItem( @@ -553,11 +553,14 @@ class _ScheduleTile extends ConsumerWidget { }, ); - if (!context.mounted) return; + if (!context.mounted) { + return; + } if (confirmed != true || selectedId == null || - selectedTargetShiftId == null) + selectedTargetShiftId == null) { return; + } try { await ref @@ -1976,7 +1979,7 @@ class _SwapRequestsPanel extends ConsumerWidget { return; } - Profile? _choice = eligible.first; + Profile? choice = eligible.first; final selected = await showDialog( context: context, builder: (context) { @@ -1984,13 +1987,13 @@ class _SwapRequestsPanel extends ConsumerWidget { title: const Text('Change recipient'), content: StatefulBuilder( builder: (context, setState) => DropdownButtonFormField( - value: _choice, + initialValue: choice, items: eligible .map( (p) => DropdownMenuItem(value: p, child: Text(p.fullName)), ) .toList(), - onChanged: (v) => setState(() => _choice = v), + onChanged: (v) => setState(() => choice = v), ), ), actions: [ @@ -1999,7 +2002,7 @@ class _SwapRequestsPanel extends ConsumerWidget { child: const Text('Cancel'), ), TextButton( - onPressed: () => Navigator.of(context).pop(_choice), + onPressed: () => Navigator.of(context).pop(choice), child: const Text('Save'), ), ], diff --git a/lib/utils/supabase_response.dart b/lib/utils/supabase_response.dart index ddcc406e..827c0a3e 100644 --- a/lib/utils/supabase_response.dart +++ b/lib/utils/supabase_response.dart @@ -14,7 +14,8 @@ String? extractSupabaseError(dynamic res) { return err is Map ? (err['message'] ?? err.toString()) : err.toString(); } if (res['status'] != null && res['status'] is int && res['status'] >= 400) { - return res['message']?.toString() ?? 'Request failed with status ${res['status']}'; + return res['message']?.toString() ?? + 'Request failed with status ${res['status']}'; } return null; } @@ -22,7 +23,9 @@ String? extractSupabaseError(dynamic res) { // Try PostgrestResponse-like fields via dynamic access (safe within try/catch). try { final err = (res as dynamic).error; - if (err != null) return err is Map ? (err['message'] ?? err.toString()) : err.toString(); + if (err != null) { + return err is Map ? (err['message'] ?? err.toString()) : err.toString(); + } } catch (_) {} try { final status = (res as dynamic).status; diff --git a/pubspec.lock b/pubspec.lock index 7ddf1aba..afe6f455 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -270,6 +270,54 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.1" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb" + url: "https://pub.dev" + source: hosted + version: "5.4.1" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -307,6 +355,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_typeahead: + dependency: "direct main" + description: + name: flutter_typeahead + sha256: b9942bd5b7611a6ec3f0730c477146cffa4cd4b051077983ba67ddfc9e7ee818 + url: "https://pub.dev" + source: hosted + version: "4.8.0" flutter_web_plugins: dependency: transitive description: flutter @@ -672,6 +728,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointer_interceptor: + dependency: transitive + description: + name: pointer_interceptor + sha256: adf7a637f97c077041d36801b43be08559fd4322d2127b3f20bb7be1b9eebc22 + url: "https://pub.dev" + source: hosted + version: "0.9.3+7" pointycastle: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cbd2672f..9a5d915b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: timezone: ^0.9.4 flutter_map: ^8.2.2 latlong2: ^0.9.0 + flutter_typeahead: ^4.1.0 dev_dependencies: flutter_test: diff --git a/supabase/migrations/20260221090000_add_request_metadata_to_tasks.sql b/supabase/migrations/20260221090000_add_request_metadata_to_tasks.sql new file mode 100644 index 00000000..dc5e6f43 --- /dev/null +++ b/supabase/migrations/20260221090000_add_request_metadata_to_tasks.sql @@ -0,0 +1,6 @@ +-- Add request type/category metadata to tasks table + +alter table tasks + add column request_type text, + add column request_type_other text, + add column request_category text; diff --git a/supabase/migrations/20260221091500_convert_request_metadata_to_enum.sql b/supabase/migrations/20260221091500_convert_request_metadata_to_enum.sql new file mode 100644 index 00000000..0a94a6f7 --- /dev/null +++ b/supabase/migrations/20260221091500_convert_request_metadata_to_enum.sql @@ -0,0 +1,25 @@ +-- Convert request_type and request_category columns to enums + +-- create enum types +create type request_type as enum ( + 'Install', + 'Repair', + 'Upgrade', + 'Replace', + 'Other' +); + +create type request_category as enum ( + 'Software', + 'Hardware', + 'Network' +); + +-- alter existing columns to use the enum types +alter table tasks + alter column request_type type request_type using ( + case when request_type is null then null else request_type::request_type end + ), + alter column request_category type request_category using ( + case when request_category is null then null else request_category::request_category end + ); diff --git a/supabase/migrations/20260221103000_add_clients_and_task_people.sql b/supabase/migrations/20260221103000_add_clients_and_task_people.sql new file mode 100644 index 00000000..7f10e172 --- /dev/null +++ b/supabase/migrations/20260221103000_add_clients_and_task_people.sql @@ -0,0 +1,27 @@ +-- Migration: add clients table and task person fields (requested/noted/received) +-- Created: 2026-02-21 10:30:00 + +BEGIN; + +-- Clients table to store non-profile requesters/receivers +CREATE TABLE IF NOT EXISTS clients ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + contact jsonb DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Ensure we can efficiently search clients by name +CREATE INDEX IF NOT EXISTS clients_name_idx ON clients (lower(name)); + +-- Add nullable person fields to tasks (requested_by, noted_by, received_by) +ALTER TABLE IF EXISTS tasks + ADD COLUMN IF NOT EXISTS requested_by text, + ADD COLUMN IF NOT EXISTS noted_by text, + ADD COLUMN IF NOT EXISTS received_by text; + +CREATE INDEX IF NOT EXISTS tasks_requested_by_idx ON tasks (requested_by); +CREATE INDEX IF NOT EXISTS tasks_noted_by_idx ON tasks (noted_by); +CREATE INDEX IF NOT EXISTS tasks_received_by_idx ON tasks (received_by); + +COMMIT; diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart index 444bab95..4415148c 100644 --- a/test/layout_smoke_test.dart +++ b/test/layout_smoke_test.dart @@ -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 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 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 []), ), ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()), + ticketMessagesProvider.overrideWith((ref, id) => const Stream.empty()), isAdminProvider.overrideWith((ref) => true), ]; } diff --git a/test/task_detail_screen_test.dart b/test/task_detail_screen_test.dart new file mode 100644 index 00000000..43af5601 --- /dev/null +++ b/test/task_detail_screen_test.dart @@ -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? lastUpdates; + + @override + Future createTask({ + required String title, + required String description, + String? officeId, + String? ticketId, + String? requestType, + String? requestTypeOther, + String? requestCategory, + }) async {} + + @override + Future updateTask({ + required String taskId, + String? requestType, + String? requestTypeOther, + String? requestCategory, + String? status, + String? requestedBy, + String? notedBy, + String? receivedBy, + }) async { + final m = {}; + 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 updateTaskStatus({ + required String taskId, + required String status, + }) async { + lastStatus = status; + } +} + +// lightweight notifications controller stub used in widget tests +class _FakeNotificationsController implements NotificationsController { + @override + Future createMentionNotifications({ + required List userIds, + required String actorId, + required int messageId, + String? ticketId, + String? taskId, + }) async {} + + @override + Future markRead(String id) async {} + + @override + Future markReadForTicket(String ticketId) async {} + + @override + Future 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 []), + ), + 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 []), + ), + 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); + }); +} diff --git a/test/tasks_provider_test.dart b/test/tasks_provider_test.dart new file mode 100644 index 00000000..de8e0ac4 --- /dev/null +++ b/test/tasks_provider_test.dart @@ -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>> tables = { + 'tasks': [], + 'task_activity_logs': [], + }; + + _FakeQuery from(String table) => _FakeQuery(this, table); +} + +class _FakeQuery { + final _FakeClient client; + final String table; + Map? _eq; + Map? _insertPayload; + Map? _updatePayload; + + _FakeQuery(this.client, this.table); + + _FakeQuery select([String? _]) => this; + + Future?> 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.from(r); + } + } + return null; + } + return rows.isEmpty ? null : Map.from(rows.first); + } + + _FakeQuery insert(Map payload) { + _insertPayload = Map.from(payload); + return this; + } + + Future> single() async { + if (_insertPayload != null) { + final id = 'tsk-${client.tables['tasks']!.length + 1}'; + final row = Map.from(_insertPayload!); + row['id'] = id; + client.tables[table]!.add(row); + return Map.from(row); + } + throw Exception('unexpected single() call'); + } + + _FakeQuery update(Map 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()), + ); + }); + + 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', + ); + }); + }); +}