import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:sharedinbox/core/models/email.dart'; /// Full-screen dialog for browsing email headers, organised into groups. class EmailHeadersDialog extends StatelessWidget { const EmailHeadersDialog({super.key, required this.headers}); final List headers; @override Widget build(BuildContext context) { return Dialog.fullscreen( child: Scaffold( appBar: AppBar( title: const Text('Mail Headers'), leading: const CloseButton(), ), body: _HeadersBody(headers: headers), ), ); } } class _HeadersBody extends StatelessWidget { const _HeadersBody({required this.headers}); final List headers; @override Widget build(BuildContext context) { final receivedHeaders = []; final listHeaders = []; final arcHeaders = []; final otherHeaders = []; // Maps X- prefix (e.g. "X-Google") → headers with that prefix. final xByPrefix = >{}; for (final h in headers) { final lower = h.name.toLowerCase(); if (lower == 'received') { receivedHeaders.add(h); continue; } if (lower.startsWith('list-')) { listHeaders.add(h); continue; } if (lower.startsWith('arc-')) { arcHeaders.add(h); continue; } if (lower.startsWith('x-')) { final parts = h.name.split('-'); // "X-Foo-Bar-Baz" → prefix "X-Foo"; "X-Single" → prefix "X-Single". final prefix = parts.length >= 3 ? '${parts[0]}-${parts[1]}' : h.name; xByPrefix.putIfAbsent(prefix, () => []).add(h); continue; } otherHeaders.add(h); } final sections = []; if (otherHeaders.isNotEmpty) { sections.add(_HeadersSection(title: 'Headers', headers: otherHeaders)); } if (listHeaders.isNotEmpty) { sections.add( _HeadersSection(title: 'List- Headers', headers: listHeaders), ); } if (receivedHeaders.isNotEmpty) { sections.add(_ReceivedSection(headers: receivedHeaders)); } if (arcHeaders.isNotEmpty) { sections.add( _HeadersSection(title: 'ARC- Headers', headers: arcHeaders), ); } // X- headers at bottom, each prefix in its own collapsible group. final sortedPrefixes = xByPrefix.keys.toList() ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); for (final prefix in sortedPrefixes) { sections.add( _HeadersSection( title: '$prefix Headers', headers: xByPrefix[prefix]!, ), ); } return ListView(children: sections); } } class _HeadersSection extends StatelessWidget { const _HeadersSection({required this.title, required this.headers}); final String title; final List headers; @override Widget build(BuildContext context) { return ExpansionTile( title: Text('$title (${headers.length})'), children: [ for (var i = 0; i < headers.length; i++) _HeaderRow(header: headers[i], index: i), ], ); } } /// Received headers section — collapsed by default; shows inter-hop delays. class _ReceivedSection extends StatelessWidget { const _ReceivedSection({required this.headers}); final List headers; @override Widget build(BuildContext context) { final entries = _buildEntries(headers); return ExpansionTile( title: Text('Received (${headers.length})'), children: [ for (var i = 0; i < entries.length; i++) ...[ _HeaderRow(header: entries[i].header, index: i), if (entries[i].delay != null) _DelayRow(delay: entries[i].delay!), ], ], ); } static List<_ReceivedEntry> _buildEntries(List headers) { final timestamps = headers.map((h) => _parseReceivedTimestamp(h.value)).toList(); return [ for (var i = 0; i < headers.length; i++) _ReceivedEntry( header: headers[i], delay: _computeDelay(timestamps, i), ), ]; } static Duration? _computeDelay(List timestamps, int i) { if (i >= timestamps.length - 1) return null; final current = timestamps[i]; final next = timestamps[i + 1]; if (current == null || next == null) return null; final d = current.difference(next); return d.isNegative ? Duration.zero : d; } } class _ReceivedEntry { const _ReceivedEntry({required this.header, this.delay}); final EmailHeader header; final Duration? delay; } class _HeaderRow extends StatelessWidget { const _HeaderRow({required this.header, required this.index}); final EmailHeader header; final int index; @override Widget build(BuildContext context) { final bg = index.isEven ? Theme.of(context).colorScheme.surfaceContainerHighest : Theme.of(context).colorScheme.surface; return Container( color: bg, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: SelectableText( header.name, style: const TextStyle(fontWeight: FontWeight.bold), ), ), const SizedBox(width: 8), Expanded(flex: 2, child: SelectableText(header.value)), ], ), ); } } class _DelayRow extends StatelessWidget { const _DelayRow({required this.delay}); final Duration delay; @override Widget build(BuildContext context) { final color = _delayColor(delay); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), child: Row( children: [ Icon(Icons.arrow_downward, size: 14, color: color), const SizedBox(width: 4), Text( _formatDuration(delay), style: TextStyle( fontSize: 12, color: color, fontWeight: delay.inSeconds >= 30 ? FontWeight.bold : FontWeight.normal, ), ), ], ), ); } } /// Parses the RFC 2822 timestamp from a Received header value. /// /// Received headers end with `; date`, e.g.: /// by mx.example.com; Mon, 1 Jan 2024 12:00:00 +0000 (UTC) DateTime? _parseReceivedTimestamp(String value) { final semiIndex = value.lastIndexOf(';'); if (semiIndex < 0) return null; var s = value.substring(semiIndex + 1).trim(); // Strip parenthesised comments like (UTC). s = s.replaceAll(RegExp(r'\([^)]*\)'), ' ').trim(); // Strip leading day-of-week abbreviation like "Mon, ". s = s.replaceFirst(RegExp(r'^[A-Za-z]{2,4},\s*'), ''); // Collapse runs of whitespace. s = s.replaceAll(RegExp(r'\s+'), ' ').trim(); for (final fmt in [ DateFormat('dd MMM yyyy HH:mm:ss Z', 'en_US'), DateFormat('d MMM yyyy HH:mm:ss Z', 'en_US'), DateFormat('dd MMM yyyy HH:mm:ss', 'en_US'), DateFormat('d MMM yyyy HH:mm:ss', 'en_US'), ]) { try { return fmt.parse(s); } catch (_) {} } return null; } String _formatDuration(Duration d) { if (d.inSeconds < 60) return '${d.inSeconds}s'; if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds.remainder(60)}s'; return '${d.inHours}h ${d.inMinutes.remainder(60)}m'; } Color _delayColor(Duration d) { if (d.inSeconds < 30) return Colors.green; if (d.inSeconds < 300) return Colors.orange; return Colors.red; }