* Push Notification Setup and attempt
* Office Ordering * Allow editing of Task and Ticket Details after creation
This commit is contained in:
@@ -8,6 +8,7 @@ import '../../models/task_assignment.dart';
|
||||
import '../../models/task_activity_log.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/ticket_message.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
@@ -22,6 +23,7 @@ import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
@@ -29,7 +31,6 @@ import '../../widgets/status_pill.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
|
||||
// Simple image embed builder to support data-URI and network images
|
||||
class _ImageEmbedBuilder extends quill.EmbedBuilder {
|
||||
@@ -315,16 +316,41 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
final detailsContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: 'Task ${task.taskNumber ?? task.id}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: 'Task ${task.taskNumber ?? task.id}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
final profile = profileAsync.maybeWhen(
|
||||
data: (p) => p,
|
||||
orElse: () => null,
|
||||
);
|
||||
final canEdit =
|
||||
profile != null &&
|
||||
(profile.role == 'admin' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff' ||
|
||||
profile.id == task.creatorId);
|
||||
if (!canEdit) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
tooltip: 'Edit task',
|
||||
onPressed: () => _showEditTaskDialog(ctx, ref, task),
|
||||
icon: const Icon(Icons.edit),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -2143,31 +2169,15 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
: assignedForTask.last;
|
||||
|
||||
DateTime? firstMessageByAssignee;
|
||||
if (latestAssignment != null) {
|
||||
messagesAsync.when(
|
||||
data: (messages) {
|
||||
final byAssignee =
|
||||
messages
|
||||
.where((m) => m.senderId == latestAssignment.userId)
|
||||
.where((m) => m.createdAt.isAfter(latestAssignment.createdAt))
|
||||
.toList()
|
||||
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||
if (byAssignee.isNotEmpty) {
|
||||
firstMessageByAssignee = byAssignee.first.createdAt;
|
||||
}
|
||||
},
|
||||
loading: () {},
|
||||
error: (err, stack) {},
|
||||
);
|
||||
}
|
||||
|
||||
DateTime? startedByAssignee;
|
||||
for (final l in logs) {
|
||||
if (l.actionType == 'started' && latestAssignment != null) {
|
||||
if (l.actorId == latestAssignment.userId &&
|
||||
l.createdAt.isAfter(latestAssignment.createdAt)) {
|
||||
startedByAssignee = l.createdAt;
|
||||
break;
|
||||
if (latestAssignment != null) {
|
||||
for (final l in logs) {
|
||||
if (l.actionType == 'started') {
|
||||
if (l.actorId == latestAssignment.userId &&
|
||||
l.createdAt.isAfter(latestAssignment.createdAt)) {
|
||||
startedByAssignee = l.createdAt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2178,7 +2188,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
final assignedAt = latestAssignment.createdAt;
|
||||
final candidates = <DateTime>[];
|
||||
if (firstMessageByAssignee != null) {
|
||||
candidates.add(firstMessageByAssignee!);
|
||||
candidates.add(firstMessageByAssignee);
|
||||
}
|
||||
if (startedByAssignee != null) {
|
||||
candidates.add(startedByAssignee);
|
||||
@@ -2693,6 +2703,100 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
return '${names[0]}, ${names[1]} and others are typing...';
|
||||
}
|
||||
|
||||
Future<void> _showEditTaskDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Task task,
|
||||
) async {
|
||||
final officesAsync = ref.watch(officesOnceProvider);
|
||||
final titleCtrl = TextEditingController(text: task.title);
|
||||
final descCtrl = TextEditingController(text: task.description);
|
||||
String? selectedOffice = task.officeId;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
shape: AppSurfaces.of(context).dialogShape,
|
||||
title: const Text('Edit Task'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: titleCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Title'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
maxLines: 4,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(
|
||||
b.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
return DropdownButtonFormField<String?>(
|
||||
initialValue: selectedOffice,
|
||||
decoration: const InputDecoration(labelText: 'Office'),
|
||||
items: [
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Unassigned'),
|
||||
),
|
||||
for (final o in officesSorted)
|
||||
DropdownMenuItem(value: o.id, child: Text(o.name)),
|
||||
],
|
||||
onChanged: (v) => selectedOffice = v,
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final outerContext = context;
|
||||
final title = titleCtrl.text.trim();
|
||||
final desc = descCtrl.text.trim();
|
||||
try {
|
||||
await ref
|
||||
.read(tasksControllerProvider)
|
||||
.updateTaskFields(
|
||||
taskId: task.id,
|
||||
title: title.isEmpty ? null : title,
|
||||
description: desc.isEmpty ? null : desc,
|
||||
officeId: selectedOffice,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(outerContext).pop();
|
||||
showSuccessSnackBar(outerContext, 'Task updated');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showErrorSnackBar(outerContext, 'Failed to update task: $e');
|
||||
}
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Task? _findTask(AsyncValue<List<Task>> tasksAsync, String taskId) {
|
||||
return tasksAsync.maybeWhen(
|
||||
data: (tasks) => tasks.where((task) => task.id == taskId).firstOrNull,
|
||||
|
||||
@@ -106,12 +106,17 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||
return const Center(child: Text('No tasks yet.'));
|
||||
}
|
||||
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort(
|
||||
(a, b) =>
|
||||
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
final officeOptions = <DropdownMenuItem<String?>>[
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('All offices'),
|
||||
),
|
||||
...offices.map(
|
||||
...officesSorted.map(
|
||||
(office) => DropdownMenuItem<String?>(
|
||||
value: office.id,
|
||||
child: Text(office.name),
|
||||
@@ -461,7 +466,13 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||
if (offices.isEmpty) {
|
||||
return const Text('No offices available.');
|
||||
}
|
||||
selectedOfficeId ??= offices.first.id;
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(
|
||||
b.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
selectedOfficeId ??= officesSorted.first.id;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -470,7 +481,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Office',
|
||||
),
|
||||
items: offices
|
||||
items: officesSorted
|
||||
.map(
|
||||
(office) => DropdownMenuItem(
|
||||
value: office.id,
|
||||
|
||||
Reference in New Issue
Block a user