import 'package:flutter/material.dart'; import '../../theme/m3_motion.dart'; import 'package:tasq/utils/app_time.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../models/office.dart'; import '../../models/profile.dart'; import '../../models/ticket.dart'; import '../../models/ticket_message.dart'; import '../../providers/notifications_provider.dart'; import '../../providers/supabase_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/tasks_provider.dart'; import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; import '../../utils/snackbar.dart'; import '../../widgets/app_breakpoints.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/status_pill.dart'; import '../../widgets/task_assignment_section.dart'; import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; class TicketDetailScreen extends ConsumerStatefulWidget { const TicketDetailScreen({super.key, required this.ticketId}); final String ticketId; @override ConsumerState createState() => _TicketDetailScreenState(); } class _TicketDetailScreenState extends ConsumerState { final _messageController = TextEditingController(); static const List _statusOptions = ['pending', 'promoted', 'closed']; String? _mentionQuery; int? _mentionStart; List _mentionResults = []; @override void initState() { super.initState(); Future.microtask( () => ref .read(notificationsControllerProvider) .markReadForTicket(widget.ticketId), ); } @override void dispose() { _messageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // Use per-item providers to avoid rebuilding when unrelated tickets/tasks // change. Only the specific ticket for this screen triggers rebuilds. final ticket = ref.watch(ticketByIdProvider(widget.ticketId)); final messagesAsync = ref.watch(ticketMessagesProvider(widget.ticketId)); final profilesAsync = ref.watch(profilesProvider); final officesAsync = ref.watch(officesProvider); final currentProfileAsync = ref.watch(currentProfileProvider); final typingState = ref.watch(typingIndicatorProvider(widget.ticketId)); final currentUserId = ref.read(supabaseClientProvider).auth.currentUser?.id; final canPromote = currentProfileAsync.maybeWhen( data: (profile) => profile != null && _canPromote(profile.role), orElse: () => false, ); final canSendMessages = ticket != null && ticket.status != 'closed'; final canAssign = currentProfileAsync.maybeWhen( data: (profile) => profile != null && _canAssignStaff(profile.role), orElse: () => false, ); final showAssign = canAssign && ticket?.status != 'closed'; final taskForTicket = ticket == null ? null : ref.watch(taskByTicketIdProvider(ticket.id)); final hasStaffMessage = _hasStaffMessage( messagesAsync.valueOrNull ?? const [], profilesAsync.valueOrNull ?? const [], ); final effectiveRespondedAt = ticket?.promotedAt != null && !hasStaffMessage ? ticket!.promotedAt : ticket?.respondedAt; return ResponsiveBody( child: LayoutBuilder( builder: (context, constraints) { if (ticket == null) { return const Center(child: Text('Ticket not found.')); } final isWide = constraints.maxWidth >= AppBreakpoints.desktop; final detailsContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( ticket.subject, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ), const SizedBox(width: 8), Builder( builder: (ctx) { final profile = currentProfileAsync.maybeWhen( data: (p) => p, orElse: () => null, ); final canEdit = profile != null && (profile.role == 'admin' || profile.role == 'programmer' || profile.role == 'dispatcher' || profile.role == 'it_staff' || profile.id == ticket.creatorId); if (!canEdit) return const SizedBox.shrink(); return IconButton( tooltip: 'Edit ticket', onPressed: () => _showEditTicketDialog(ctx, ref, ticket), icon: const Icon(Icons.edit), ); }, ), ], ), ), const SizedBox(height: 6), Align( alignment: Alignment.center, child: Text( _filedByLabel(profilesAsync, ticket), textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall, ), ), const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ _buildStatusChip(context, ref, ticket, canPromote), _MetaBadge( label: 'Office', value: _officeLabel(officesAsync, ticket), ), _MetaBadge( label: 'Ticket ID', value: ticket.id, isMono: true, ), ], ), const SizedBox(height: 12), // collapse the rest of the details so tall chat areas won't push off-screen ExpansionTile( key: const Key('ticket-details-expansion'), title: const Text('Details'), initiallyExpanded: true, childrenPadding: const EdgeInsets.only(top: 8), children: [ Text(ticket.description), const SizedBox(height: 12), _buildTatRow(context, ticket, effectiveRespondedAt), if (taskForTicket != null) ...[ const SizedBox(height: 16), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TaskAssignmentSection( taskId: taskForTicket.id, canAssign: showAssign, ), ), const SizedBox(width: 8), IconButton( tooltip: 'Open task', onPressed: () => context.go('/tasks/${taskForTicket.id}'), icon: const Icon(Icons.open_in_new), ), ], ), ], ], ), ], ); final detailsCard = Card( child: Padding( padding: const EdgeInsets.all(20), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Align( alignment: Alignment.topLeft, child: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), ), detailsContent, ], ), ), ), ); final messagesCard = Card( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Column( children: [ Expanded( child: messagesAsync.when( data: (messages) { if (messages.isEmpty) { return const Center(child: Text('No messages yet.')); } final profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; return ListView.builder( reverse: true, padding: const EdgeInsets.fromLTRB(0, 16, 0, 72), itemCount: messages.length, itemBuilder: (context, index) { final message = messages[index]; final currentUserId = ref.read(supabaseClientProvider).auth.currentUser?.id; final isMe = currentUserId != null && message.senderId == currentUserId; final senderName = message.senderId == null ? 'System' : profileById[message.senderId]?.fullName ?? message.senderId!; final bubbleColor = isMe ? Theme.of(context).colorScheme.primaryContainer : Theme.of( context, ).colorScheme.surfaceContainerHighest; final textColor = isMe ? Theme.of( context, ).colorScheme.onPrimaryContainer : Theme.of(context).colorScheme.onSurface; return Align( alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (!isMe) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( senderName, style: Theme.of( context, ).textTheme.labelSmall, ), ), Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), constraints: const BoxConstraints( minWidth: 160, maxWidth: 520, ), decoration: BoxDecoration( color: bubbleColor, borderRadius: BorderRadius.only( topLeft: Radius.circular( AppSurfaces.of(context).cardRadius, ), topRight: Radius.circular( AppSurfaces.of(context).cardRadius, ), bottomLeft: Radius.circular( isMe ? AppSurfaces.of( context, ).cardRadius : 4, ), bottomRight: Radius.circular( isMe ? 4 : AppSurfaces.of( context, ).cardRadius, ), ), ), child: _buildMentionText( message.content, textColor, profilesAsync.valueOrNull ?? [], ), ), ], ), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center( child: Text('Failed to load messages: $error'), ), ), ), SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(0, 8, 0, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (typingState.userIds.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 6), child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6, ), decoration: BoxDecoration( color: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(16), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( _typingLabel( typingState.userIds, profilesAsync, ), style: Theme.of( context, ).textTheme.labelSmall, ), const SizedBox(width: 8), TypingDots( size: 8, color: Theme.of( context, ).colorScheme.primary, ), ], ), ), ), if (_mentionQuery != null) Padding( padding: const EdgeInsets.only(bottom: 8), child: _buildMentionList(profilesAsync), ), if (!canSendMessages) Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( 'Messaging is disabled for closed tickets.', style: Theme.of(context).textTheme.labelMedium, ), ), Row( children: [ Expanded( child: TextField( controller: _messageController, decoration: const InputDecoration( hintText: 'Message...', ), enabled: canSendMessages, textInputAction: TextInputAction.send, onChanged: canSendMessages ? (_) => _handleComposerChanged( profilesAsync.valueOrNull ?? [], currentUserId, canSendMessages, ) : null, onSubmitted: canSendMessages ? (_) => _handleSendMessage( ref, profilesAsync.valueOrNull ?? [], currentUserId, canSendMessages, ) : null, ), ), const SizedBox(width: 12), IconButton( tooltip: 'Send', onPressed: canSendMessages ? () => _handleSendMessage( ref, profilesAsync.valueOrNull ?? [], currentUserId, canSendMessages, ) : null, icon: const Icon(Icons.send), ), ], ), ], ), ), ), ], ), ), ); final mainContent = isWide ? Row( children: [ Expanded(flex: 2, child: detailsCard), const SizedBox(width: 16), Expanded(flex: 3, child: messagesCard), ], ) : Column( children: [ detailsCard, const SizedBox(height: 12), Expanded(child: messagesCard), ], ); return mainContent; }, ), ); } String _filedByLabel(AsyncValue> profilesAsync, Ticket ticket) { final creatorId = ticket.creatorId; if (creatorId == null || creatorId.isEmpty) { return 'Filed by: Unknown'; } final profile = profilesAsync.valueOrNull ?.where((item) => item.id == creatorId) .firstOrNull; final name = profile?.fullName.isNotEmpty == true ? profile!.fullName : creatorId; return 'Filed by: $name'; } bool _hasStaffMessage(List messages, List profiles) { if (messages.isEmpty || profiles.isEmpty) { return false; } final staffIds = profiles .where( (profile) => profile.role == 'admin' || profile.role == 'dispatcher' || profile.role == 'it_staff', ) .map((profile) => profile.id) .toSet(); if (staffIds.isEmpty) { return false; } return messages.any( (message) => message.senderId != null && staffIds.contains(message.senderId!), ); } Future _handleSendMessage( WidgetRef ref, List profiles, String? currentUserId, bool canSendMessages, ) async { if (!canSendMessages) return; final content = _messageController.text.trim(); if (content.isEmpty) return; _maybeTypingController(widget.ticketId)?.stopTyping(); // Capture mentions and clear the composer immediately so the UI // remains snappy. Perform the network send and notification creation // in a fire-and-forget background Future. final mentionUserIds = _extractMentionedUserIds( content, profiles, currentUserId, ); if (mounted) { _messageController.clear(); _clearMentions(); } Future(() async { try { final message = await ref .read(ticketsControllerProvider) .sendTicketMessage(ticketId: widget.ticketId, content: content); if (mentionUserIds.isNotEmpty && currentUserId != null) { try { await ref .read(notificationsControllerProvider) .createMentionNotifications( userIds: mentionUserIds, actorId: currentUserId, ticketId: widget.ticketId, messageId: message.id, ); } catch (_) {} } } catch (e, st) { debugPrint('sendTicketMessage error: $e\n$st'); } }); } List _extractMentionedUserIds( String content, List profiles, String? currentUserId, ) { final lower = content.toLowerCase(); final mentioned = {}; for (final profile in profiles) { if (profile.id == currentUserId) continue; final label = profile.fullName.isEmpty ? profile.id : profile.fullName; if (label.isEmpty) continue; final token = '@${label.toLowerCase()}'; if (lower.contains(token)) { mentioned.add(profile.id); } } return mentioned.toList(); } void _handleComposerChanged( List profiles, String? currentUserId, bool canSendMessages, ) { if (!canSendMessages) { _maybeTypingController(widget.ticketId)?.stopTyping(); _clearMentions(); return; } _maybeTypingController(widget.ticketId)?.userTyping(); final text = _messageController.text; final cursor = _messageController.selection.baseOffset; if (cursor < 0) { _clearMentions(); return; } final textBeforeCursor = text.substring(0, cursor); final atIndex = textBeforeCursor.lastIndexOf('@'); if (atIndex == -1) { _clearMentions(); return; } if (atIndex > 0 && !_isWhitespace(textBeforeCursor[atIndex - 1])) { _clearMentions(); return; } final query = textBeforeCursor.substring(atIndex + 1); if (query.contains(RegExp(r'\s'))) { _clearMentions(); return; } final normalizedQuery = query.toLowerCase(); final candidates = profiles.where((profile) { if (profile.id == currentUserId) { return false; } final label = profile.fullName.isEmpty ? profile.id : profile.fullName; return label.toLowerCase().contains(normalizedQuery); }).toList(); setState(() { _mentionQuery = query; _mentionStart = atIndex; _mentionResults = candidates.take(6).toList(); }); } void _clearMentions() { if (_mentionQuery == null && _mentionResults.isEmpty) { return; } setState(() { _mentionQuery = null; _mentionStart = null; _mentionResults = []; }); } // Safely obtain the typing controller for [ticketId]. TypingIndicatorController? _maybeTypingController(String ticketId) { try { final controller = ref.read(typingIndicatorProvider(ticketId).notifier); return controller.mounted ? controller : null; } on StateError { return null; } catch (_) { return null; } } bool _isWhitespace(String char) { return char.trim().isEmpty; } String _typingLabel( Set userIds, AsyncValue> profilesAsync, ) { final profileById = { for (final profile in profilesAsync.valueOrNull ?? []) profile.id: profile, }; final names = userIds .map((id) => profileById[id]?.fullName ?? id) .where((name) => name.isNotEmpty) .toList(); if (names.isEmpty) { return 'Someone is typing...'; } if (names.length == 1) { return '${names.first} is typing...'; } if (names.length == 2) { return '${names[0]} and ${names[1]} are typing...'; } return '${names[0]}, ${names[1]} and others are typing...'; } Widget _buildMentionList(AsyncValue> profilesAsync) { if (_mentionResults.isEmpty) { return const SizedBox.shrink(); } return Container( constraints: const BoxConstraints(maxHeight: 200), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular( AppSurfaces.of(context).compactCardRadius, ), ), child: ListView.separated( shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 8), itemCount: _mentionResults.length, separatorBuilder: (context, index) => const SizedBox(height: 4), itemBuilder: (context, index) { final profile = _mentionResults[index]; final label = profile.fullName.isEmpty ? profile.id : profile.fullName; return ListTile( dense: true, title: Text(label), onTap: () => _insertMention(profile), ); }, ), ); } void _insertMention(Profile profile) { final start = _mentionStart; if (start == null) { _clearMentions(); return; } final text = _messageController.text; final cursor = _messageController.selection.baseOffset; final end = cursor < 0 ? text.length : cursor; final label = profile.fullName.isEmpty ? profile.id : profile.fullName; final mentionText = '@$label '; final updated = text.replaceRange(start, end, mentionText); final newCursor = start + mentionText.length; _messageController.text = updated; _messageController.selection = TextSelection.collapsed(offset: newCursor); _clearMentions(); } Widget _buildTatRow( BuildContext context, Ticket ticket, DateTime? respondedAtOverride, ) { final respondedAt = respondedAtOverride ?? ticket.respondedAt; final responseDuration = respondedAt?.difference(ticket.createdAt); final triageEnd = _earliestDate(ticket.promotedAt, ticket.closedAt); final triageStart = respondedAt; final triageDuration = triageStart == null || triageEnd == null ? null : triageEnd.difference(triageStart); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Response time: ${responseDuration == null ? 'Pending' : _formatDuration(responseDuration)}', ), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Text( 'Triage duration: ${triageDuration == null ? 'Pending' : _formatDuration(triageDuration)}', ), ), IconButton( tooltip: 'View timeline', onPressed: () => _showTimelineDialog(context, ticket), icon: const Icon(Icons.access_time), ), ], ), ], ); } Widget _buildMentionText( String text, Color baseColor, List profiles, ) { final mentionColor = Theme.of(context).colorScheme.primary; final spans = _mentionSpans(text, baseColor, mentionColor, profiles); return RichText( text: TextSpan( children: spans, style: TextStyle(color: baseColor), ), ); } List _mentionSpans( String text, Color baseColor, Color mentionColor, List profiles, ) { final mentionLabels = profiles .map( (profile) => profile.fullName.isEmpty ? profile.id : profile.fullName, ) .where((label) => label.isNotEmpty) .map(_escapeRegExp) .toList(); final pattern = mentionLabels.isEmpty ? r'@\S+' : '@(?:${mentionLabels.join('|')})'; final matches = RegExp(pattern, caseSensitive: false).allMatches(text); if (matches.isEmpty) { return [ TextSpan( text: text, style: TextStyle(color: baseColor), ), ]; } final spans = []; var lastIndex = 0; for (final match in matches) { if (match.start > lastIndex) { spans.add( TextSpan( text: text.substring(lastIndex, match.start), style: TextStyle(color: baseColor), ), ); } spans.add( TextSpan( text: text.substring(match.start, match.end), style: TextStyle(color: mentionColor, fontWeight: FontWeight.w700), ), ); lastIndex = match.end; } if (lastIndex < text.length) { spans.add( TextSpan( text: text.substring(lastIndex), style: TextStyle(color: baseColor), ), ); } return spans; } String _escapeRegExp(String value) { return value.replaceAllMapped( RegExp(r'[\\^$.*+?()[\]{}|]'), (match) => '\\${match[0]}', ); } DateTime? _earliestDate(DateTime? first, DateTime? second) { if (first == null) return second; if (second == null) return first; return first.isBefore(second) ? first : second; } String _officeLabel(AsyncValue> officesAsync, Ticket ticket) { final offices = officesAsync.valueOrNull ?? []; final office = offices .where((item) => item.id == ticket.officeId) .firstOrNull; return office?.name ?? ticket.officeId; } Future _showTimelineDialog(BuildContext context, Ticket ticket) async { await m3ShowDialog( context: context, builder: (dialogContext) { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Ticket Timeline'), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _timelineRow('Created', ticket.createdAt), _timelineRow('Responded', ticket.respondedAt), _timelineRow('Promoted', ticket.promotedAt), _timelineRow('Closed', ticket.closedAt), ], ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Close'), ), ], ); }, ); } Future _showEditTicketDialog( BuildContext context, WidgetRef ref, Ticket ticket, ) async { final screenContext = context; final dialogShape = AppSurfaces.of(context).dialogShape; final officesAsync = ref.watch(officesOnceProvider); final subjectCtrl = TextEditingController(text: ticket.subject); final descCtrl = TextEditingController(text: ticket.description); String? selectedOffice = ticket.officeId; await m3ShowDialog( context: context, builder: (dialogContext) { var saving = false; return StatefulBuilder( builder: (dialogBuilderContext, setDialogState) { return AlertDialog( shape: dialogShape, title: const Text('Edit Ticket'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: subjectCtrl, enabled: !saving, decoration: const InputDecoration(labelText: 'Subject'), ), const SizedBox(height: 8), TextField( controller: descCtrl, enabled: !saving, decoration: const InputDecoration( labelText: 'Description', ), maxLines: 4, ), const SizedBox(height: 8), officesAsync.when( data: (offices) { final officesSorted = List.from(offices) ..sort( (a, b) => a.name.toLowerCase().compareTo( b.name.toLowerCase(), ), ); return DropdownButtonFormField( 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: saving ? null : (v) => setDialogState(() => selectedOffice = v), ); }, loading: () => const SizedBox.shrink(), error: (error, stack) => const SizedBox.shrink(), ), ], ), ), actions: [ TextButton( onPressed: saving ? null : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: saving ? null : () async { final subject = subjectCtrl.text.trim(); final desc = descCtrl.text.trim(); setDialogState(() => saving = true); try { await ref .read(ticketsControllerProvider) .updateTicket( ticketId: ticket.id, subject: subject.isEmpty ? null : subject, description: desc.isEmpty ? null : desc, officeId: selectedOffice, ); ref.invalidate(ticketsProvider); ref.invalidate(ticketByIdProvider(ticket.id)); if (!dialogContext.mounted || !screenContext.mounted) { return; } Navigator.of(dialogContext).pop(); showSuccessSnackBar( screenContext, 'Ticket updated', ); } catch (e) { if (!screenContext.mounted) return; showErrorSnackBar( screenContext, 'Failed to update ticket: $e', ); } finally { if (dialogContext.mounted) { setDialogState(() => saving = false); } } }, child: saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Save'), ), ], ); }, ); }, ); } Widget _timelineRow(String label, DateTime? value) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Text('$label: ${value == null ? '—' : AppTime.formatDate(value)}'), ); } String _formatDuration(Duration duration) { if (duration.inSeconds < 60) { return 'Less than a minute'; } final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60); if (hours > 0) { return '${hours}h ${minutes}m'; } return '${minutes}m'; } Widget _buildStatusChip( BuildContext context, WidgetRef ref, Ticket ticket, bool canPromote, ) { final isLocked = ticket.status == 'promoted' || ticket.status == 'closed'; final chip = StatusPill( label: _statusLabel(ticket.status), isEmphasized: ticket.status != 'pending', ); if (isLocked) { return chip; } final availableStatuses = canPromote ? _statusOptions : _statusOptions.where((status) => status != 'promoted').toList(); return PopupMenuButton( onSelected: (value) async { // Rely on the realtime stream to propagate the status change. await ref .read(ticketsControllerProvider) .updateTicketStatus(ticketId: ticket.id, status: value); }, itemBuilder: (context) => availableStatuses .map( (status) => PopupMenuItem( value: status, child: Text(_statusMenuLabel(status)), ), ) .toList(), child: chip, ); } String _statusLabel(String status) { return status.toUpperCase(); } String _statusMenuLabel(String status) { return switch (status) { 'pending' => 'Pending', 'promoted' => 'Promote to Task', 'closed' => 'Close', _ => status, }; } bool _canPromote(String role) { return role == 'admin' || role == 'dispatcher' || role == 'it_staff'; } bool _canAssignStaff(String role) { return role == 'admin' || role == 'dispatcher' || role == 'it_staff'; } } class _MetaBadge extends StatelessWidget { const _MetaBadge({required this.label, required this.value, this.isMono}); final String label; final String value; final bool? isMono; @override Widget build(BuildContext context) { final border = Theme.of(context).colorScheme.outlineVariant; final background = Theme.of(context).colorScheme.surfaceContainerLow; final textStyle = Theme.of(context).textTheme.labelSmall; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular( AppSurfaces.of(context).compactCardRadius, ), border: Border.all(color: border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(label, style: textStyle), const SizedBox(width: 6), if (isMono == true) MonoText(value, style: textStyle) else Text(value, style: textStyle), ], ), ); } } extension _FirstOrNull on Iterable { T? get firstOrNull => isEmpty ? null : first; }