Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 e16dab6752 test: add agentloop code test comment to DEVELOPMENT.md
Closes #335

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 09:12:17 +02:00
Bot of Thomas Güttler d905cd653f fix: check Docker availability before falling back to local Dagger engine (#329) (#333) 2026-05-29 23:19:14 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 e21cde0a3c fix: allow forgejo-actions as issue author in agent loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:52:56 +02:00
Bot of Thomas Güttler 50a6678ec2 feat: reimplement user preferences, archive, configurable navigation (#315) (#324) 2026-05-29 19:08:12 +02:00
Bot of Thomas Güttler 91083218d4 fix: diff from last deployed SHA to catch all changes since last deploy (#320) (#332) 2026-05-29 17:34:21 +02:00
Bot of Thomas Güttler adc4eb6f6d feat: remove publish-website from deploy.yml, schedule website.yml hourly (#325) (#330) 2026-05-29 12:53:18 +02:00
Bot of Thomas Güttler 05d00bdf09 fix: move overflow actions into popup menu so three-dot menu is always visible (#312) (#323) 2026-05-28 07:19:11 +02:00
Bot of Thomas Güttler c45775be92 fix: move sync health report to own row below each account (#311) (#322) 2026-05-28 06:53:11 +02:00
47fc534a8d fix: disable github-actions manager to suppress GitHub token warning (#285) (#306)
## Summary

- Disables the `github-actions` Renovate manager in `renovate.json`
- Removes the previous `fileMatch` override that pointed Renovate at Forgejo workflow files
- Stops Renovate from scanning workflow YAML files for action version updates, eliminating GitHub API calls and the "GitHub token is required" warning

## Test plan

- [ ] Verify `renovate.json` is valid JSON (done locally with `python3 -m json.tool`)
- [ ] Confirm the next Renovate run no longer produces the GitHub token warning in its logs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de>
Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/306
2026-05-28 05:03:02 +02:00
Bot of Thomas Güttler a5928c1aa6 fix: add _tea_get and merged-PR catch-up to close issues on merge (#305) (#310) 2026-05-28 00:07:13 +02:00
Bot of Thomas Güttler 7f3cd43d6e feat: add --dangerously-skip-permissions to claude --resume output (#304) (#309) 2026-05-27 23:48:12 +02:00
Bot of Thomas Güttler f0f210e5ab feat: configurable next action after single mail view (#300) (#308) 2026-05-27 23:33:14 +02:00
26 changed files with 825 additions and 223 deletions
+11 -46
View File
@@ -17,7 +17,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 0
- name: Detect Android and Linux changes - name: Detect Android and Linux changes
id: diff id: diff
@@ -48,7 +48,7 @@ jobs:
data = json.loads(r.read()) data = json.loads(r.read())
runs = [ runs = [
r for r in data.get("workflow_runs", []) r for r in data.get("workflow_runs", [])
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success" if r.get("status") == "success"
] ]
print(runs[0].get("commit_sha") or "") print(runs[0].get("commit_sha") or "")
except Exception as e: except Exception as e:
@@ -64,10 +64,17 @@ jobs:
exit 0 exit 0
fi fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files # Diff from the last successfully deployed commit to catch all changes since
# when the parent is unavailable (initial commit, shallow clone). # that deploy, not just the most recent commit. Falls back to HEAD~1 when
# LAST_DEPLOYED_SHA is unknown or not in local history.
if [ -n "$LAST_DEPLOYED_SHA" ] && git cat-file -e "$LAST_DEPLOYED_SHA" 2>/dev/null; then
echo "Diffing from last deployed SHA $LAST_DEPLOYED_SHA"
CHANGED=$(git diff --name-only "$LAST_DEPLOYED_SHA" HEAD 2>/dev/null \
|| git show --name-only --format= HEAD)
else
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \ CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
|| git show --name-only --format= HEAD) || git show --name-only --format= HEAD)
fi
echo "Changed files:" echo "Changed files:"
echo "$CHANGED" echo "$CHANGED"
@@ -204,48 +211,6 @@ jobs:
if: always() if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
publish-website:
name: Publish Website Build History
runs-on: ubuntu-latest
needs: [build-linux, deploy-playstore, deploy-apk]
if: |
always() &&
(needs.build-linux.result == 'success' || needs.deploy-playstore.result == 'success' || needs.deploy-apk.result == 'success')
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check runner tools
run: |
command -v dagger >/dev/null 2>&1 || { echo "ERROR: dagger is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
command -v task >/dev/null 2>&1 || { echo "ERROR: task is not installed in the runner image. Add it to .forgejo/Dockerfile."; exit 1; }
dpkg -s stunnel4 netcat-openbsd >/dev/null 2>&1 || { echo "ERROR: stunnel4/netcat-openbsd are not installed in the runner image. Add them to .forgejo/Dockerfile."; exit 1; }
- name: Setup Dagger Remote Engine (via stunnel)
env:
DAGGER_STUNNEL_URL: ${{ secrets.DAGGER_STUNNEL_URL }}
DAGGER_CA_CERT: ${{ secrets.DAGGER_CA_CERT }}
DAGGER_CLIENT_CERT: ${{ secrets.DAGGER_CLIENT_CERT }}
DAGGER_CLIENT_KEY: ${{ secrets.DAGGER_CLIENT_KEY }}
run: scripts/setup_dagger_remote.sh
- name: Generate build history and deploy website
if: ${{ secrets.SSH_PRIVATE_KEY != '' }}
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
SSH_USER: ${{ secrets.SSH_USER }}
SSH_HOST: ${{ secrets.SSH_HOST }}
DAGGER_NO_NAG: "1"
run: task publish-website
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
label-deploy-health: label-deploy-health:
name: Update Deploy Health Label name: Update Deploy Health Label
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2
View File
@@ -1,6 +1,8 @@
name: Update Website name: Update Website
on: on:
schedule:
- cron: '0 * * * *' # every hour on the hour
push: push:
branches: [main] branches: [main]
paths: paths:
+2
View File
@@ -188,3 +188,5 @@ Using SSH to `localhost` is preferred over complex X11/Wayland permission hacks.
## Daily Workflow ## Daily Workflow
Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands. Refer to the [README.md](./README.md#daily-workflow) for common development tasks and commands.
<!-- agentloop code test passed -->
+1 -1
View File
@@ -294,7 +294,7 @@ tasks:
for attempt in 1 2 3; do for attempt in 1 2 3; do
run_dagger "$@" && return 0 run_dagger "$@" && return 0
RC=$? RC=$?
if [ "$attempt" -lt 3 ] && grep -qE "connection reset|context canceled|connection refused|invalid return status code" "$DAGGER_OUT"; then if [ "$attempt" -lt 3 ] && { grep -qE "connection reset|context canceled|context deadline exceeded|connection refused|invalid return status code" "$DAGGER_OUT" || [ "$RC" -eq 2 ]; }; then
echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2 echo "$(_ts) dagger: network error on attempt $attempt/3, retrying..." >&2
elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then elif [ "$attempt" -lt 3 ] && grep -q "No space left on device" "$DAGGER_OUT"; then
echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2 echo "$(_ts) dagger: disk space error on attempt $attempt/3, pruning Dagger cache..." >&2
+12
View File
@@ -4,6 +4,18 @@ This file contains tasks which got implemented.
Tasks get moved from next.md to done.md Tasks get moved from next.md to done.md
## Tasks (2026-05-29)
- **Merge PR #307 — user preferences and configurable navigation (Issue #315)**: Confirmed that
all features from PR #307 (issue #299) were already merged into main via separate PRs:
- Configurable menu bar position (bottom/top) for mailbox view — merged via #298/#303
- Configurable back button position for single mail view — merged via #299/#307 features in #300
- Configurable "after mail action" (next message / return to mailbox) — merged via #300/#308
- Archive button with `resolveMailboxByRole` helper — merged via #287/#291, #286/#290
- User preferences DB schema (v34v36: `user_preferences` table) — in main
- PR #307 and issue #299 closed.
- Issue #315 closed.
## Tasks (2026-05-26) ## Tasks (2026-05-26)
- **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep - **Renovate Bot (Issue #257)**: Renovate Bot runs daily via Forgejo Actions to keep
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 34; const int dbSchemaVersion = 36;
+9 -1
View File
@@ -1,6 +1,14 @@
enum MenuPosition { bottom, top } enum MenuPosition { bottom, top }
enum AfterMailViewAction { nextMessage, showMailbox }
class UserPreferences { class UserPreferences {
const UserPreferences({this.menuPosition = MenuPosition.bottom}); const UserPreferences({
this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
});
final MenuPosition menuPosition; final MenuPosition menuPosition;
final MenuPosition mailViewButtonPosition;
final AfterMailViewAction afterMailViewAction;
} }
@@ -3,4 +3,6 @@ import 'package:sharedinbox/core/models/user_preferences.dart';
abstract class UserPreferencesRepository { abstract class UserPreferencesRepository {
Stream<UserPreferences> observePreferences(); Stream<UserPreferences> observePreferences();
Future<void> updateMenuPosition(MenuPosition position); Future<void> updateMenuPosition(MenuPosition position);
Future<void> updateMailViewButtonPosition(MenuPosition position);
Future<void> updateAfterMailViewAction(AfterMailViewAction action);
} }
+18
View File
@@ -313,6 +313,12 @@ class UserPreferences extends Table {
IntColumn get id => integer()(); IntColumn get id => integer()();
// 'bottom' (default) | 'top' // 'bottom' (default) | 'top'
TextColumn get menuPosition => text().withDefault(const Constant('bottom'))(); TextColumn get menuPosition => text().withDefault(const Constant('bottom'))();
// Added in schema v35: 'bottom' (default) | 'top'
TextColumn get mailViewButtonPosition =>
text().withDefault(const Constant('bottom'))();
// Added in schema v36: 'nextMessage' (default) | 'showMailbox'
TextColumn get afterMailViewAction =>
text().withDefault(const Constant('nextMessage'))();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
@@ -593,6 +599,18 @@ class AppDatabase extends _$AppDatabase {
if (from < 34) { if (from < 34) {
await m.createTable(userPreferences); await m.createTable(userPreferences);
} }
if (from >= 34 && from < 35) {
await m.addColumn(
userPreferences,
userPreferences.mailViewButtonPosition,
);
}
if (from >= 34 && from < 36) {
await m.addColumn(
userPreferences,
userPreferences.afterMailViewAction,
);
}
}, },
); );
} }
@@ -26,6 +26,28 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
); );
} }
@override
Future<void> updateMailViewButtonPosition(pref.MenuPosition position) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
mailViewButtonPosition: Value(position.name),
),
);
}
@override
Future<void> updateAfterMailViewAction(
pref.AfterMailViewAction action,
) async {
await _db.into(_db.userPreferences).insertOnConflictUpdate(
UserPreferencesCompanion(
id: const Value(_rowId),
afterMailViewAction: Value(action.name),
),
);
}
static pref.UserPreferences _rowToModel(UserPreferencesRow? row) { static pref.UserPreferences _rowToModel(UserPreferencesRow? row) {
if (row == null) return const pref.UserPreferences(); if (row == null) return const pref.UserPreferences();
return pref.UserPreferences( return pref.UserPreferences(
@@ -33,6 +55,14 @@ class UserPreferencesRepositoryImpl implements UserPreferencesRepository {
(e) => e.name == row.menuPosition, (e) => e.name == row.menuPosition,
orElse: () => pref.MenuPosition.bottom, orElse: () => pref.MenuPosition.bottom,
), ),
mailViewButtonPosition: pref.MenuPosition.values.firstWhere(
(e) => e.name == row.mailViewButtonPosition,
orElse: () => pref.MenuPosition.bottom,
),
afterMailViewAction: pref.AfterMailViewAction.values.firstWhere(
(e) => e.name == row.afterMailViewAction,
orElse: () => pref.AfterMailViewAction.nextMessage,
),
); );
} }
} }
+39 -36
View File
@@ -120,43 +120,13 @@ 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),
title: Text(account.displayName),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${account.email}\n$typeLabel'), ListTile(
const SizedBox(height: 4), leading: const Icon(Icons.account_circle),
health.when( title: Text(account.displayName),
data: (h) { subtitle: Text('${account.email}\n$typeLabel'),
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'),
),
],
),
isThreeLine: true, isThreeLine: true,
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -167,7 +137,8 @@ class _AccountTile extends ConsumerWidget {
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), 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( error: (e, _) => Tooltip(
message: e.toString(), message: e.toString(),
child: const Icon(Icons.error_outline, color: Colors.red), child: const Icon(Icons.error_outline, color: Colors.red),
@@ -215,6 +186,38 @@ class _AccountTile extends ConsumerWidget {
], ],
), ),
onTap: () => context.push('/accounts/${account.id}/mailboxes'), 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'),
),
),
],
); );
} }
+79 -35
View File
@@ -13,6 +13,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/core/utils/format_utils.dart'; 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';
@@ -76,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',
@@ -98,6 +90,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
tooltip: 'Delete', tooltip: 'Delete',
onPressed: () async { onPressed: () async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final destPath = await repo.deleteEmail(widget.emailId); final destPath = await repo.deleteEmail(widget.emailId);
if (header != null) { if (header != null) {
@@ -116,28 +109,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
if (context.mounted) context.pop(); 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,
@@ -152,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'),
@@ -170,9 +161,18 @@ 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);
await repo.setFlag(widget.emailId, seen: false); await repo.setFlag(widget.emailId, seen: false);
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} else if (value == 'headers' && body != null) { } else if (value == 'headers' && body != null) {
_showHeaders(context, body); _showHeaders(context, body);
} else if (value == 'structure' && body != null) { } else if (value == 'structure' && body != null) {
@@ -252,6 +252,39 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
); );
} }
Future<String?> _getNextEmailIdIfNeeded(Email? header) async {
if (header == null) return null;
final prefs = ref.read(userPreferencesProvider).value;
final action =
prefs?.afterMailViewAction ?? AfterMailViewAction.nextMessage;
if (action != AfterMailViewAction.nextMessage) return null;
final threads = await ref
.read(emailRepositoryProvider)
.observeThreads(header.accountId, header.mailboxPath)
.first;
final currentIndex =
threads.indexWhere((t) => t.emailIds.contains(widget.emailId));
if (currentIndex >= 0 && currentIndex + 1 < threads.length) {
return threads[currentIndex + 1].latestEmailId;
}
return null;
}
void _navigateTo(BuildContext context, Email? header, String? nextEmailId) {
if (!context.mounted) return;
if (nextEmailId != null && header != null) {
context.go(
'/accounts/${header.accountId}'
'/mailboxes/${Uri.encodeComponent(header.mailboxPath)}'
'/emails/${Uri.encodeComponent(nextEmailId)}',
);
} else {
context.pop();
}
}
Future<void> _downloadAndOpen(EmailAttachment att) async { Future<void> _downloadAndOpen(EmailAttachment att) async {
setState(() => _downloading.add(att.filename)); setState(() => _downloading.add(att.filename));
try { try {
@@ -403,6 +436,9 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
Future<void> _archive(BuildContext context, Email header) async { Future<void> _archive(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole( final mailbox = await resolveMailboxByRole(
context, context,
ref.read(mailboxRepositoryProvider), ref.read(mailboxRepositoryProvider),
@@ -432,10 +468,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _markAsSpam(BuildContext context, Email header) async { Future<void> _markAsSpam(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final mailbox = await resolveMailboxByRole( final mailbox = await resolveMailboxByRole(
context, context,
ref.read(mailboxRepositoryProvider), ref.read(mailboxRepositoryProvider),
@@ -465,7 +504,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _forward( Future<void> _forward(
@@ -490,6 +529,8 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
} }
Future<void> _moveTo(BuildContext context, Email header) async { Future<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
final mailboxRepo = ref.read(mailboxRepositoryProvider); final mailboxRepo = ref.read(mailboxRepositoryProvider);
final mailboxes = final mailboxes =
await mailboxRepo.observeMailboxes(header.accountId).first; await mailboxRepo.observeMailboxes(header.accountId).first;
@@ -538,10 +579,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
); );
if (context.mounted) context.pop(); if (context.mounted) _navigateTo(context, header, nextEmailId);
} }
Future<void> _snooze(BuildContext context, Email header) async { Future<void> _snooze(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
if (!context.mounted) return;
final until = await showModalBottomSheet<DateTime>( final until = await showModalBottomSheet<DateTime>(
context: context, context: context,
builder: (ctx) => const SnoozePicker(), builder: (ctx) => const SnoozePicker(),
@@ -569,7 +613,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
), ),
), ),
); );
context.pop(); _navigateTo(context, header, nextEmailId);
} }
} }
+23 -1
View File
@@ -7,6 +7,7 @@ import 'package:intl/intl.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/models/user_preferences.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/widgets/secure_email_webview.dart'; import 'package:sharedinbox/ui/widgets/secure_email_webview.dart';
@@ -28,9 +29,16 @@ class ThreadDetailScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final repo = ref.watch(emailRepositoryProvider); final repo = ref.watch(emailRepositoryProvider);
final prefs =
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
final buttonAtBottom = prefs.mailViewButtonPosition == MenuPosition.bottom;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Thread')), appBar: AppBar(
title: const Text('Thread'),
automaticallyImplyLeading: !buttonAtBottom,
),
bottomNavigationBar: buttonAtBottom ? _buildBackButtonBar(context) : null,
body: StreamBuilder<List<Email>>( body: StreamBuilder<List<Email>>(
stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId), stream: repo.observeEmailsInThread(accountId, mailboxPath, threadId),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -60,6 +68,20 @@ class ThreadDetailScreen extends ConsumerWidget {
), ),
); );
} }
Widget _buildBackButtonBar(BuildContext context) {
return BottomAppBar(
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Back',
onPressed: () => context.pop(),
),
],
),
);
}
} }
class _EmailMessageCard extends ConsumerStatefulWidget { class _EmailMessageCard extends ConsumerStatefulWidget {
@@ -59,6 +59,84 @@ class UserPreferencesScreen extends ConsumerWidget {
], ],
), ),
), ),
const Divider(),
ListTile(
title: Text(
'Single mail view button position',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'Where the back button is shown in the single mail view.',
),
),
RadioGroup<MenuPosition>(
groupValue: prefs.mailViewButtonPosition,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateMailViewButtonPosition(value),
);
},
child: const Column(
children: [
RadioListTile<MenuPosition>(
title: Text('Bottom (default)'),
subtitle: Text(
'Show the back button at the bottom of the screen.',
),
value: MenuPosition.bottom,
),
RadioListTile<MenuPosition>(
title: Text('Top'),
subtitle: Text(
'Show the back button in the top bar.',
),
value: MenuPosition.top,
),
],
),
),
const Divider(),
ListTile(
title: Text(
'After mail action',
style: Theme.of(context).textTheme.titleSmall,
),
subtitle: const Text(
'What to show after deleting, archiving, or otherwise handling a message.',
),
),
RadioGroup<AfterMailViewAction>(
groupValue: prefs.afterMailViewAction,
onChanged: (value) {
if (value == null) return;
unawaited(
ref
.read(userPreferencesRepositoryProvider)
.updateAfterMailViewAction(value),
);
},
child: const Column(
children: [
RadioListTile<AfterMailViewAction>(
title: Text('Next message (default)'),
subtitle: Text(
'Show the next message in the mailbox.',
),
value: AfterMailViewAction.nextMessage,
),
RadioListTile<AfterMailViewAction>(
title: Text('Return to mailbox'),
subtitle: Text(
'Return to the message list.',
),
value: AfterMailViewAction.showMailbox,
),
],
),
),
], ],
), ),
), ),
+8 -8
View File
@@ -659,10 +659,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1088,26 +1088,26 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: test name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.30.0" version: "1.31.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.11"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.17"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -5,7 +5,7 @@
], ],
"labels": ["dependencies"], "labels": ["dependencies"],
"github-actions": { "github-actions": {
"fileMatch": ["^\\.forgejo/workflows/[^/]+\\.ya?ml$"] "enabled": false
}, },
"packageRules": [ "packageRules": [
{ {
+114 -33
View File
@@ -11,15 +11,18 @@ Flow
a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0 a. pending_issue type=="plan" → post resume comment, set State/Planned, exit 0
b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed b. pending_issue + open PR → check PR branch CI, merge/fix/wait as needed
c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them c. Catch-up: orphaned issue-N-fix PRs with passing CI → merge them
d. Main CI running → save pending-ci state, exit 0 d. Catch-up: close issues for PRs already merged (e.g., merged manually after
e. Main CI failed → start fix-CI agent (pushes fix to main), exit 0 State/Question was set because CI path filter didn't trigger) → exit 0
f. Main CI ok + pending_issue → close the issue, exit 0 (dead code path — e. Catch-up: Renovate PRs with passing CI → merge them
f. Main CI running → save pending-ci state, exit 0
g. Main CI failed → start fix-CI agent (pushes fix to main), exit 0
h. Main CI ok + pending_issue → close the issue, exit 0 (dead code path —
section 2b always returns first) section 2b always returns first)
g. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent, i. Main CI ok (or no run yet) → find oldest ToPlan issue, start plan agent,
save state, exit 0 save state, exit 0
h. No ToPlan issues → find oldest Ready issue, start issue agent, j. No ToPlan issues → find oldest Ready issue, start issue agent,
save state, exit 0 save state, exit 0
i. No Ready issues → print "nothing to do", exit 0 k. No Ready issues → print "nothing to do", exit 0
Issue agents must NOT close the issue themselves; the loop closes it after CI passes. Issue agents must NOT close the issue themselves; the loop closes it after CI passes.
Plan agents must NOT write any code or create PRs; they only post a plan comment. Plan agents must NOT write any code or create PRs; they only post a plan comment.
@@ -32,7 +35,7 @@ Output is written to ~/.sharedinbox-agent-logs/<session>-<timestamp>.log.
To resume the Claude conversation, look up the session UUID first: To resume the Claude conversation, look up the session UUID first:
scripts/agent_loop.py list # shows NAME and UUID columns scripts/agent_loop.py list # shows NAME and UUID columns
claude --resume <uuid> # use the UUID, NOT the session name claude --resume <uuid> --dangerously-skip-permissions # use the UUID, NOT the session name
""" """
import argparse import argparse
@@ -43,6 +46,8 @@ import shlex
import subprocess import subprocess
import sys import sys
import time import time
import urllib.error
import urllib.request
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -74,7 +79,7 @@ LABEL_TO_PLAN = "State/ToPlan"
LABEL_PLANNED = "State/Planned" LABEL_PLANNED = "State/Planned"
# Only pick up issues filed by these accounts. # Only pick up issues filed by these accounts.
ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2"} ALLOWED_ISSUE_AUTHORS = {"guettli", "guettlibot", "guettlibot2", "forgejo-actions"}
# ── helpers ─────────────────────────────────────────────────────────────────── # ── helpers ───────────────────────────────────────────────────────────────────
@@ -120,6 +125,30 @@ def _fgj_run_list(limit: int = 20) -> list[dict]:
return data if isinstance(data, list) else [] return data if isinstance(data, list) else []
def _tea_get(path: str) -> dict:
"""Make an authenticated GET request to the Codeberg API and return parsed JSON.
Tries FORGEJO_TOKEN env var first, then ``fgj auth token`` for the token.
"""
token = os.environ.get("FORGEJO_TOKEN", "")
if not token:
r = subprocess.run(
["fgj", "--hostname", "codeberg.org", "auth", "token"],
capture_output=True, text=True,
)
if r.returncode == 0:
token = r.stdout.strip()
url = f"https://codeberg.org/api/v1{path}"
req = urllib.request.Request(url)
if token:
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
raise RuntimeError(f"GET {path}: HTTP {e.code} {e.reason}") from e
def _set_labels(issue: int, add: list[str], remove: list[str]) -> None: def _set_labels(issue: int, add: list[str], remove: list[str]) -> None:
"""Add/remove labels on an issue via fgj.""" """Add/remove labels on an issue via fgj."""
cmd = ["issue", "edit", str(issue), "--repo", REPO] cmd = ["issue", "edit", str(issue), "--repo", REPO]
@@ -186,7 +215,8 @@ def _latest_main_ci_run() -> dict | None:
event=push and prettyref=main, so filtering by event alone is not enough. event=push and prettyref=main, so filtering by event alone is not enough.
We also require workflow_id == "ci.yml". We also require workflow_id == "ci.yml".
""" """
for run in _fgj_run_list(limit=20): data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
for run in data.get("workflow_runs", []):
if (run.get("event") == "push" if (run.get("event") == "push"
and run.get("prettyref") == "main" and run.get("prettyref") == "main"
and run.get("workflow_id") == "ci.yml"): and run.get("workflow_id") == "ci.yml"):
@@ -197,18 +227,21 @@ def _latest_main_ci_run() -> dict | None:
def _latest_ci_run_for_branch(branch: str) -> dict | None: def _latest_ci_run_for_branch(branch: str) -> dict | None:
"""Return the latest CI run for a specific branch, or None. """Return the latest CI run for a specific branch, or None.
For push events fgj reports the branch in ``prettyref``; for pull_request For pull_request events the branch is embedded in the JSON ``event_payload``
events ``prettyref`` is ``#N``, so we resolve the PR number first. field; for push events it appears directly in ``prettyref``.
""" """
runs = _fgj_run_list(limit=20) data = _tea_get(f"/repos/{REPO}/actions/runs?limit=20")
pr_data = _find_pr_for_branch(branch) for run in data.get("workflow_runs", []):
pr_ref = f"#{pr_data['number']}" if pr_data else None
for run in runs:
if run.get("event") == "pull_request": if run.get("event") == "pull_request":
if pr_ref and run.get("prettyref") == pr_ref: payload_str = run.get("event_payload", "")
if payload_str:
try:
payload = json.loads(payload_str)
if payload.get("pull_request", {}).get("head", {}).get("ref") == branch:
return run return run
elif run.get("event") == "push": except (json.JSONDecodeError, AttributeError):
if run.get("prettyref") == branch: pass
elif run.get("event") == "push" and run.get("prettyref") == branch:
return run return run
return None return None
@@ -269,6 +302,35 @@ def _open_renovate_prs() -> list[dict]:
return renovate_prs return renovate_prs
def _merged_issue_prs() -> list[dict]:
"""Return recently merged PRs with issue-{N}-fix branches, oldest-first.
Used for catch-up: if the loop set State/Question (e.g., no CI run detected)
but the PR was later merged manually, we still want to close the issue.
"""
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "list",
"--repo", REPO, "--state", "closed", "--json"],
capture_output=True, text=True,
)
if result.returncode != 0 or not result.stdout.strip():
return []
try:
prs = json.loads(result.stdout)
except json.JSONDecodeError:
return []
merged = []
for pr in prs:
if not pr.get("merged"):
continue
head = pr.get("head", {})
ref = head.get("ref") or head.get("label", "").split(":")[-1]
if re.match(r"^issue-\d+-fix$", ref or ""):
merged.append(pr)
merged.sort(key=lambda p: p["number"])
return merged
def _latest_ci_run_for_pr(pr_number: int) -> dict | None: def _latest_ci_run_for_pr(pr_number: int) -> dict | None:
"""Return the latest CI run triggered by a pull_request event for the given PR number.""" """Return the latest CI run triggered by a pull_request event for the given PR number."""
pr_ref = f"#{pr_number}" pr_ref = f"#{pr_number}"
@@ -307,17 +369,10 @@ def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: in
"merged" — PR closed after a retry "merged" — PR closed after a retry
"fallback" — all options exhausted; caller should set State/Question "fallback" — all options exhausted; caller should set State/Question
""" """
result = subprocess.run(
["fgj", "--hostname", "codeberg.org", "pr", "view", str(pr_number),
"--repo", REPO, "--json"],
capture_output=True, text=True,
)
pr_data: dict = {}
if result.returncode == 0 and result.stdout.strip():
try: try:
pr_data = json.loads(result.stdout) pr_data = _tea_get(f"/repos/{REPO}/pulls/{pr_number}")
except json.JSONDecodeError: except RuntimeError:
pass pr_data = {}
mergeable = pr_data.get("mergeable") mergeable = pr_data.get("mergeable")
if mergeable is False: if mergeable is False:
@@ -542,7 +597,7 @@ def cmd_list() -> int:
sessions.sort(reverse=True) sessions.sort(reverse=True)
total = len(sessions) total = len(sessions)
print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid>)") print(f" {'DATE':<16} {'NAME':<20} UUID (use with: claude --resume <uuid> --dangerously-skip-permissions)")
print(f" {'-'*16} {'-'*20} {'-'*36}") print(f" {'-'*16} {'-'*20} {'-'*36}")
for mtime, name, sid in sessions[:20]: for mtime, name, sid in sessions[:20]:
ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M") ts = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
@@ -626,9 +681,9 @@ def _run_loop() -> int:
session_name = state.get("session_name") session_name = state.get("session_name")
uuid = _find_session_uuid(session_name) if session_name else None uuid = _find_session_uuid(session_name) if session_name else None
if uuid: if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}" resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
elif session_name: elif session_name:
resume_cmd = f"claude --resume <uuid> # run: scripts/agent_loop.py list" resume_cmd = f"claude --resume <uuid> --dangerously-skip-permissions # run: scripts/agent_loop.py list"
else: else:
resume_cmd = "" resume_cmd = ""
git_info = _git_summary() git_info = _git_summary()
@@ -657,7 +712,7 @@ def _run_loop() -> int:
session_name = f"plan-issue-{pending_issue}" session_name = f"plan-issue-{pending_issue}"
uuid = _find_session_uuid(session_name) uuid = _find_session_uuid(session_name)
if uuid: if uuid:
resume_cmd = f"claude --resume {shlex.quote(uuid)}" resume_cmd = f"claude --resume {shlex.quote(uuid)} --dangerously-skip-permissions"
_comment_issue( _comment_issue(
pending_issue, pending_issue,
f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```", f"Planning complete. To resume this session:\n\n```\n{resume_cmd}\n```",
@@ -846,7 +901,33 @@ def _run_loop() -> int:
print(f"Merged PR #{pr_number}.") print(f"Merged PR #{pr_number}.")
return 0 return 0
# ── 2c. Catch-up: merge Renovate PRs with passing CI ───────────────────── # ── 2c. Catch-up: close issues whose PRs were already merged ─────────────
# Handles the case where State/Question was set (e.g., no CI run appeared
# because the changed paths didn't match ci.yml's path filter) but the PR
# was merged manually afterward. The next loop tick closes the issue.
for pr in _merged_issue_prs():
head = pr.get("head", {})
branch = head.get("ref") or head.get("label", "").split(":")[-1]
m = re.match(r"^issue-(\d+)-fix$", branch or "")
if not m:
continue
issue_num = int(m.group(1))
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.")
try:
_close_issue(issue_num)
except RuntimeError as e:
print(f"Catch-up (merged PR): could not close issue #{issue_num}: {e}")
continue
return 0
# ── 2d. Catch-up: merge Renovate PRs with passing CI ─────────────────────
# The merge-renovate CI job only fires on pull_request events. If a Renovate # The merge-renovate CI job only fires on pull_request events. If a Renovate
# PR had CI run before that job was added (or the automerge label was absent), # PR had CI run before that job was added (or the automerge label was absent),
# it stays open forever. Detect and merge those here. # it stays open forever. Detect and merge those here.
+6
View File
@@ -24,6 +24,12 @@ for attempt in $(seq 1 $MAX_PROBE_ATTEMPTS); do
fi fi
if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then if [ "$attempt" -eq "$MAX_PROBE_ATTEMPTS" ]; then
echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts" echo "Warning: No Dagger server responded on $host:$port after $MAX_PROBE_ATTEMPTS attempts"
if ! docker info >/dev/null 2>&1; then
echo "Error: Remote Dagger engine is unavailable AND local Docker daemon is not running."
echo "Cannot proceed. Ensure either the remote server at $host:$port is accessible"
echo "or that Docker is running locally (check: sudo systemctl start docker)."
exit 1
fi
echo "Remote engine unavailable — CI will use the local Dagger engine." echo "Remote engine unavailable — CI will use the local Dagger engine."
exit 0 exit 0
fi fi
+77 -1
View File
@@ -202,6 +202,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(10)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \ patch("agent_loop._set_labels", side_effect=fake_set_labels), \
@@ -229,6 +230,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(7)]), \
patch("agent_loop._set_labels", side_effect=fake_set_labels), \ patch("agent_loop._set_labels", side_effect=fake_set_labels), \
@@ -243,6 +245,7 @@ class TestMain(unittest.TestCase):
"""main() exits cleanly with 0 when there are no ready issues.""" """main() exits cleanly with 0 when there are no ready issues."""
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
patch("agent_loop._set_labels") as mock_labels, \ patch("agent_loop._set_labels") as mock_labels, \
@@ -263,6 +266,7 @@ class TestMain(unittest.TestCase):
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \ patch("agent_loop._ready_issues", return_value=[self._make_issue(42)]), \
patch("agent_loop._set_labels"), \ patch("agent_loop._set_labels"), \
@@ -442,6 +446,7 @@ class TestPendingCi(unittest.TestCase):
"type": "ci-fix", "type": "ci-fix",
}), \ }), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \ patch("agent_loop._latest_main_ci_run", return_value={"id": 1, "status": "success"}), \
patch("agent_loop._close_issue") as mock_close, \ patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
@@ -459,6 +464,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
@@ -471,6 +477,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \ patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
@@ -482,6 +489,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=run), \ patch("agent_loop._latest_main_ci_run", return_value=run), \
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
agent_loop._run_loop() agent_loop._run_loop()
@@ -493,6 +501,7 @@ class TestOutputFormat(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[issue]), \ patch("agent_loop._ready_issues", return_value=[issue]), \
patch("agent_loop._set_labels"), \ patch("agent_loop._set_labels"), \
@@ -714,7 +723,7 @@ class TestRunLoopResumeCommand(unittest.TestCase):
contextlib.redirect_stdout(buf): contextlib.redirect_stdout(buf):
agent_loop._run_loop() agent_loop._run_loop()
output = buf.getvalue() output = buf.getvalue()
self.assertIn(f"claude --resume {fake_uuid}", output) self.assertIn(f"claude --resume {fake_uuid} --dangerously-skip-permissions", output)
def test_resume_shows_list_hint_when_uuid_not_found(self): def test_resume_shows_list_hint_when_uuid_not_found(self):
buf = io.StringIO() buf = io.StringIO()
@@ -757,6 +766,7 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
ci_run = {"id": 999, "status": "success"} ci_run = {"id": 999, "status": "success"}
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[pr]), \ patch("agent_loop._open_issue_prs", return_value=[pr]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \ patch("agent_loop._latest_ci_run_for_pr", return_value=ci_run), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \ patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._merge_pr") as mock_merge, \ patch("agent_loop._merge_pr") as mock_merge, \
@@ -785,6 +795,71 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
mock_merge.assert_called_once_with(50) mock_merge.assert_called_once_with(50)
class TestMergedPrCatchup(unittest.TestCase):
"""Catch-up closes issues whose PRs were already merged outside the normal flow."""
def _make_merged_pr(self, pr_number=283, branch="issue-282-fix"):
return {"number": pr_number, "merged": True, "head": {"ref": branch}}
def test_closes_issue_when_pr_was_merged(self):
"""When a merged issue-N-fix PR exists and the issue still has labels, close it."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_called_once_with(282)
def test_skips_when_issue_has_no_labels(self):
"""When _get_issue_labels returns [] (likely already closed), skip the issue."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[]), \
patch("agent_loop._close_issue") as mock_close, \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
mock_close.assert_not_called()
def test_output_mentions_merged_pr_and_issue(self):
"""The catch-up log line names the PR number and issue number."""
pr = self._make_merged_pr(pr_number=283, branch="issue-282-fix")
buf = io.StringIO()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue"), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]), \
contextlib.redirect_stdout(buf):
agent_loop._run_loop()
output = buf.getvalue()
self.assertIn("283", output)
self.assertIn("282", output)
def test_continues_on_close_error(self):
"""If _close_issue raises, the loop continues instead of crashing."""
pr = self._make_merged_pr()
with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[pr]), \
patch("agent_loop._get_issue_labels", return_value=[agent_loop.LABEL_QUESTION]), \
patch("agent_loop._close_issue", side_effect=RuntimeError("already closed")), \
patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]):
result = agent_loop._run_loop()
self.assertEqual(result, 0)
class TestMergeFailsOpen(unittest.TestCase): class TestMergeFailsOpen(unittest.TestCase):
"""Tests for auto-resolution when a PR is still open after the merge command.""" """Tests for auto-resolution when a PR is still open after the merge command."""
@@ -928,6 +1003,7 @@ class TestHeartbeat(unittest.TestCase):
self.assertFalse(Path(self._tmp.name).exists()) self.assertFalse(Path(self._tmp.name).exists())
with patch("agent_loop._read_state", return_value=None), \ with patch("agent_loop._read_state", return_value=None), \
patch("agent_loop._open_issue_prs", return_value=[]), \ patch("agent_loop._open_issue_prs", return_value=[]), \
patch("agent_loop._merged_issue_prs", return_value=[]), \
patch("agent_loop._latest_main_ci_run", return_value=None), \ patch("agent_loop._latest_main_ci_run", return_value=None), \
patch("agent_loop._ready_issues", return_value=[]): patch("agent_loop._ready_issues", return_value=[]):
agent_loop._run_loop() agent_loop._run_loop()
+23 -2
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () { group('Migration', () {
test('schemaVersion matches expected value', () async { test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 34); expect(db.schemaVersion, 36);
await db.close(); await db.close();
}); });
@@ -202,6 +202,13 @@ void main() {
// v34: user_preferences table. // v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get(); await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
@@ -397,11 +404,18 @@ void main() {
// v34: user_preferences table. // v34: user_preferences table.
await db.customSelect('SELECT count(*) FROM user_preferences').get(); await db.customSelect('SELECT count(*) FROM user_preferences').get();
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
if (dbFile.existsSync()) dbFile.deleteSync(); if (dbFile.existsSync()) dbFile.deleteSync();
}); });
test('fresh install creates all tables at schemaVersion 34', () async { test('fresh install creates all tables at schemaVersion 36', () async {
final db = AppDatabase(NativeDatabase.memory()); final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get(); await db.select(db.accounts).get();
@@ -448,6 +462,13 @@ void main() {
expect(syncLogColumns, contains('error_stack_trace')); expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent')); expect(syncLogColumns, contains('is_permanent'));
// v35: mail_view_button_position column on user_preferences.
final userPrefsColumns = await _tableColumns(db, 'user_preferences');
expect(userPrefsColumns, contains('mail_view_button_position'));
// v36: after_mail_view_action column on user_preferences.
expect(userPrefsColumns, contains('after_mail_view_action'));
await db.close(); await db.close();
}); });
}); });
+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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+23 -3
View File
@@ -414,6 +414,7 @@ class _NoOpManageSieveProbeService implements ManageSieveProbeService {
Widget buildApp({ Widget buildApp({
required String initialLocation, required String initialLocation,
required List<Override> overrides, required List<Override> overrides,
UserPreferencesRepository? userPreferences,
}) { }) {
final testRouter = GoRouter( final testRouter = GoRouter(
initialLocation: initialLocation, initialLocation: initialLocation,
@@ -523,7 +524,7 @@ Widget buildApp({
const NoOpSyncLogRepository(), const NoOpSyncLogRepository(),
), ),
userPreferencesRepositoryProvider.overrideWithValue( userPreferencesRepositoryProvider.overrideWithValue(
FakeUserPreferencesRepository(), userPreferences ?? FakeUserPreferencesRepository(),
), ),
...overrides, ...overrides,
manageSieveProbeServiceProvider.overrideWith( manageSieveProbeServiceProvider.overrideWith(
@@ -624,18 +625,37 @@ Email testEmail({
class FakeUserPreferencesRepository implements UserPreferencesRepository { class FakeUserPreferencesRepository implements UserPreferencesRepository {
FakeUserPreferencesRepository({ FakeUserPreferencesRepository({
this.menuPosition = MenuPosition.bottom, this.menuPosition = MenuPosition.bottom,
this.mailViewButtonPosition = MenuPosition.bottom,
this.afterMailViewAction = AfterMailViewAction.nextMessage,
}); });
MenuPosition menuPosition; MenuPosition menuPosition;
MenuPosition mailViewButtonPosition;
AfterMailViewAction afterMailViewAction;
@override @override
Stream<UserPreferences> observePreferences() => Stream<UserPreferences> observePreferences() => Stream.value(
Stream.value(UserPreferences(menuPosition: menuPosition)); UserPreferences(
menuPosition: menuPosition,
mailViewButtonPosition: mailViewButtonPosition,
afterMailViewAction: afterMailViewAction,
),
);
@override @override
Future<void> updateMenuPosition(MenuPosition position) async { Future<void> updateMenuPosition(MenuPosition position) async {
menuPosition = position; menuPosition = position;
} }
@override
Future<void> updateMailViewButtonPosition(MenuPosition position) async {
mailViewButtonPosition = position;
}
@override
Future<void> updateAfterMailViewAction(AfterMailViewAction action) async {
afterMailViewAction = action;
}
} }
class FakeSearchHistoryRepository implements SearchHistoryRepository { class FakeSearchHistoryRepository implements SearchHistoryRepository {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/user_preferences.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'helpers.dart'; import 'helpers.dart';
@@ -142,6 +143,60 @@ void main() {
expect(find.byIcon(Icons.expand_more), findsOneWidget); expect(find.byIcon(Icons.expand_more), findsOneWidget);
}); });
testWidgets('shows bottom app bar with back button by default', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
});
testWidgets('hides bottom app bar when button position is top', (
tester,
) async {
final email = _threadEmail();
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
userPreferences: FakeUserPreferencesRepository(
mailViewButtonPosition: MenuPosition.top,
),
overrides: [
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository(),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email]),
),
],
),
);
await tester.pumpAndSettle();
expect(find.byType(BottomAppBar), findsNothing);
});
testWidgets('flagged email shows star icon', (tester) async { testWidgets('flagged email shows star icon', (tester) async {
final email = _threadEmail(isFlagged: true); final email = _threadEmail(isFlagged: true);
await tester.pumpWidget( await tester.pumpWidget(
+133 -8
View File
@@ -20,11 +20,13 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Menu bar position'), findsOneWidget); expect(find.text('Menu bar position'), findsOneWidget);
expect(find.text('Bottom (default)'), findsOneWidget); expect(find.text('Bottom (default)'), findsNWidgets(2));
expect(find.text('Top'), findsOneWidget); expect(find.text('Top'), findsNWidgets(2));
}); });
testWidgets('bottom option is selected by default', (tester) async { testWidgets('shows single mail view button position section', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/preferences', initialLocation: '/accounts/preferences',
@@ -33,12 +35,15 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final radioGroup = find.byType(RadioGroup<MenuPosition>); expect(
final widget = tester.widget<RadioGroup<MenuPosition>>(radioGroup); find.text('Single mail view button position'),
expect(widget.groupValue, MenuPosition.bottom); findsOneWidget,
);
}); });
testWidgets('tapping Top option updates the repo', (tester) async { testWidgets('menu position bottom option is selected by default', (
tester,
) async {
await tester.pumpWidget( await tester.pumpWidget(
buildApp( buildApp(
initialLocation: '/accounts/preferences', initialLocation: '/accounts/preferences',
@@ -47,7 +52,41 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('Top')); final radioGroups = find.byType(RadioGroup<MenuPosition>);
final menuGroup =
tester.widget<RadioGroup<MenuPosition>>(radioGroups.first);
expect(menuGroup.groupValue, MenuPosition.bottom);
});
testWidgets('mail view button position bottom is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<MenuPosition>);
final mailViewGroup =
tester.widget<RadioGroup<MenuPosition>>(radioGroups.last);
expect(mailViewGroup.groupValue, MenuPosition.bottom);
});
testWidgets('tapping Top in menu position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').first);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final repo = ProviderScope.containerOf( final repo = ProviderScope.containerOf(
@@ -57,5 +96,91 @@ void main() {
expect(repo.menuPosition, MenuPosition.top); expect(repo.menuPosition, MenuPosition.top);
}); });
testWidgets(
'tapping Top in mail view button position section updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Top').last);
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.mailViewButtonPosition, MenuPosition.top);
});
testWidgets('shows after mail action section', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
// Scroll down to reveal the new section below the fold.
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
expect(find.text('After mail action'), findsOneWidget);
expect(find.text('Next message (default)'), findsOneWidget);
expect(find.text('Return to mailbox'), findsOneWidget);
});
testWidgets('after mail action next message is selected by default', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
final radioGroups = find.byType(RadioGroup<AfterMailViewAction>);
final group =
tester.widget<RadioGroup<AfterMailViewAction>>(radioGroups.first);
expect(group.groupValue, AfterMailViewAction.nextMessage);
});
testWidgets('tapping Return to mailbox updates the repo', (
tester,
) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/preferences',
overrides: baseOverrides(),
),
);
await tester.pumpAndSettle();
await tester.drag(find.byType(ListView), const Offset(0, -500));
await tester.pumpAndSettle();
await tester.tap(find.text('Return to mailbox'));
await tester.pumpAndSettle();
final repo = ProviderScope.containerOf(
tester.element(find.byType(UserPreferencesScreen)),
).read(userPreferencesRepositoryProvider)
as FakeUserPreferencesRepository;
expect(repo.afterMailViewAction, AfterMailViewAction.showMailbox);
});
}); });
} }