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
+73 -70
View File
@@ -120,15 +120,76 @@ class _AccountTile extends ConsumerWidget {
final health = ref.watch(syncHealthProvider(account.id)); final health = ref.watch(syncHealthProvider(account.id));
final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP'; final typeLabel = account.type == AccountType.jmap ? 'JMAP' : 'IMAP';
return ListTile( return Column(
leading: const Icon(Icons.account_circle), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(account.displayName), children: [
subtitle: Column( ListTile(
crossAxisAlignment: CrossAxisAlignment.start, leading: const Icon(Icons.account_circle),
children: [ title: Text(account.displayName),
Text('${account.email}\n$typeLabel'), subtitle: Text('${account.email}\n$typeLabel'),
const SizedBox(height: 4), isThreeLine: true,
health.when( trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) =>
const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
),
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 8),
child: health.when(
data: (h) { data: (h) {
if (h == null) return const Text('Sync health: Not verified yet'); if (h == null) return const Text('Sync health: Not verified yet');
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0]; final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
@@ -141,7 +202,7 @@ class _AccountTile extends ConsumerWidget {
color: h.isHealthy ? Colors.green : Colors.orange, color: h.isHealthy ? Colors.green : Colors.orange,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Flexible( Expanded(
child: Text( child: Text(
h.isHealthy h.isHealthy
? 'Healthy' ? 'Healthy'
@@ -155,66 +216,8 @@ class _AccountTile extends ConsumerWidget {
loading: () => const Text('Sync health: checking...'), loading: () => const Text('Sync health: checking...'),
error: (e, _) => Text('Sync health error: $e'), error: (e, _) => Text('Sync health error: $e'),
), ),
], ),
), ],
isThreeLine: true,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
status.when(
loading: () => const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
data: (_) => const Icon(Icons.check_circle, color: Colors.green),
error: (e, _) => Tooltip(
message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red),
),
),
PopupMenuButton<_AccountAction>(
onSelected: (action) => _onAction(context, action),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AccountAction.syncLog,
child: Text('Sync log'),
),
const PopupMenuItem(
value: _AccountAction.verifySync,
child: Text('Verify sync health'),
),
const PopupMenuItem(
value: _AccountAction.forceSync,
child: Text('Force full sync'),
),
const PopupMenuItem(
value: _AccountAction.edit,
child: Text('Edit'),
),
if (_sieveSupported(account))
const PopupMenuItem(
value: _AccountAction.emailFiltersRemote,
child: Text('Server email filters'),
),
const PopupMenuItem(
value: _AccountAction.emailFiltersLocal,
child: Text('Local email filters'),
),
const PopupMenuItem(
value: _AccountAction.send,
child: Text('Send accounts'),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AccountAction.delete,
child: Text('Delete'),
),
],
),
],
),
onTap: () => context.push('/accounts/${account.id}/mailboxes'),
); );
} }
+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( IconButton(
icon: const Icon(Icons.archive), icon: const Icon(Icons.archive),
tooltip: 'Archive', tooltip: 'Archive',
@@ -121,25 +112,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (context.mounted) _navigateTo(context, header, nextEmailId); 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( IconButton(
icon: Icon( icon: Icon(
_isFlagged ? Icons.star : Icons.star_border, _isFlagged ? Icons.star : Icons.star_border,
@@ -154,10 +126,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: (ctx) => [ 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( const PopupMenuItem(
value: 'mark_unread', value: 'mark_unread',
child: Text('Mark as unread'), child: Text('Mark as unread'),
), ),
const PopupMenuDivider(),
const PopupMenuItem( const PopupMenuItem(
value: 'headers', value: 'headers',
child: Text('Show Mail Headers'), child: Text('Show Mail Headers'),
@@ -172,7 +161,15 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
], ],
onSelected: (value) async { 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); final nextEmailId = await _getNextEmailIdIfNeeded(header);
await repo.setFlag(widget.emailId, seen: false); await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) _navigateTo(context, header, nextEmailId); if (context.mounted) _navigateTo(context, header, nextEmailId);
+5 -3
View File
@@ -912,9 +912,11 @@ def _run_loop() -> int:
if not m: if not m:
continue continue
issue_num = int(m.group(1)) issue_num = int(m.group(1))
labels = _get_issue_labels(issue_num) try:
if not labels: issue_data = _tea_get(f"/repos/{REPO}/issues/{issue_num}")
# Issue is likely already closed — skip. except RuntimeError:
continue
if issue_data.get("state") != "open":
continue continue
pr_number = pr["number"] pr_number = pr["number"]
print(f"Catch-up (merged PR): PR #{pr_number} for issue #{issue_num} was merged — closing.") 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); 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)); 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( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42', initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
@@ -282,12 +283,19 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// No standalone icon button for mark as spam.
expect( expect(
find.byWidgetPredicate( find.byWidgetPredicate(
(w) => w is Tooltip && w.message == 'Mark as spam', (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', testWidgets('Mark as spam shows dialog when no junk folder',
@@ -304,11 +312,11 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( // Open the popup menu first, then tap Mark as spam.
find.byWidgetPredicate( await tester.tap(find.byType(PopupMenuButton<String>));
(w) => w is Tooltip && w.message == 'Mark as spam', await tester.pumpAndSettle();
),
); await tester.tap(find.text('Mark as spam'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('No spam folder found'), findsOneWidget); expect(find.text('No spam folder found'), findsOneWidget);