Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 8563d19e2c fix: move overflow actions into popup menu so three-dot menu is always visible (#312)
Too many standalone icon buttons in the email detail AppBar caused the
PopupMenuButton to be clipped off-screen on narrow mobile displays.
Moved Forward, Move to folder, Snooze, and Mark as spam into the popup
menu, leaving only Reply, Archive, Delete, and Flag as top-level icons.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:18:22 +02:00
Thomas SharedInbox 551ac67daa fix: move sync health report to its own row below each account tile (#311)
The sync health widget was inside ListTile.subtitle, which caused it to
be squeezed to near-zero width by the trailing Row (status icon + menu).
Move it to a full-width Padding row below the ListTile so it can display
properly without layout pressure from the trailing widget.
2026-05-28 06:49:12 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 91c86d72ef fix: check issue state not labels in merged-PR catch-up
Closed issues can still have labels; the old `if not labels` guard
never fired for issue #272, so the loop retried the no-op close every
minute and never reached the ToPlan/Ready issue logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:49:12 +02:00
5 changed files with 143 additions and 109 deletions
+39 -36
View File
@@ -120,43 +120,13 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return ListTile(
leading: const Icon(Icons.account_circle),
title: Text(account.displayName),
subtitle: Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${account.email}\n$typeLabel'),
const SizedBox(height: 4),
health.when(
data: (h) {
if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
return Row(
children: [
const Text('Sync health: '),
Icon(
h.isHealthy ? Icons.verified : Icons.warning_amber,
size: 14,
color: h.isHealthy ? Colors.green : Colors.orange,
),
const SizedBox(width: 4),
Flexible(
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(' ($date)', style: const TextStyle(fontSize: 10)),
],
);
},
loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'),
),
],
),
ListTile(
leading: const Icon(Icons.account_circle),
title: Text(account.displayName),
subtitle: Text('${account.email}\n$typeLabel'),
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -167,7 +137,8 @@ class _AccountTile extends ConsumerWidget {
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
data: (_) =>
const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
@@ -215,6 +186,38 @@ class _AccountTile extends ConsumerWidget {
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
),
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
child: health.when(
data: (h) {
if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
return Row(
children: [
const Text('Sync health: '),
Icon(
h.isHealthy ? Icons.verified : Icons.warning_amber,
size: 14,
color: h.isHealthy ? Colors.green : Colors.orange,
),
const SizedBox(width: 4),
Expanded(
child: Text(
h.isHealthy
? 'Healthy'
: _formatDiscrepancies(h.discrepancySummary),
),
),
Text(' ($date)', style: const TextStyle(fontSize: 10)),
],
);
},
loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'),
),
),
],
);
}
+26 -29
View File
@@ -77,15 +77,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
},
),
IconButton(
icon: const Icon(Icons.forward),
tooltip: 'Forward',
onPressed: header == null
? null
: () {
unawaited(_forward(context, header, body));
},
),
IconButton(
icon: const Icon(Icons.archive),
tooltip: 'Archive',
@@ -121,25 +112,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (context.mounted) _navigateTo(context, header, nextEmailId);
},
),
IconButton(
icon: const Icon(Icons.report_outlined),
tooltip: 'Mark as spam',
onPressed: header == null
? null
: () {
unawaited(_markAsSpam(context, header));
},
),
IconButton(
icon: const Icon(Icons.drive_file_move_outline),
tooltip: 'Move to folder',
onPressed: header == null ? null : () => _moveTo(context, header),
),
IconButton(
icon: const Icon(Icons.access_time),
tooltip: 'Snooze',
onPressed: header == null ? null : () => _snooze(context, header),
),
IconButton(
icon: Icon(
_isFlagged ? Icons.star : Icons.star_border,
@@ -154,10 +126,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
PopupMenuButton<String>(
itemBuilder: (ctx) => [
const PopupMenuItem(
value: 'forward',
child: Text('Forward'),
),
const PopupMenuItem(
value: 'move',
child: Text('Move to folder'),
),
const PopupMenuItem(
value: 'snooze',
child: Text('Snooze'),
),
const PopupMenuItem(
value: 'spam',
child: Text('Mark as spam'),
),
const PopupMenuItem(
value: 'mark_unread',
child: Text('Mark as unread'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'headers',
child: Text('Show Mail Headers'),
@@ -172,7 +161,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
),
],
onSelected: (value) async {
if (value == 'mark_unread') {
if (value == 'forward' && header != null) {
unawaited(_forward(context, header, body));
} else if (value == 'move' && header != null) {
unawaited(_moveTo(context, header));
} else if (value == 'snooze' && header != null) {
unawaited(_snooze(context, header));
} else if (value == 'spam' && header != null) {
unawaited(_markAsSpam(context, header));
} else if (value == 'mark_unread') {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) _navigateTo(context, header, nextEmailId);
+5 -3
View File
@@ -912,9 +912,11 @@ def _run_loop() -> int:
if not m:
continue
issue_num = int(m.group(1))
labels = _get_issue_labels(issue_num)
if not labels:
# Issue is likely already closed — skip.
try:
issue_data = _tea_get(f"/repos/{REPO}/issues/{issue_num}")
except RuntimeError:
continue
if issue_data.get("state") != "open":
continue
pr_number = pr["number"]
print(f"Catch-up (merged PR): PR #{pr_number} for issue #{issue_num} was merged — closing.")
+24
View File
@@ -252,5 +252,29 @@ void main() {
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
},
);
testWidgets(
'sync health row is positioned below the account name row',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts',
overrides: baseOverrides(
accounts: [kTestAccount],
syncHealth: SyncHealthRow(
accountId: kTestAccount.id,
lastVerifiedAt: DateTime(2024, 6),
isHealthy: true,
),
),
),
);
await tester.pumpAndSettle();
final namePos = tester.getTopLeft(find.text('Alice')).dy;
final healthPos = tester.getTopLeft(find.textContaining('Healthy')).dy;
expect(healthPos, greaterThan(namePos));
},
);
});
}
+15 -7
View File
@@ -271,7 +271,8 @@ void main() {
expect(find.textContaining('carol@example.com'), findsAtLeastNWidgets(1));
});
testWidgets('Mark as spam button is present in app bar', (tester) async {
testWidgets('Mark as spam is in popup menu, not a standalone button',
(tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -282,12 +283,19 @@ void main() {
);
await tester.pumpAndSettle();
// No standalone icon button for mark as spam.
expect(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
findsOneWidget,
findsNothing,
);
// It appears in the popup menu.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
expect(find.text('Mark as spam'), findsOneWidget);
});
testWidgets('Mark as spam shows dialog when no junk folder',
@@ -304,11 +312,11 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(
find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam',
),
);
// Open the popup menu first, then tap Mark as spam.
await tester.tap(find.byType(PopupMenuButton<String>));
await tester.pumpAndSettle();
await tester.tap(find.text('Mark as spam'));
await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget);