Added Type, Category and Signatories on task
This commit is contained in:
parent
d32449d096
commit
8d31a629ac
|
|
@ -14,6 +14,13 @@ class Task {
|
||||||
required this.creatorId,
|
required this.creatorId,
|
||||||
required this.startedAt,
|
required this.startedAt,
|
||||||
required this.completedAt,
|
required this.completedAt,
|
||||||
|
// new optional metadata fields
|
||||||
|
this.requestedBy,
|
||||||
|
this.notedBy,
|
||||||
|
this.receivedBy,
|
||||||
|
this.requestType,
|
||||||
|
this.requestTypeOther,
|
||||||
|
this.requestCategory,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -29,6 +36,16 @@ class Task {
|
||||||
final DateTime? startedAt;
|
final DateTime? startedAt;
|
||||||
final DateTime? completedAt;
|
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<String, dynamic> map) {
|
factory Task.fromMap(Map<String, dynamic> map) {
|
||||||
return Task(
|
return Task(
|
||||||
id: map['id'] as String,
|
id: map['id'] as String,
|
||||||
|
|
@ -47,6 +64,12 @@ class Task {
|
||||||
completedAt: map['completed_at'] == null
|
completedAt: map['completed_at'] == null
|
||||||
? null
|
? null
|
||||||
: AppTime.parse(map['completed_at'] as String),
|
: 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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -209,21 +209,41 @@ final tasksControllerProvider = Provider<TasksController>((ref) {
|
||||||
class TasksController {
|
class TasksController {
|
||||||
TasksController(this._client);
|
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<void> createTask({
|
Future<void> createTask({
|
||||||
required String title,
|
required String title,
|
||||||
required String description,
|
required String description,
|
||||||
String? officeId,
|
String? officeId,
|
||||||
String? ticketId,
|
String? ticketId,
|
||||||
|
// optional request metadata when creating a task
|
||||||
|
String? requestType,
|
||||||
|
String? requestTypeOther,
|
||||||
|
String? requestCategory,
|
||||||
}) async {
|
}) async {
|
||||||
final actorId = _client.auth.currentUser?.id;
|
final actorId = _client.auth.currentUser?.id;
|
||||||
final payload = <String, dynamic>{
|
final payload = <String, dynamic>{
|
||||||
'title': title,
|
'title': title,
|
||||||
'description': description,
|
'description': description,
|
||||||
};
|
};
|
||||||
if (officeId != null) payload['office_id'] = officeId;
|
if (officeId != null) {
|
||||||
if (ticketId != null) payload['ticket_id'] = ticketId;
|
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
|
final data = await _client
|
||||||
.from('tasks')
|
.from('tasks')
|
||||||
|
|
@ -291,7 +311,7 @@ class TasksController {
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, role')
|
.select('id, role')
|
||||||
.inFilter('role', roles);
|
.inFilter('role', roles);
|
||||||
final rows = data as List<dynamic>;
|
final rows = data;
|
||||||
final ids = rows
|
final ids = rows
|
||||||
.map((row) => row['id'] as String?)
|
.map((row) => row['id'] as String?)
|
||||||
.whereType<String>()
|
.whereType<String>()
|
||||||
|
|
@ -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<void> updateTaskStatus({
|
Future<void> updateTaskStatus({
|
||||||
required String taskId,
|
required String taskId,
|
||||||
required String status,
|
required String status,
|
||||||
}) async {
|
}) 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);
|
await _client.from('tasks').update({'status': status}).eq('id', taskId);
|
||||||
|
|
||||||
// Log important status transitions
|
// 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<void> updateTask({
|
||||||
|
required String taskId,
|
||||||
|
String? requestType,
|
||||||
|
String? requestTypeOther,
|
||||||
|
String? requestCategory,
|
||||||
|
String? status,
|
||||||
|
String? requestedBy,
|
||||||
|
String? notedBy,
|
||||||
|
String? receivedBy,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
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.
|
// Auto-assignment logic executed once on creation.
|
||||||
Future<void> _autoAssignTask({
|
Future<void> _autoAssignTask({
|
||||||
required String taskId,
|
required String taskId,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import '../../models/task_activity_log.dart';
|
||||||
import '../../models/ticket.dart';
|
import '../../models/ticket.dart';
|
||||||
import '../../models/ticket_message.dart';
|
import '../../models/ticket_message.dart';
|
||||||
import '../../providers/notifications_provider.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/profile_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
|
|
@ -21,6 +24,17 @@ import '../../theme/app_surfaces.dart';
|
||||||
import '../../widgets/task_assignment_section.dart';
|
import '../../widgets/task_assignment_section.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
|
|
||||||
|
// Local request metadata options (kept consistent with other screens)
|
||||||
|
const List<String> requestTypeOptions = [
|
||||||
|
'Install',
|
||||||
|
'Repair',
|
||||||
|
'Upgrade',
|
||||||
|
'Replace',
|
||||||
|
'Other',
|
||||||
|
];
|
||||||
|
|
||||||
|
const List<String> requestCategoryOptions = ['Software', 'Hardware', 'Network'];
|
||||||
|
|
||||||
class TaskDetailScreen extends ConsumerStatefulWidget {
|
class TaskDetailScreen extends ConsumerStatefulWidget {
|
||||||
const TaskDetailScreen({super.key, required this.taskId});
|
const TaskDetailScreen({super.key, required this.taskId});
|
||||||
|
|
||||||
|
|
@ -32,6 +46,13 @@ class TaskDetailScreen extends ConsumerStatefulWidget {
|
||||||
|
|
||||||
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
final _messageController = TextEditingController();
|
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<String> _statusOptions = [
|
static const List<String> _statusOptions = [
|
||||||
'queued',
|
'queued',
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|
@ -54,6 +75,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
|
_requestedController.dispose();
|
||||||
|
_notedController.dispose();
|
||||||
|
_receivedController.dispose();
|
||||||
|
_requestedDebounce?.cancel();
|
||||||
|
_notedDebounce?.cancel();
|
||||||
|
_receivedDebounce?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,10 +176,480 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(description),
|
Text(description),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildTatSection(task),
|
|
||||||
const SizedBox(height: 16),
|
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<String?>(
|
||||||
|
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<String?>(
|
||||||
|
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<String>(
|
||||||
|
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<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(r) => r['name'] as String,
|
||||||
|
)
|
||||||
|
.whereType<String>()
|
||||||
|
.toList() ??
|
||||||
|
<String>[];
|
||||||
|
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<String>(
|
||||||
|
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<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(r) => r['name'] as String,
|
||||||
|
)
|
||||||
|
.whereType<String>()
|
||||||
|
.toList() ??
|
||||||
|
<String>[];
|
||||||
|
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<String>(
|
||||||
|
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<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(r) => r['name'] as String,
|
||||||
|
)
|
||||||
|
.whereType<String>()
|
||||||
|
.toList() ??
|
||||||
|
<String>[];
|
||||||
|
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<TaskDetailScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return StreamBuilder<int>(
|
return StreamBuilder<int>(
|
||||||
stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
|
stream: Stream.periodic(
|
||||||
|
const Duration(seconds: 1),
|
||||||
|
(tick) => tick,
|
||||||
|
).asBroadcastStream(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
return _buildTatContent(task, AppTime.now());
|
return _buildTatContent(task, AppTime.now());
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,21 @@ import '../../widgets/tasq_adaptive_list.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
|
|
||||||
|
// request metadata options used in task creation/editing dialogs
|
||||||
|
const List<String> _requestTypeOptions = [
|
||||||
|
'Install',
|
||||||
|
'Repair',
|
||||||
|
'Upgrade',
|
||||||
|
'Replace',
|
||||||
|
'Other',
|
||||||
|
];
|
||||||
|
|
||||||
|
const List<String> _requestCategoryOptions = [
|
||||||
|
'Software',
|
||||||
|
'Hardware',
|
||||||
|
'Network',
|
||||||
|
];
|
||||||
|
|
||||||
class TasksListScreen extends ConsumerStatefulWidget {
|
class TasksListScreen extends ConsumerStatefulWidget {
|
||||||
const TasksListScreen({super.key});
|
const TasksListScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -402,6 +417,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
String? selectedOfficeId;
|
String? selectedOfficeId;
|
||||||
|
String? selectedRequestType;
|
||||||
|
String? requestTypeOther;
|
||||||
|
String? selectedRequestCategory;
|
||||||
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -438,7 +456,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
return const Text('No offices available.');
|
return const Text('No offices available.');
|
||||||
}
|
}
|
||||||
selectedOfficeId ??= offices.first.id;
|
selectedOfficeId ??= offices.first.id;
|
||||||
return DropdownButtonFormField<String>(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
initialValue: selectedOfficeId,
|
initialValue: selectedOfficeId,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Office',
|
labelText: 'Office',
|
||||||
|
|
@ -453,6 +474,53 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
setState(() => selectedOfficeId = value),
|
setState(() => selectedOfficeId = value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// optional request metadata inputs
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
onChanged: (v) => requestTypeOther = v,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
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(
|
loading: () => const Align(
|
||||||
|
|
@ -484,6 +552,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
officeId: officeId,
|
officeId: officeId,
|
||||||
|
requestType: selectedRequestType,
|
||||||
|
requestTypeOther: requestTypeOther,
|
||||||
|
requestCategory: selectedRequestCategory,
|
||||||
);
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
|
|
|
||||||
|
|
@ -477,7 +477,7 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: selectedId,
|
initialValue: selectedId,
|
||||||
items: [
|
items: [
|
||||||
for (final profile in staff)
|
for (final profile in staff)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
|
|
@ -515,7 +515,7 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: selectedTargetShiftId,
|
initialValue: selectedTargetShiftId,
|
||||||
items: [
|
items: [
|
||||||
for (final s in recipientShifts)
|
for (final s in recipientShifts)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
|
|
@ -553,11 +553,14 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (confirmed != true ||
|
if (confirmed != true ||
|
||||||
selectedId == null ||
|
selectedId == null ||
|
||||||
selectedTargetShiftId == null)
|
selectedTargetShiftId == null) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ref
|
await ref
|
||||||
|
|
@ -1976,7 +1979,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Profile? _choice = eligible.first;
|
Profile? choice = eligible.first;
|
||||||
final selected = await showDialog<Profile?>(
|
final selected = await showDialog<Profile?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
|
|
@ -1984,13 +1987,13 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
title: const Text('Change recipient'),
|
title: const Text('Change recipient'),
|
||||||
content: StatefulBuilder(
|
content: StatefulBuilder(
|
||||||
builder: (context, setState) => DropdownButtonFormField<Profile>(
|
builder: (context, setState) => DropdownButtonFormField<Profile>(
|
||||||
value: _choice,
|
initialValue: choice,
|
||||||
items: eligible
|
items: eligible
|
||||||
.map(
|
.map(
|
||||||
(p) => DropdownMenuItem(value: p, child: Text(p.fullName)),
|
(p) => DropdownMenuItem(value: p, child: Text(p.fullName)),
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
onChanged: (v) => setState(() => _choice = v),
|
onChanged: (v) => setState(() => choice = v),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -1999,7 +2002,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(_choice),
|
onPressed: () => Navigator.of(context).pop(choice),
|
||||||
child: const Text('Save'),
|
child: const Text('Save'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ String? extractSupabaseError(dynamic res) {
|
||||||
return err is Map ? (err['message'] ?? err.toString()) : err.toString();
|
return err is Map ? (err['message'] ?? err.toString()) : err.toString();
|
||||||
}
|
}
|
||||||
if (res['status'] != null && res['status'] is int && res['status'] >= 400) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +23,9 @@ String? extractSupabaseError(dynamic res) {
|
||||||
// Try PostgrestResponse-like fields via dynamic access (safe within try/catch).
|
// Try PostgrestResponse-like fields via dynamic access (safe within try/catch).
|
||||||
try {
|
try {
|
||||||
final err = (res as dynamic).error;
|
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 (_) {}
|
} catch (_) {}
|
||||||
try {
|
try {
|
||||||
final status = (res as dynamic).status;
|
final status = (res as dynamic).status;
|
||||||
|
|
|
||||||
64
pubspec.lock
64
pubspec.lock
|
|
@ -270,6 +270,54 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.2.1"
|
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:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -307,6 +355,14 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -672,6 +728,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
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:
|
pointycastle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ dependencies:
|
||||||
timezone: ^0.9.4
|
timezone: ^0.9.4
|
||||||
flutter_map: ^8.2.2
|
flutter_map: ^8.2.2
|
||||||
latlong2: ^0.9.0
|
latlong2: ^0.9.0
|
||||||
|
flutter_typeahead: ^4.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/notification_item.dart';
|
||||||
import 'package:tasq/models/office.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/models/team_member.dart';
|
||||||
import 'package:tasq/providers/notifications_provider.dart';
|
import 'package:tasq/providers/notifications_provider.dart';
|
||||||
import 'package:tasq/providers/profile_provider.dart';
|
import 'package:tasq/providers/profile_provider.dart';
|
||||||
|
|
||||||
import 'package:tasq/providers/tasks_provider.dart';
|
import 'package:tasq/providers/tasks_provider.dart';
|
||||||
import 'package:tasq/providers/tickets_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/user_offices_provider.dart';
|
||||||
import 'package:tasq/providers/supabase_provider.dart';
|
|
||||||
import 'package:tasq/screens/admin/offices_screen.dart';
|
import 'package:tasq/screens/admin/offices_screen.dart';
|
||||||
import 'package:tasq/screens/admin/user_management_screen.dart';
|
import 'package:tasq/screens/admin/user_management_screen.dart';
|
||||||
import 'package:tasq/screens/tasks/tasks_list_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/providers/teams_provider.dart';
|
||||||
import 'package:tasq/widgets/app_shell.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
|
// Test double for NotificationsController so widget tests don't initialize
|
||||||
// a real Supabase client.
|
// a real Supabase client.
|
||||||
class FakeNotificationsController implements NotificationsController {
|
class FakeNotificationsController implements NotificationsController {
|
||||||
|
|
@ -50,10 +48,6 @@ class FakeNotificationsController implements NotificationsController {
|
||||||
Future<void> markReadForTask(String taskId) async {}
|
Future<void> markReadForTask(String taskId) async {}
|
||||||
}
|
}
|
||||||
|
|
||||||
SupabaseClient _fakeSupabaseClient() {
|
|
||||||
return SupabaseClient('http://localhost', 'test-key');
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
final now = DateTime(2026, 2, 10, 12, 0, 0);
|
final now = DateTime(2026, 2, 10, 12, 0, 0);
|
||||||
final office = Office(id: 'office-1', name: 'HQ');
|
final office = Office(id: 'office-1', name: 'HQ');
|
||||||
|
|
@ -84,6 +78,9 @@ void main() {
|
||||||
creatorId: 'user-2',
|
creatorId: 'user-2',
|
||||||
startedAt: null,
|
startedAt: null,
|
||||||
completedAt: null,
|
completedAt: null,
|
||||||
|
requestType: null,
|
||||||
|
requestTypeOther: null,
|
||||||
|
requestCategory: null,
|
||||||
);
|
);
|
||||||
final notification = NotificationItem(
|
final notification = NotificationItem(
|
||||||
id: 'N-1',
|
id: 'N-1',
|
||||||
|
|
@ -99,7 +96,6 @@ void main() {
|
||||||
|
|
||||||
List<Override> baseOverrides() {
|
List<Override> baseOverrides() {
|
||||||
return [
|
return [
|
||||||
supabaseClientProvider.overrideWithValue(_fakeSupabaseClient()),
|
|
||||||
currentProfileProvider.overrideWith((ref) => Stream.value(admin)),
|
currentProfileProvider.overrideWith((ref) => Stream.value(admin)),
|
||||||
profilesProvider.overrideWith((ref) => Stream.value([admin, tech])),
|
profilesProvider.overrideWith((ref) => Stream.value([admin, tech])),
|
||||||
|
|
||||||
|
|
@ -107,22 +103,17 @@ void main() {
|
||||||
notificationsProvider.overrideWith((ref) => Stream.value([notification])),
|
notificationsProvider.overrideWith((ref) => Stream.value([notification])),
|
||||||
ticketsProvider.overrideWith((ref) => Stream.value([ticket])),
|
ticketsProvider.overrideWith((ref) => Stream.value([ticket])),
|
||||||
tasksProvider.overrideWith((ref) => Stream.value([task])),
|
tasksProvider.overrideWith((ref) => Stream.value([task])),
|
||||||
|
tasksControllerProvider.overrideWith((ref) => TasksController(null)),
|
||||||
userOfficesProvider.overrideWith(
|
userOfficesProvider.overrideWith(
|
||||||
(ref) =>
|
(ref) =>
|
||||||
Stream.value([UserOffice(userId: 'user-1', officeId: 'office-1')]),
|
Stream.value([UserOffice(userId: 'user-1', officeId: 'office-1')]),
|
||||||
),
|
),
|
||||||
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
|
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
|
||||||
|
ticketMessagesProvider.overrideWith((ref, id) => const Stream.empty()),
|
||||||
isAdminProvider.overrideWith((ref) => true),
|
isAdminProvider.overrideWith((ref) => true),
|
||||||
notificationsControllerProvider.overrideWithValue(
|
notificationsControllerProvider.overrideWithValue(
|
||||||
FakeNotificationsController(),
|
FakeNotificationsController(),
|
||||||
),
|
),
|
||||||
typingIndicatorProvider.overrideWithProvider(
|
|
||||||
AutoDisposeStateNotifierProvider.family<
|
|
||||||
TypingIndicatorController,
|
|
||||||
TypingIndicatorState,
|
|
||||||
String
|
|
||||||
>((ref, id) => TypingIndicatorController(_fakeSupabaseClient(), id)),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,6 +126,7 @@ void main() {
|
||||||
(ref) => Stream.value(const <UserOffice>[]),
|
(ref) => Stream.value(const <UserOffice>[]),
|
||||||
),
|
),
|
||||||
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
|
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
|
||||||
|
ticketMessagesProvider.overrideWith((ref, id) => const Stream.empty()),
|
||||||
isAdminProvider.overrideWith((ref) => true),
|
isAdminProvider.overrideWith((ref) => true),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
203
test/task_detail_screen_test.dart
Normal file
203
test/task_detail_screen_test.dart
Normal 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
test/tasks_provider_test.dart
Normal file
123
test/tasks_provider_test.dart
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user