729 lines
27 KiB
Dart
729 lines
27 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:tasq/utils/app_time.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../models/office.dart';
|
|
import '../../models/notification_item.dart';
|
|
import '../../models/profile.dart';
|
|
import '../../models/ticket.dart';
|
|
import '../../providers/notifications_provider.dart';
|
|
import '../../providers/profile_provider.dart';
|
|
import '../../providers/tickets_provider.dart';
|
|
import '../../providers/realtime_controller.dart';
|
|
import '../../providers/typing_provider.dart';
|
|
import '../../widgets/mono_text.dart';
|
|
import '../../widgets/reconnect_overlay.dart';
|
|
import 'package:skeletonizer/skeletonizer.dart';
|
|
import '../../widgets/responsive_body.dart';
|
|
import '../../widgets/tasq_adaptive_list.dart';
|
|
import '../../widgets/typing_dots.dart';
|
|
import '../../theme/app_surfaces.dart';
|
|
import '../../utils/snackbar.dart';
|
|
|
|
class TicketsListScreen extends ConsumerStatefulWidget {
|
|
const TicketsListScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<TicketsListScreen> createState() => _TicketsListScreenState();
|
|
}
|
|
|
|
class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
|
final TextEditingController _subjectController = TextEditingController();
|
|
String? _selectedOfficeId;
|
|
String? _selectedStatus;
|
|
DateTimeRange? _selectedDateRange;
|
|
bool _isInitial = true;
|
|
|
|
@override
|
|
void dispose() {
|
|
_subjectController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool get _hasTicketFilters {
|
|
return _subjectController.text.trim().isNotEmpty ||
|
|
_selectedOfficeId != null ||
|
|
_selectedStatus != null ||
|
|
_selectedDateRange != null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ticketsAsync = ref.watch(ticketsProvider);
|
|
final officesAsync = ref.watch(officesProvider);
|
|
final notificationsAsync = ref.watch(notificationsProvider);
|
|
final profilesAsync = ref.watch(profilesProvider);
|
|
final realtime = ref.watch(realtimeControllerProvider);
|
|
|
|
final showSkeleton =
|
|
realtime.isConnecting ||
|
|
ticketsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
|
officesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
|
profilesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
|
notificationsAsync.maybeWhen(loading: () => true, orElse: () => false);
|
|
|
|
if (_isInitial) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
setState(() => _isInitial = false);
|
|
});
|
|
}
|
|
final effectiveShowSkeleton = showSkeleton || _isInitial;
|
|
|
|
return Stack(
|
|
children: [
|
|
ResponsiveBody(
|
|
maxWidth: double.infinity,
|
|
child: Skeletonizer(
|
|
enabled: effectiveShowSkeleton,
|
|
child: ticketsAsync.when(
|
|
data: (tickets) {
|
|
if (tickets.isEmpty) {
|
|
return const Center(child: Text('No tickets yet.'));
|
|
}
|
|
final officeById = <String, Office>{
|
|
for (final office in officesAsync.valueOrNull ?? <Office>[])
|
|
office.id: office,
|
|
};
|
|
final profileById = <String, Profile>{
|
|
for (final profile
|
|
in profilesAsync.valueOrNull ?? <Profile>[])
|
|
profile.id: profile,
|
|
};
|
|
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
|
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'),
|
|
),
|
|
...officesSorted.map(
|
|
(office) => DropdownMenuItem<String?>(
|
|
value: office.id,
|
|
child: Text(office.name),
|
|
),
|
|
),
|
|
];
|
|
final statusOptions = _ticketStatusOptions(tickets);
|
|
final filteredTickets = _applyTicketFilters(
|
|
tickets,
|
|
subjectQuery: _subjectController.text,
|
|
officeId: _selectedOfficeId,
|
|
status: _selectedStatus,
|
|
dateRange: _selectedDateRange,
|
|
);
|
|
final summaryDashboard = _StatusSummaryRow(
|
|
counts: _statusCounts(filteredTickets),
|
|
);
|
|
final filterHeader = Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 220,
|
|
child: TextField(
|
|
controller: _subjectController,
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Subject',
|
|
prefixIcon: Icon(Icons.search),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 200,
|
|
child: DropdownButtonFormField<String?>(
|
|
isExpanded: true,
|
|
key: ValueKey(_selectedOfficeId),
|
|
initialValue: _selectedOfficeId,
|
|
items: officeOptions,
|
|
onChanged: (value) =>
|
|
setState(() => _selectedOfficeId = value),
|
|
decoration: const InputDecoration(labelText: 'Office'),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 180,
|
|
child: DropdownButtonFormField<String?>(
|
|
isExpanded: true,
|
|
key: ValueKey(_selectedStatus),
|
|
initialValue: _selectedStatus,
|
|
items: statusOptions,
|
|
onChanged: (value) =>
|
|
setState(() => _selectedStatus = value),
|
|
decoration: const InputDecoration(labelText: 'Status'),
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
final next = await showDateRangePicker(
|
|
context: context,
|
|
firstDate: DateTime(2020),
|
|
lastDate: AppTime.now().add(
|
|
const Duration(days: 365),
|
|
),
|
|
currentDate: AppTime.now(),
|
|
initialDateRange: _selectedDateRange,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _selectedDateRange = next);
|
|
},
|
|
icon: const Icon(Icons.date_range),
|
|
label: Text(
|
|
_selectedDateRange == null
|
|
? 'Date range'
|
|
: AppTime.formatDateRange(_selectedDateRange!),
|
|
),
|
|
),
|
|
if (_hasTicketFilters)
|
|
TextButton.icon(
|
|
onPressed: () => setState(() {
|
|
_subjectController.clear();
|
|
_selectedOfficeId = null;
|
|
_selectedStatus = null;
|
|
_selectedDateRange = null;
|
|
}),
|
|
icon: const Icon(Icons.close),
|
|
label: const Text('Clear'),
|
|
),
|
|
],
|
|
);
|
|
final listBody = TasQAdaptiveList<Ticket>(
|
|
items: filteredTickets,
|
|
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
|
|
summaryDashboard: summaryDashboard,
|
|
filterHeader: filterHeader,
|
|
skeletonMode: effectiveShowSkeleton,
|
|
onRequestRefresh: () {
|
|
// For server-side pagination, update the query provider
|
|
// This will trigger a reload with new pagination parameters
|
|
ref.read(ticketsQueryProvider.notifier).state =
|
|
const TicketQuery(offset: 0, limit: 50);
|
|
},
|
|
onPageChanged: (firstRow) {
|
|
ref
|
|
.read(ticketsQueryProvider.notifier)
|
|
.update((q) => q.copyWith(offset: firstRow));
|
|
},
|
|
isLoading: false,
|
|
columns: [
|
|
TasQColumn<Ticket>(
|
|
header: 'Ticket ID',
|
|
technical: true,
|
|
cellBuilder: (context, ticket) => Text(ticket.id),
|
|
),
|
|
TasQColumn<Ticket>(
|
|
header: 'Subject',
|
|
cellBuilder: (context, ticket) => Text(ticket.subject),
|
|
),
|
|
TasQColumn<Ticket>(
|
|
header: 'Office',
|
|
cellBuilder: (context, ticket) => Text(
|
|
officeById[ticket.officeId]?.name ?? ticket.officeId,
|
|
),
|
|
),
|
|
TasQColumn<Ticket>(
|
|
header: 'Filed by',
|
|
cellBuilder: (context, ticket) =>
|
|
Text(_assignedAgent(profileById, ticket.creatorId)),
|
|
),
|
|
TasQColumn<Ticket>(
|
|
header: 'Status',
|
|
cellBuilder: (context, ticket) =>
|
|
_StatusBadge(status: ticket.status),
|
|
),
|
|
TasQColumn<Ticket>(
|
|
header: 'Timestamp',
|
|
technical: true,
|
|
cellBuilder: (context, ticket) =>
|
|
Text(_formatTimestamp(ticket.createdAt)),
|
|
),
|
|
],
|
|
mobileTileBuilder: (context, ticket, actions) {
|
|
final officeName =
|
|
officeById[ticket.officeId]?.name ?? ticket.officeId;
|
|
final assigned = _assignedAgent(
|
|
profileById,
|
|
ticket.creatorId,
|
|
);
|
|
final hasMention = unreadByTicketId[ticket.id] == true;
|
|
final typingState = ref.watch(
|
|
typingIndicatorProvider(ticket.id),
|
|
);
|
|
final showTyping = typingState.userIds.isNotEmpty;
|
|
return Card(
|
|
child: ListTile(
|
|
leading: const Icon(Icons.confirmation_number_outlined),
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
title: Text(ticket.subject),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(officeName),
|
|
const SizedBox(height: 2),
|
|
Text('Filed by: $assigned'),
|
|
const SizedBox(height: 4),
|
|
MonoText('ID ${ticket.id}'),
|
|
const SizedBox(height: 2),
|
|
Text(_formatTimestamp(ticket.createdAt)),
|
|
],
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_StatusBadge(status: 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}'),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
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: listBody),
|
|
],
|
|
);
|
|
},
|
|
loading: () => const SizedBox.shrink(),
|
|
error: (error, _) =>
|
|
Center(child: Text('Failed to load tickets: $error')),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 16,
|
|
bottom: 16,
|
|
child: SafeArea(
|
|
child: FloatingActionButton.extended(
|
|
onPressed: () => _showCreateTicketDialog(context, ref),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('New Ticket'),
|
|
),
|
|
),
|
|
),
|
|
const ReconnectOverlay(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _showCreateTicketDialog(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
) async {
|
|
final subjectController = TextEditingController();
|
|
final descriptionController = TextEditingController();
|
|
Office? selectedOffice;
|
|
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
bool saving = false;
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AlertDialog(
|
|
shape: AppSurfaces.of(context).dialogShape,
|
|
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',
|
|
),
|
|
enabled: !saving,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: descriptionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
),
|
|
maxLines: 3,
|
|
enabled: !saving,
|
|
),
|
|
const SizedBox(height: 12),
|
|
officesAsync.when(
|
|
data: (offices) {
|
|
if (offices.isEmpty) {
|
|
return const Text('No offices assigned.');
|
|
}
|
|
final officesSorted = List<Office>.from(offices)
|
|
..sort(
|
|
(a, b) => a.name.toLowerCase().compareTo(
|
|
b.name.toLowerCase(),
|
|
),
|
|
);
|
|
selectedOffice ??= officesSorted.first;
|
|
return DropdownButtonFormField<Office>(
|
|
key: ValueKey(selectedOffice?.id),
|
|
initialValue: selectedOffice,
|
|
items: officesSorted
|
|
.map(
|
|
(office) => DropdownMenuItem(
|
|
value: office,
|
|
child: Text(office.name),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: saving
|
|
? null
|
|
: (value) =>
|
|
setState(() => selectedOffice = value),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Office',
|
|
),
|
|
);
|
|
},
|
|
loading: () => const LinearProgressIndicator(),
|
|
error: (error, _) =>
|
|
Text('Failed to load offices: $error'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: saving
|
|
? null
|
|
: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: saving
|
|
? null
|
|
: () async {
|
|
final subject = subjectController.text.trim();
|
|
final description = descriptionController.text.trim();
|
|
if (subject.isEmpty ||
|
|
description.isEmpty ||
|
|
selectedOffice == null) {
|
|
showWarningSnackBar(
|
|
context,
|
|
'Fill out all fields.',
|
|
);
|
|
return;
|
|
}
|
|
setState(() => saving = true);
|
|
await ref
|
|
.read(ticketsControllerProvider)
|
|
.createTicket(
|
|
subject: subject,
|
|
description: description,
|
|
officeId: selectedOffice!.id,
|
|
);
|
|
// Supabase stream will emit the new ticket — no explicit
|
|
// invalidation required and avoids a temporary reload.
|
|
if (context.mounted) {
|
|
Navigator.of(dialogContext).pop();
|
|
showSuccessSnackBar(
|
|
context,
|
|
'Ticket "$subject" has been created successfully.',
|
|
);
|
|
}
|
|
},
|
|
child: saving
|
|
? const SizedBox(
|
|
height: 18,
|
|
width: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: 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>{},
|
|
);
|
|
}
|
|
}
|
|
|
|
List<DropdownMenuItem<String?>> _ticketStatusOptions(List<Ticket> tickets) {
|
|
final statuses = tickets.map((ticket) => ticket.status).toSet().toList()
|
|
..sort();
|
|
return [
|
|
const DropdownMenuItem<String?>(value: null, child: Text('All statuses')),
|
|
...statuses.map(
|
|
(status) => DropdownMenuItem<String?>(value: status, child: Text(status)),
|
|
),
|
|
];
|
|
}
|
|
|
|
List<Ticket> _applyTicketFilters(
|
|
List<Ticket> tickets, {
|
|
required String subjectQuery,
|
|
required String? officeId,
|
|
required String? status,
|
|
required DateTimeRange? dateRange,
|
|
}) {
|
|
final query = subjectQuery.trim().toLowerCase();
|
|
return tickets.where((ticket) {
|
|
if (query.isNotEmpty &&
|
|
!ticket.subject.toLowerCase().contains(query) &&
|
|
!ticket.id.toLowerCase().contains(query)) {
|
|
return false;
|
|
}
|
|
if (officeId != null && ticket.officeId != officeId) {
|
|
return false;
|
|
}
|
|
if (status != null && ticket.status != status) {
|
|
return false;
|
|
}
|
|
if (dateRange != null) {
|
|
final start = DateTime(
|
|
dateRange.start.year,
|
|
dateRange.start.month,
|
|
dateRange.start.day,
|
|
);
|
|
final end = DateTime(
|
|
dateRange.end.year,
|
|
dateRange.end.month,
|
|
dateRange.end.day,
|
|
23,
|
|
59,
|
|
59,
|
|
);
|
|
if (ticket.createdAt.isBefore(start) || ticket.createdAt.isAfter(end)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
Map<String, int> _statusCounts(List<Ticket> tickets) {
|
|
final counts = <String, int>{};
|
|
for (final ticket in tickets) {
|
|
counts.update(ticket.status, (value) => value + 1, ifAbsent: () => 1);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
class _StatusSummaryRow extends StatelessWidget {
|
|
const _StatusSummaryRow({required this.counts});
|
|
|
|
final Map<String, int> counts;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (counts.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final entries = counts.entries.toList()
|
|
..sort((a, b) => a.key.compareTo(b.key));
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth = constraints.maxWidth;
|
|
final maxPerRow = maxWidth >= 1000
|
|
? 4
|
|
: maxWidth >= 720
|
|
? 3
|
|
: maxWidth >= 480
|
|
? 2
|
|
: entries.length;
|
|
final perRow = entries.length < maxPerRow ? entries.length : maxPerRow;
|
|
final spacing = maxWidth < 480 ? 8.0 : 12.0;
|
|
final itemWidth = perRow == 0
|
|
? maxWidth
|
|
: (maxWidth - spacing * (perRow - 1)) / perRow;
|
|
|
|
return Wrap(
|
|
spacing: spacing,
|
|
runSpacing: spacing,
|
|
children: [
|
|
for (final entry in entries)
|
|
SizedBox(
|
|
width: itemWidth,
|
|
child: _StatusSummaryCard(
|
|
status: entry.key,
|
|
count: entry.value,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusSummaryCard extends StatelessWidget {
|
|
const _StatusSummaryCard({required this.status, required this.count});
|
|
|
|
final String status;
|
|
final int count;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final background = switch (status) {
|
|
'critical' => scheme.errorContainer,
|
|
'pending' => scheme.tertiaryContainer,
|
|
'promoted' => scheme.secondaryContainer,
|
|
'closed' => scheme.primaryContainer,
|
|
_ => scheme.surfaceContainerHigh,
|
|
};
|
|
final foreground = switch (status) {
|
|
'critical' => scheme.onErrorContainer,
|
|
'pending' => scheme.onTertiaryContainer,
|
|
'promoted' => scheme.onSecondaryContainer,
|
|
'closed' => scheme.onPrimaryContainer,
|
|
_ => scheme.onSurfaceVariant,
|
|
};
|
|
|
|
return Card(
|
|
color: background,
|
|
// summary cards are compact — use compact token for consistent density
|
|
shape: AppSurfaces.of(context).compactShape,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
status.toUpperCase(),
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: foreground,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
count.toString(),
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
color: foreground,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
|
if (userId == null || userId.isEmpty) {
|
|
return 'Unassigned';
|
|
}
|
|
final profile = profileById[userId];
|
|
if (profile == null) {
|
|
return userId;
|
|
}
|
|
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
|
}
|
|
|
|
String _formatTimestamp(DateTime value) {
|
|
final year = value.year.toString().padLeft(4, '0');
|
|
final month = value.month.toString().padLeft(2, '0');
|
|
final day = value.day.toString().padLeft(2, '0');
|
|
final hour = value.hour.toString().padLeft(2, '0');
|
|
final minute = value.minute.toString().padLeft(2, '0');
|
|
return '$year-$month-$day $hour:$minute';
|
|
}
|
|
|
|
class _StatusBadge extends StatelessWidget {
|
|
const _StatusBadge({required this.status});
|
|
|
|
final String status;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final background = switch (status) {
|
|
'critical' => scheme.errorContainer,
|
|
'pending' => scheme.tertiaryContainer,
|
|
'promoted' => scheme.secondaryContainer,
|
|
'closed' => scheme.primaryContainer,
|
|
_ => scheme.surfaceContainerHighest,
|
|
};
|
|
final foreground = switch (status) {
|
|
'critical' => scheme.onErrorContainer,
|
|
'pending' => scheme.onTertiaryContainer,
|
|
'promoted' => scheme.onSecondaryContainer,
|
|
'closed' => scheme.onPrimaryContainer,
|
|
_ => scheme.onSurfaceVariant,
|
|
};
|
|
|
|
return Badge(
|
|
backgroundColor: background,
|
|
label: Text(
|
|
status.toUpperCase(),
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: foreground,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.3,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|