From a82927cae8555bb23c3e394276af023be80f1030 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Thu, 4 Jun 2026 14:53:50 +0200 Subject: [PATCH] create screenshots. --- .gitignore | 1 + Taskfile.yml | 6 + test/screenshot_automation_test.dart | 422 +++++++++++++++++++++++++++ test/widget/helpers.dart | 9 + 4 files changed, 438 insertions(+) create mode 100644 test/screenshot_automation_test.dart diff --git a/.gitignore b/.gitignore index 9107d22..6711b54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # --- Flutter/Dart --- coverage/ +screenshots/ .dart_tool/ .dart-tool/ .packages diff --git a/Taskfile.yml b/Taskfile.yml index 933fe42..db97430 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -712,6 +712,12 @@ tasks: cmds: - scripts/ci_logs.sh "{{.RUN}}" "{{.JOB}}" + screenshots: + desc: Generate Play Store promotional screenshots (30 golden files — 3 devices × 2 themes × 5 scenes) + deps: [_preflight, _codegen] + cmds: + - fvm flutter test test/screenshot_automation_test.dart --update-goldens + check: desc: Full check suite — unit tests first, then integration (merges coverage), then gate deps: [analyze, build-linux, test] diff --git a/test/screenshot_automation_test.dart b/test/screenshot_automation_test.dart new file mode 100644 index 0000000..cf65d0a --- /dev/null +++ b/test/screenshot_automation_test.dart @@ -0,0 +1,422 @@ +// Generates Play Store promotional screenshots for all three device classes. +// +// Run with: +// fvm flutter test test/screenshot_automation_test.dart --update-goldens +// +// Output: screenshots/{phone,tablet_7in,tablet_10in}/{light,dark}/.png +// at the repository root (one directory above test/). + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:sharedinbox/core/models/account.dart'; +import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/core/models/mailbox.dart'; +import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/screens/email_list_screen.dart'; + +import 'widget/helpers.dart'; + +// --------------------------------------------------------------------------- +// Device configurations +// --------------------------------------------------------------------------- + +typedef _Device = ({String name, double width, double height}); + +const _devices = <_Device>[ + (name: 'phone', width: 1080.0, height: 1920.0), + (name: 'tablet_7in', width: 1200.0, height: 1920.0), + (name: 'tablet_10in', width: 1600.0, height: 2560.0), +]; + +// --------------------------------------------------------------------------- +// Sample data — fixed date so golden files are stable between runs +// --------------------------------------------------------------------------- + +const _kAccount = Account( + id: 'acc-1', + displayName: 'Alice', + email: 'alice@sharedinbox.de', + imapHost: 'imap.sharedinbox.de', + smtpHost: 'smtp.sharedinbox.de', +); + +final _kDate = DateTime(2025, 5, 14, 10, 30); + +Email _email({ + required String id, + required String subject, + required String fromName, + required String fromEmail, + bool isSeen = true, + bool isFlagged = false, + bool hasAttachment = false, + String? preview, +}) => + Email( + id: id, + accountId: 'acc-1', + mailboxPath: 'INBOX', + uid: int.parse(id.split(':').last), + subject: subject, + receivedAt: _kDate, + sentAt: _kDate, + from: [EmailAddress(name: fromName, email: fromEmail)], + to: const [EmailAddress(name: 'Alice', email: 'alice@sharedinbox.de')], + cc: const [], + isSeen: isSeen, + isFlagged: isFlagged, + hasAttachment: hasAttachment, + preview: preview, + ); + +final _sampleEmails = [ + _email( + id: 'acc-1:1', + subject: 'Re: Project kick-off next week', + fromName: 'Maria Hoffmann', + fromEmail: 'maria@corp.example', + isSeen: false, + preview: 'Sounds great! I will prepare the slides beforehand.', + ), + _email( + id: 'acc-1:2', + subject: 'Your invoice #2024-0312 is ready', + fromName: 'Billing', + fromEmail: 'billing@service.example', + isSeen: false, + preview: 'Your invoice for May is attached as a PDF.', + ), + _email( + id: 'acc-1:3', + subject: 'Team lunch — Friday 12:30', + fromName: 'Thomas Müller', + fromEmail: 'thomas@corp.example', + isFlagged: true, + preview: 'The Italian place on Main Street. RSVP by Thursday please.', + ), + _email( + id: 'acc-1:4', + subject: 'Quarterly review agenda', + fromName: 'HR Team', + fromEmail: 'hr@corp.example', + preview: + "Please find the agenda for next week's quarterly review attached.", + ), + _email( + id: 'acc-1:5', + subject: 'Weekend hiking trip — photos inside', + fromName: 'Jonas Weber', + fromEmail: 'jonas@personal.example', + hasAttachment: true, + preview: 'Had such a great time! Here are the photos from Saturday.', + ), + _email( + id: 'acc-1:6', + subject: 'Reminder: dentist appointment tomorrow', + fromName: 'City Dental', + fromEmail: 'noreply@citydental.example', + preview: 'Your appointment is confirmed for Thursday at 14:00.', + ), + _email( + id: 'acc-1:7', + subject: 'Re: Feedback on the draft', + fromName: 'Laura Schmidt', + fromEmail: 'laura@corp.example', + isSeen: false, + preview: 'I left some comments on page 3. Overall it looks really solid!', + ), + _email( + id: 'acc-1:8', + subject: 'Flight confirmation PNR XYZ123', + fromName: 'Sunshine Airlines', + fromEmail: 'noreply@airline.example', + preview: + 'Your booking is confirmed. Check-in opens 24 hours before departure.', + ), +]; + +final _sampleMailboxes = [ + const Mailbox( + id: 'acc-1:INBOX', + accountId: 'acc-1', + path: 'INBOX', + name: 'INBOX', + role: 'inbox', + unreadCount: 3, + totalCount: 8, + ), + const Mailbox( + id: 'acc-1:Sent', + accountId: 'acc-1', + path: 'Sent', + name: 'Sent', + role: 'sent', + unreadCount: 0, + totalCount: 42, + ), + const Mailbox( + id: 'acc-1:Drafts', + accountId: 'acc-1', + path: 'Drafts', + name: 'Drafts', + role: 'drafts', + unreadCount: 0, + totalCount: 1, + ), + const Mailbox( + id: 'acc-1:Trash', + accountId: 'acc-1', + path: 'Trash', + name: 'Trash', + role: 'trash', + unreadCount: 0, + totalCount: 7, + ), +]; + +// Email shown in the detail scene. +final _detailEmail = _email( + id: 'acc-1:1', + subject: 'Re: Project kick-off next week', + fromName: 'Maria Hoffmann', + fromEmail: 'maria@corp.example', +); + +const _detailBody = EmailBody( + emailId: 'acc-1:1', + attachments: [], + textBody: 'Hi Alice,\n\n' + 'Sounds great! I will prepare the slides beforehand so we have ' + 'something concrete to discuss.\n\n' + 'Looking forward to meeting everyone!\n\n' + 'Best,\nMaria', +); + +// Emails shown when the user searches for "invoice". +final _searchResults = [ + _email( + id: 'acc-1:2', + subject: 'Your invoice #2024-0312 is ready', + fromName: 'Billing', + fromEmail: 'billing@service.example', + isSeen: false, + ), + _email( + id: 'acc-1:9', + subject: 'Invoice for March services', + fromName: 'Cloud Services', + fromEmail: 'noreply@cloud.example', + ), +]; + +// --------------------------------------------------------------------------- +// Provider override sets for each scene +// --------------------------------------------------------------------------- + +List _inboxOverrides() => [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([_kAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(_sampleMailboxes), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: _sampleEmails), + ), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + searchHistoryRepositoryProvider.overrideWithValue( + FakeSearchHistoryRepository(), + ), + syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)), + ]; + +List _detailOverrides() => [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([_kAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(_sampleMailboxes), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository( + emails: _sampleEmails, + emailDetail: _detailEmail, + emailBody: _detailBody, + ), + ), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)), + ]; + +List _composeOverrides() => [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([_kAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(_sampleMailboxes), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository(emails: _sampleEmails), + ), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + searchHistoryRepositoryProvider.overrideWithValue( + FakeSearchHistoryRepository(), + ), + syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)), + ]; + +List _mailboxOverrides() => [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([_kAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(_sampleMailboxes), + ), + emailRepositoryProvider.overrideWithValue(FakeEmailRepository()), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)), + ]; + +List _searchOverrides() => [ + accountRepositoryProvider.overrideWithValue( + FakeAccountRepository([_kAccount]), + ), + mailboxRepositoryProvider.overrideWithValue( + FakeMailboxRepository(_sampleMailboxes), + ), + emailRepositoryProvider.overrideWithValue( + FakeEmailRepository( + emails: _sampleEmails, + searchResults: _searchResults, + ), + ), + draftRepositoryProvider.overrideWithValue(FakeDraftRepository()), + searchHistoryRepositoryProvider.overrideWithValue( + FakeSearchHistoryRepository(), + ), + syncLastErrorProvider.overrideWith((ref, _) => Stream.value(null)), + ]; + +// --------------------------------------------------------------------------- +// Tests — 3 devices × 2 themes × 5 scenes = 30 golden files +// --------------------------------------------------------------------------- + +void main() { + for (final device in _devices) { + for (final themeMode in [ThemeMode.light, ThemeMode.dark]) { + final themeName = themeMode == ThemeMode.light ? 'light' : 'dark'; + // Golden files are stored relative to this test file (test/). + // The ../ prefix places them at repo root under screenshots/. + final dir = '../screenshots/${device.name}/$themeName'; + + group('${device.name}/$themeName', () { + void setDevice(WidgetTester tester) { + tester.view.physicalSize = Size(device.width, device.height); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + } + + testWidgets('inbox_list', (tester) async { + setDevice(tester); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: _inboxOverrides(), + themeMode: themeMode, + ), + ); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('$dir/inbox_list.png'), + ); + }); + + testWidgets('email_detail', (tester) async { + setDevice(tester); + await tester.pumpWidget( + buildApp( + // The colon in "acc-1:1" must be percent-encoded in the URL. + initialLocation: + '/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A1', + overrides: _detailOverrides(), + themeMode: themeMode, + ), + ); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('$dir/email_detail.png'), + ); + }); + + testWidgets('compose', (tester) async { + setDevice(tester); + // Start at the inbox, then navigate to compose with pre-fill extras + // so GoRouter can pass them to ComposeScreen via state.extra. + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: _composeOverrides(), + themeMode: themeMode, + ), + ); + await tester.pumpAndSettle(); + GoRouter.of(tester.element(find.byType(EmailListScreen))).go( + '/compose', + extra: { + 'accountId': 'acc-1', + 'prefillTo': 'thomas@corp.example', + 'prefillSubject': 'Re: Team lunch — Friday 12:30', + 'prefillBody': + 'Hi Thomas,\n\nCount me in! See you on Friday.\n\nBest,\nAlice', + }, + ); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('$dir/compose.png'), + ); + }); + + testWidgets('mailbox_list', (tester) async { + setDevice(tester); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes', + overrides: _mailboxOverrides(), + themeMode: themeMode, + ), + ); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('$dir/mailbox_list.png'), + ); + }); + + testWidgets('search_results', (tester) async { + setDevice(tester); + await tester.pumpWidget( + buildApp( + initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails', + overrides: _searchOverrides(), + themeMode: themeMode, + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(SearchBar), 'invoice'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('$dir/search_results.png'), + ); + }); + }); + } + } +} diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 26c9704..4ce00ae 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -421,6 +421,7 @@ Widget buildApp({ required String initialLocation, required List overrides, UserPreferencesRepository? userPreferences, + ThemeMode themeMode = ThemeMode.light, }) { final testRouter = GoRouter( initialLocation: initialLocation, @@ -544,10 +545,18 @@ Widget buildApp({ ], child: MaterialApp.router( routerConfig: testRouter, + themeMode: themeMode, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true, ), + darkTheme: ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.dark, + ), + useMaterial3: true, + ), ), ); }