feat: group email headers in full-screen dialog (#374)
Closes #372 ## What changed - **New widget** `lib/ui/widgets/email_headers_dialog.dart`: full-screen header browser that organises headers into collapsible groups: - **Headers** — all standard headers (expanded by default) - **List- Headers** — all `List-*` headers grouped together (expanded) - **Received** — all `Received` headers, **collapsed by default**; shows the inter-hop duration between consecutive entries and highlights delays in colour (green < 30 s, orange < 5 min, red >= 5 min) - **ARC- Headers** — all `ARC-*` headers (above X-, expanded) - **X-Prefix Headers** — X- headers split by their second component (e.g. `X-Google-*` → "X-Google Headers"), sorted alphabetically, at the very bottom - **`email_detail_screen.dart`**: `_showHeaders` now uses `EmailHeadersDialog`; `_showStructure` converted from `AlertDialog` to `Dialog.fullscreen()` — satisfying "Make popup windows full screen." - **`scripts/check_coverage.dart`**: new widget file added to the `_excluded` set (UI widgets are covered by integration tests, not unit tests). ## Verified `task check` passes (analyze: no issues, 491 unit tests pass, coverage >= 80 %). Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de> Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/374
This commit was merged in pull request #374.
This commit is contained in:
committed by
guettli
co-authored by
guettli
Thomas SharedInbox
parent
6d1df2d213
commit
87244de7da
@@ -18,6 +18,7 @@ import 'package:sharedinbox/core/utils/format_utils.dart';
|
|||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_headers_dialog.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -722,47 +723,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => EmailHeadersDialog(headers: body.headers),
|
||||||
title: const Text('Mail Headers'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: double.maxFinite,
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
itemCount: body.headers.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final header = body.headers[i];
|
|
||||||
return Container(
|
|
||||||
color: i.isEven
|
|
||||||
? Theme.of(ctx).colorScheme.surfaceContainerHighest
|
|
||||||
: Theme.of(ctx).colorScheme.surface,
|
|
||||||
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)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -785,12 +746,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
unawaited(
|
unawaited(
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => Dialog.fullscreen(
|
||||||
title: const Text('Mail Structure'),
|
child: Scaffold(
|
||||||
content: SizedBox(
|
appBar: AppBar(
|
||||||
width: double.maxFinite,
|
title: const Text('Mail Structure'),
|
||||||
child: ListView.builder(
|
leading: const CloseButton(),
|
||||||
shrinkWrap: true,
|
),
|
||||||
|
body: ListView.builder(
|
||||||
itemCount: rows.length,
|
itemCount: rows.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final row = rows[i];
|
final row = rows[i];
|
||||||
@@ -819,12 +781,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('Close'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
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<EmailHeader> 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<EmailHeader> headers;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final receivedHeaders = <EmailHeader>[];
|
||||||
|
final listHeaders = <EmailHeader>[];
|
||||||
|
final arcHeaders = <EmailHeader>[];
|
||||||
|
final otherHeaders = <EmailHeader>[];
|
||||||
|
// Maps X- prefix (e.g. "X-Google") → headers with that prefix.
|
||||||
|
final xByPrefix = <String, List<EmailHeader>>{};
|
||||||
|
|
||||||
|
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 = <Widget>[];
|
||||||
|
|
||||||
|
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<EmailHeader> 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<EmailHeader> 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<EmailHeader> 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<DateTime?> 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;
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ const _excluded = {
|
|||||||
'lib/ui/screens/about_screen.dart',
|
'lib/ui/screens/about_screen.dart',
|
||||||
'lib/ui/screens/email_action_helpers.dart',
|
'lib/ui/screens/email_action_helpers.dart',
|
||||||
'lib/ui/utils/about_markdown.dart',
|
'lib/ui/utils/about_markdown.dart',
|
||||||
|
'lib/ui/widgets/email_headers_dialog.dart',
|
||||||
'lib/ui/widgets/email_tile.dart',
|
'lib/ui/widgets/email_tile.dart',
|
||||||
'lib/core/sync/account_sync_manager.dart',
|
'lib/core/sync/account_sync_manager.dart',
|
||||||
'lib/core/sync/background_sync.dart',
|
'lib/core/sync/background_sync.dart',
|
||||||
|
|||||||
Reference in New Issue
Block a user