Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ca6a1108 | ||
|
|
2f1bff8922 | ||
|
|
dd66c3834d | ||
|
|
548f4e92dc | ||
|
|
5311720a7e |
@@ -40,3 +40,69 @@ jobs:
|
||||
|
||||
- name: Build Linux
|
||||
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
@@ -368,7 +368,18 @@ tasks:
|
||||
|
||||
check-fast:
|
||||
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:
|
||||
desc: Verify that no forbidden files (like home dir config) are tracked
|
||||
|
||||
@@ -18,6 +18,14 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
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 {
|
||||
const EmailDetailScreen({super.key, required this.emailId});
|
||||
final String emailId;
|
||||
@@ -553,7 +561,11 @@ class _SafeHtmlState extends State<_SafeHtml> {
|
||||
(_) => ErrorWidget.builder = prev,
|
||||
);
|
||||
|
||||
return Html(data: widget.data, extensions: widget.extensions);
|
||||
return Html(
|
||||
data: widget.data,
|
||||
extensions: widget.extensions,
|
||||
onLinkTap: _openLink,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@ import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||
|
||||
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 {
|
||||
const EmailListScreen({
|
||||
@@ -641,7 +649,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_dateFmt.format(t.latestDate),
|
||||
_fmtDate(t.latestDate),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
||||
|
||||
@@ -168,6 +169,18 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
extensions: [
|
||||
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
||||
],
|
||||
onLinkTap: (url, _, __) {
|
||||
if (url == null) return;
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null) {
|
||||
unawaited(
|
||||
launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
] else
|
||||
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 |
Reference in New Issue
Block a user