tasq/lib/screens/tickets/tickets_list_screen.dart

269 lines
10 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../models/office.dart';
import '../../models/notification_item.dart';
import '../../providers/notifications_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../providers/typing_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/typing_dots.dart';
class TicketsListScreen extends ConsumerWidget {
const TicketsListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ticketsAsync = ref.watch(ticketsProvider);
final officesAsync = ref.watch(officesProvider);
final notificationsAsync = ref.watch(notificationsProvider);
return Scaffold(
body: ResponsiveBody(
child: ticketsAsync.when(
data: (tickets) {
if (tickets.isEmpty) {
return const Center(child: Text('No tickets yet.'));
}
final officeById = {
for (final office in officesAsync.valueOrNull ?? [])
office.id: office,
};
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Align(
alignment: Alignment.center,
child: Text(
'Tickets',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: tickets.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final ticket = tickets[index];
final officeName =
officeById[ticket.officeId]?.name ?? ticket.officeId;
final hasMention = unreadByTicketId[ticket.id] == true;
final typingState = ref.watch(
typingIndicatorProvider(ticket.id),
);
final showTyping = typingState.userIds.isNotEmpty;
return ListTile(
leading: const Icon(Icons.confirmation_number_outlined),
title: Text(ticket.subject),
subtitle: Text(officeName),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatusChip(context, ticket.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('/tickets/${ticket.id}'),
);
},
),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load tickets: $error')),
),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showCreateTicketDialog(context, ref),
icon: const Icon(Icons.add),
label: const Text('New Ticket'),
),
);
}
Future<void> _showCreateTicketDialog(
BuildContext context,
WidgetRef ref,
) async {
final subjectController = TextEditingController();
final descriptionController = TextEditingController();
Office? selectedOffice;
await showDialog<void>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: const Text('Create Ticket'),
content: Consumer(
builder: (context, ref, child) {
final officesAsync = ref.watch(officesProvider);
return SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: subjectController,
decoration: const InputDecoration(
labelText: 'Subject',
),
),
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 assigned.');
}
selectedOffice ??= offices.first;
return DropdownButtonFormField<Office>(
key: ValueKey(selectedOffice?.id),
initialValue: selectedOffice,
items: offices
.map(
(office) => DropdownMenuItem(
value: office,
child: Text(office.name),
),
)
.toList(),
onChanged: (value) =>
setState(() => selectedOffice = value),
decoration: const InputDecoration(
labelText: 'Office',
),
);
},
loading: () => const LinearProgressIndicator(),
error: (error, _) =>
Text('Failed to load offices: $error'),
),
],
),
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () async {
final subject = subjectController.text.trim();
final description = descriptionController.text.trim();
if (subject.isEmpty ||
description.isEmpty ||
selectedOffice == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fill out all fields.')),
);
return;
}
await ref
.read(ticketsControllerProvider)
.createTicket(
subject: subject,
description: description,
officeId: selectedOffice!.id,
);
ref.invalidate(ticketsProvider);
if (context.mounted) {
Navigator.of(dialogContext).pop();
}
},
child: const Text('Create'),
),
],
);
},
);
},
);
}
Map<String, bool> _unreadByTicketId(
AsyncValue<List<NotificationItem>> notificationsAsync,
) {
return notificationsAsync.maybeWhen(
data: (items) {
final map = <String, bool>{};
for (final item in items) {
if (item.ticketId == null) continue;
if (item.isUnread) {
map[item.ticketId!] = true;
}
}
return map;
},
orElse: () => <String, bool>{},
);
}
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) {
'pending' => Colors.amber.shade300,
'promoted' => Colors.blue.shade300,
'closed' => Colors.green.shade300,
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
};
}
Color _statusTextColor(BuildContext context, String status) {
return switch (status) {
'pending' => Colors.brown.shade900,
'promoted' => Colors.blue.shade900,
'closed' => Colors.green.shade900,
_ => Theme.of(context).colorScheme.onSurfaceVariant,
};
}
}