tasq/lib/screens/tasks/tasks_list_screen.dart

320 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/notification_item.dart';
import '../../models/task.dart';
import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../providers/typing_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/typing_dots.dart';
class TasksListScreen extends ConsumerWidget {
const TasksListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksProvider);
final ticketsAsync = ref.watch(ticketsProvider);
final officesAsync = ref.watch(officesProvider);
final profileAsync = ref.watch(currentProfileProvider);
final notificationsAsync = ref.watch(notificationsProvider);
final canCreate = profileAsync.maybeWhen(
data: (profile) =>
profile != null &&
(profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff'),
orElse: () => false,
);
final ticketById = {
for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket,
};
final officeById = {
for (final office in officesAsync.valueOrNull ?? []) office.id: office,
};
return Scaffold(
body: ResponsiveBody(
child: tasksAsync.when(
data: (tasks) {
if (tasks.isEmpty) {
return const Center(child: Text('No tasks yet.'));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Align(
alignment: Alignment.center,
child: Text(
'Tasks',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: tasks.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final task = tasks[index];
final ticketId = task.ticketId;
final ticket = ticketId == null
? null
: ticketById[ticketId];
final officeId = ticket?.officeId ?? task.officeId;
final officeName = officeId == null
? 'Unassigned office'
: (officeById[officeId]?.name ?? officeId);
final subtitle = _buildSubtitle(officeName, task.status);
final hasMention = _hasTaskMention(
notificationsAsync,
task,
);
final typingChannelId = task.id;
final typingState = ref.watch(
typingIndicatorProvider(typingChannelId),
);
final showTyping = typingState.userIds.isNotEmpty;
return ListTile(
leading: _buildQueueBadge(context, task),
title: Text(
task.title.isNotEmpty
? task.title
: (ticket?.subject ?? 'Task ${task.id}'),
),
subtitle: Text(subtitle),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatusChip(context, task.status),
if (showTyping) ...[
const SizedBox(width: 6),
TypingDots(
size: 6,
color: Theme.of(context).colorScheme.primary,
),
],
if (hasMention)
const Padding(
padding: EdgeInsets.only(left: 8),
child: Icon(
Icons.circle,
size: 10,
color: Colors.red,
),
),
],
),
onTap: () => context.go('/tasks/${task.id}'),
);
},
),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load tasks: $error')),
),
),
floatingActionButton: canCreate
? FloatingActionButton.extended(
onPressed: () => _showCreateTaskDialog(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Task'),
)
: null,
);
}
Future<void> _showCreateTaskDialog(
BuildContext context,
WidgetRef ref,
) async {
final titleController = TextEditingController();
final descriptionController = TextEditingController();
String? selectedOfficeId;
await showDialog<void>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final officesAsync = ref.watch(officesProvider);
return AlertDialog(
title: const Text('Create Task'),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'Task title',
),
),
const SizedBox(height: 12),
TextField(
controller: descriptionController,
decoration: const InputDecoration(
labelText: 'Description',
),
maxLines: 3,
),
const SizedBox(height: 12),
officesAsync.when(
data: (offices) {
if (offices.isEmpty) {
return const Text('No offices available.');
}
selectedOfficeId ??= offices.first.id;
return DropdownButtonFormField<String>(
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),
);
},
loading: () => const Align(
alignment: Alignment.centerLeft,
child: CircularProgressIndicator(),
),
error: (error, _) =>
Text('Failed to load offices: $error'),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
final title = titleController.text.trim();
final description = descriptionController.text.trim();
final officeId = selectedOfficeId;
if (title.isEmpty || officeId == null) {
return;
}
await ref
.read(tasksControllerProvider)
.createTask(
title: title,
description: description,
officeId: officeId,
);
if (context.mounted) {
Navigator.of(dialogContext).pop();
}
},
child: const Text('Create'),
),
],
);
},
);
},
);
}
bool _hasTaskMention(
AsyncValue<List<NotificationItem>> notificationsAsync,
Task task,
) {
return notificationsAsync.maybeWhen(
data: (items) => items.any(
(item) =>
item.isUnread &&
(item.taskId == task.id || item.ticketId == task.ticketId),
),
orElse: () => false,
);
}
Widget _buildQueueBadge(BuildContext context, Task task) {
final queueOrder = task.queueOrder;
final isQueued = task.status == 'queued';
if (!isQueued || queueOrder == null) {
return const Icon(Icons.fact_check_outlined);
}
return Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'#$queueOrder',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
);
}
String _buildSubtitle(String officeName, String status) {
final statusLabel = status.toUpperCase();
return '$officeName · $statusLabel';
}
Widget _buildStatusChip(BuildContext context, String status) {
return Chip(
label: Text(status.toUpperCase()),
backgroundColor: _statusColor(context, status),
labelStyle: TextStyle(
color: _statusTextColor(context, status),
fontWeight: FontWeight.w600,
),
);
}
Color _statusColor(BuildContext context, String status) {
return switch (status) {
'queued' => Colors.blueGrey.shade200,
'in_progress' => Colors.blue.shade300,
'completed' => Colors.green.shade300,
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
};
}
Color _statusTextColor(BuildContext context, String status) {
return switch (status) {
'queued' => Colors.blueGrey.shade900,
'in_progress' => Colors.blue.shade900,
'completed' => Colors.green.shade900,
_ => Theme.of(context).colorScheme.onSurfaceVariant,
};
}
}