Compare commits

...
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 f6ca6a1108 ci: add macOS and Windows debug build jobs (D1)
Add build-macos and build-windows CI jobs that run flutter build
for each platform after the main check suite passes on main.

Both jobs use FVM to install the pinned Flutter version and run
codegen before building. They require platform-specific runners
labelled 'macos-latest' / 'windows-latest'; the jobs are skipped
automatically if no matching runner is registered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:55:46 +02:00
Bot of Thomas Güttler 2f1bff8922 ci: enforce ui/→data/ layer boundary (A5) (#53) 2026-05-14 11:41:34 +02:00
Bot of Thomas Güttler dd66c3834d test: golden tests for key EmailListScreen states (T5) (#52) 2026-05-14 11:33:45 +02:00
Bot of Thomas Güttler 548f4e92dc perf: cache formatted date strings in EmailListScreen (P5) (#51) 2026-05-14 11:31:19 +02:00
Bot of Thomas Güttler 5311720a7e fix: open HTML email links in external browser (S4) (#50) 2026-05-14 11:26:33 +02:00
11 changed files with 271 additions and 3 deletions
+66
View File
@@ -40,3 +40,69 @@ jobs:
- name: Build Linux - name: Build Linux
run: nix develop --command task build-linux-release run: nix develop --command task build-linux-release
build-macos:
name: Build macOS Debug
runs-on: macos-latest
needs: check
if: github.ref == 'refs/heads/main'
# Requires a macOS runner labelled 'macos-latest'.
# Jobs are skipped automatically when no matching runner is registered.
steps:
- uses: actions/checkout@v4
- name: Install FVM
run: dart pub global activate fvm
- name: Install Flutter via FVM
run: |
fvm install --skip-pub-get
fvm use --skip-pub-get
- name: Pub get
run: fvm flutter pub get --suppress-analytics
- name: Generate code
run: fvm flutter pub run build_runner build
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 --pretty=format:"* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s" --date=short > assets/changelog.txt
- name: Build macOS
run: fvm flutter build macos --debug --no-pub
build-windows:
name: Build Windows Debug
runs-on: windows-latest
needs: check
if: github.ref == 'refs/heads/main'
# Requires a Windows runner labelled 'windows-latest'.
# Jobs are skipped automatically when no matching runner is registered.
steps:
- uses: actions/checkout@v4
- name: Install FVM
run: dart pub global activate fvm
- name: Install Flutter via FVM
run: |
fvm install --skip-pub-get
fvm use --skip-pub-get
- name: Pub get
run: fvm flutter pub get --suppress-analytics
- name: Generate code
run: fvm flutter pub run build_runner build
- name: Generate changelog
run: |
mkdir -p assets
git log -n 50 "--pretty=format:* %ad [%h](https://codeberg.org/guettli/sharedinbox/commit/%H): %s" --date=short > assets/changelog.txt
- name: Build Windows
run: fvm flutter build windows --debug --no-pub
+12 -1
View File
@@ -368,7 +368,18 @@ tasks:
check-fast: check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration) desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene] deps: [analyze, check-coverage, check-hygiene, check-layers]
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
cmds:
- |
VIOLATIONS=$(grep -rn "package:sharedinbox/data/" lib/ui/ 2>/dev/null || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):"
echo "$VIOLATIONS"
exit 1
fi
check-hygiene: check-hygiene:
desc: Verify that no forbidden files (like home dir config) are tracked desc: Verify that no forbidden files (like home dir config) are tracked
+13 -1
View File
@@ -18,6 +18,14 @@ import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm'); final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
void _openLink(String? url, Map<String, String> attrs, dynamic _) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(launchUrl(uri, mode: LaunchMode.externalApplication));
}
}
class EmailDetailScreen extends ConsumerStatefulWidget { class EmailDetailScreen extends ConsumerStatefulWidget {
const EmailDetailScreen({super.key, required this.emailId}); const EmailDetailScreen({super.key, required this.emailId});
final String emailId; final String emailId;
@@ -553,7 +561,11 @@ class _SafeHtmlState extends State<_SafeHtml> {
(_) => ErrorWidget.builder = prev, (_) => ErrorWidget.builder = prev,
); );
return Html(data: widget.data, extensions: widget.extensions); return Html(
data: widget.data,
extensions: widget.extensions,
onLinkTap: _openLink,
);
} }
} }
+9 -1
View File
@@ -15,6 +15,14 @@ import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
final _dateFmt = DateFormat('MMM d'); final _dateFmt = DateFormat('MMM d');
// Cache formatted dates by local calendar day so DateFormat.format is called
// at most once per unique date rather than once per list item per rebuild.
final _formattedDates = <int, String>{};
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
String _fmtDate(DateTime dt) =>
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
class EmailListScreen extends ConsumerStatefulWidget { class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({ const EmailListScreen({
@@ -641,7 +649,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
const Icon(Icons.star, color: Colors.amber, size: 16), const Icon(Icons.star, color: Colors.amber, size: 16),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_dateFmt.format(t.latestDate), _fmtDate(t.latestDate),
style: Theme.of(ctx).textTheme.bodySmall, style: Theme.of(ctx).textTheme.bodySmall,
), ),
], ],
+13
View File
@@ -10,6 +10,7 @@ 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/utils/html_utils.dart'; import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d, HH:mm'); final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
@@ -168,6 +169,18 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
extensions: [ extensions: [
if (!_loadRemoteImages) _BlockRemoteImagesExtension(), if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
], ],
onLinkTap: (url, _, __) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(
launchUrl(
uri,
mode: LaunchMode.externalApplication,
),
);
}
},
), ),
] else ] else
SelectableText( SelectableText(
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
// Fixed-date emails so golden files don't change day to day.
final _kDate = DateTime(2024, 6);
Email _email({
String id = 'acc-1:1',
String subject = 'Hello world',
bool isSeen = true,
bool isFlagged = false,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: int.parse(id.split(':').last),
subject: subject,
receivedAt: _kDate,
sentAt: _kDate,
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: false,
);
List<Override> _overrides({
List<Email> emails = const [],
List<Email> searchResults = const [],
String? syncError,
}) =>
[
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: emails, searchResults: searchResults),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith(
(ref, _) => Stream.value(syncError),
),
];
void main() {
group('EmailListScreen goldens', () {
testWidgets('golden: empty state', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_empty.png'),
);
});
testWidgets('golden: list with emails', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review', isFlagged: true),
_email(id: 'acc-1:3', subject: 'Welcome to the project'),
],
),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_with_emails.png'),
);
});
testWidgets('golden: selection mode', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review'),
],
),
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Team standup notes'));
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_selection.png'),
);
});
testWidgets('golden: search with results', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
searchResults: [
_email(id: 'acc-1:5', subject: 'Project proposal'),
],
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(SearchBar), 'project');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_search_results.png'),
);
});
testWidgets('golden: error banner', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(syncError: 'Connection refused'),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_error_banner.png'),
);
});
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB