Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
100ca9d8a1 | ||
|
|
3d47af177a | ||
|
|
f6a37eaa16 |
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -124,7 +125,6 @@ class _AccountTile extends ConsumerWidget {
|
||||
if (h == null) return const Text('Sync health: Not verified yet');
|
||||
final date = h.lastVerifiedAt.toLocal().toString().split('.')[0];
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Sync health: '),
|
||||
Icon(
|
||||
@@ -133,7 +133,13 @@ class _AccountTile extends ConsumerWidget {
|
||||
color: h.isHealthy ? Colors.green : Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(h.isHealthy ? 'Healthy' : 'Discrepancies found'),
|
||||
Flexible(
|
||||
child: Text(
|
||||
h.isHealthy
|
||||
? 'Healthy'
|
||||
: _formatDiscrepancies(h.discrepancySummary),
|
||||
),
|
||||
),
|
||||
Text(' ($date)', style: const TextStyle(fontSize: 10)),
|
||||
],
|
||||
);
|
||||
@@ -293,6 +299,30 @@ class _AccountTile extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDiscrepancies(String? summary) {
|
||||
if (summary == null) return 'Discrepancies found';
|
||||
try {
|
||||
final decoded = jsonDecode(summary) as Map<String, dynamic>;
|
||||
var missingLocally = 0;
|
||||
var missingOnServer = 0;
|
||||
var flagMismatches = 0;
|
||||
for (final v in decoded.values) {
|
||||
final m = v as Map<String, dynamic>;
|
||||
missingLocally += (m['missingLocally'] as int? ?? 0);
|
||||
missingOnServer += (m['missingOnServer'] as int? ?? 0);
|
||||
flagMismatches += (m['flagMismatches'] as int? ?? 0);
|
||||
}
|
||||
final parts = <String>[];
|
||||
if (missingLocally > 0) parts.add('missing locally: $missingLocally');
|
||||
if (missingOnServer > 0) parts.add('missing on server: $missingOnServer');
|
||||
if (flagMismatches > 0) parts.add('flag mismatches: $flagMismatches');
|
||||
if (parts.isEmpty) return 'Discrepancies found';
|
||||
return 'Discrepancies found (${parts.join(', ')})';
|
||||
} catch (_) {
|
||||
return 'Discrepancies found';
|
||||
}
|
||||
}
|
||||
|
||||
class _OnboardingView extends StatelessWidget {
|
||||
const _OnboardingView();
|
||||
|
||||
|
||||
@@ -938,10 +938,13 @@ class _UnsubscribeChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final uri = _parseUnsubscribeUri(header);
|
||||
if (uri == null) return const SizedBox.shrink();
|
||||
return ActionChip(
|
||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||
label: const Text('Unsubscribe'),
|
||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||
return Tooltip(
|
||||
message: uri.toString(),
|
||||
child: ActionChip(
|
||||
avatar: const Icon(Icons.unsubscribe_outlined, size: 16),
|
||||
label: const Text('Unsubscribe'),
|
||||
onPressed: () => launchUrl(uri, mode: LaunchMode.externalApplication),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,13 @@ String buildEmailHtml(String htmlBody, {bool loadRemoteImages = false}) {
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta http-equiv="Content-Security-Policy" content="$csp">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||
body { margin: 0; padding: 0; font-family: sans-serif; word-break: break-word; overflow-x: hidden; color-scheme: light; background-color: #ffffff; color: #000000; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
a { color: #1976D2; }
|
||||
* { box-sizing: border-box; }
|
||||
* { box-sizing: border-box; max-width: 100%; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
td, th { overflow-wrap: break-word; word-break: break-word; }
|
||||
pre { white-space: pre-wrap; word-break: break-word; overflow-x: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
|
||||
|
||||
import 'helpers.dart';
|
||||
|
||||
@@ -206,5 +207,50 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(find.text('sharedinbox.de'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows Healthy when sync health is healthy', (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();
|
||||
|
||||
expect(find.textContaining('Healthy'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows discrepancy details when sync health has discrepancies',
|
||||
(tester) async {
|
||||
const summary =
|
||||
'{"INBOX":{"missingLocally":3,"missingOnServer":0,"flagMismatches":1}}';
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation: '/accounts',
|
||||
overrides: baseOverrides(
|
||||
accounts: [kTestAccount],
|
||||
syncHealth: SyncHealthRow(
|
||||
accountId: kTestAccount.id,
|
||||
lastVerifiedAt: DateTime(2024, 6),
|
||||
isHealthy: false,
|
||||
discrepancySummary: summary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('missing locally: 3'), findsOneWidget);
|
||||
expect(find.textContaining('flag mismatches: 1'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -475,6 +475,44 @@ void main() {
|
||||
expect(find.text('Share'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'long-press on unsubscribe chip shows URL tooltip',
|
||||
(tester) async {
|
||||
final email = testEmail(
|
||||
listUnsubscribeHeader: '<https://example.com/unsubscribe>',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildApp(
|
||||
initialLocation:
|
||||
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||
overrides: _overrides(
|
||||
body: const EmailBody(emailId: 'acc-1:42', attachments: []),
|
||||
email: email,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Unsubscribe'), findsOneWidget);
|
||||
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) =>
|
||||
w is Tooltip && w.message == 'https://example.com/unsubscribe',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.longPress(find.text('Unsubscribe'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('https://example.com/unsubscribe'),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('Show Mail Structure opens dialog with MIME parts', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:sharedinbox/core/services/account_discovery_service.dart';
|
||||
import 'package:sharedinbox/core/services/connection_test_service.dart';
|
||||
import 'package:sharedinbox/core/services/managesieve_probe_service.dart';
|
||||
import 'package:sharedinbox/core/services/share_encryption_service.dart';
|
||||
import 'package:sharedinbox/data/db/database.dart' show SyncHealthRow;
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_list_screen.dart';
|
||||
import 'package:sharedinbox/ui/screens/account_receive_screen.dart';
|
||||
@@ -505,13 +506,12 @@ Widget buildApp({
|
||||
return ProviderScope(
|
||||
// Defaults come first so tests can override them via [overrides].
|
||||
//
|
||||
// syncHealthProvider and syncLogRepositoryProvider are backed by Drift
|
||||
// StreamQueries. When a StreamProvider that wraps a Drift query is disposed,
|
||||
// Drift schedules a Timer.run() for cache debouncing. Flutter's test
|
||||
// framework then fails the test with "A Timer is still pending". Replacing
|
||||
// these with simple synchronous streams avoids the pending-timer assertion.
|
||||
// syncLogRepositoryProvider is backed by a Drift StreamQuery. When the
|
||||
// provider is disposed, Drift schedules a Timer.run() for cache
|
||||
// debouncing. Flutter's test framework then fails the test with "A Timer
|
||||
// is still pending". Replacing it with a synchronous stream avoids this.
|
||||
// syncHealthProvider has the same issue and is overridden in baseOverrides.
|
||||
overrides: [
|
||||
syncHealthProvider.overrideWith((ref, _) => Stream.value(null)),
|
||||
syncLogRepositoryProvider.overrideWithValue(
|
||||
const NoOpSyncLogRepository(),
|
||||
),
|
||||
@@ -541,6 +541,7 @@ List<Override> baseOverrides({
|
||||
Exception? connectionError,
|
||||
ShareKeyRepository? shareKeyRepository,
|
||||
bool hasStoredPassword = true,
|
||||
SyncHealthRow? syncHealth,
|
||||
}) =>
|
||||
[
|
||||
accountRepositoryProvider.overrideWithValue(
|
||||
@@ -559,6 +560,9 @@ List<Override> baseOverrides({
|
||||
shareKeyRepositoryProvider.overrideWithValue(
|
||||
shareKeyRepository ?? FakeShareKeyRepository(),
|
||||
),
|
||||
// syncHealthProvider is backed by a Drift StreamQuery; override with a
|
||||
// plain stream to avoid "A Timer is still pending" in tests.
|
||||
syncHealthProvider.overrideWith((ref, _) => Stream.value(syncHealth)),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -588,6 +592,7 @@ Email testEmail({
|
||||
bool isSeen = false,
|
||||
bool isFlagged = false,
|
||||
bool hasAttachment = false,
|
||||
String? listUnsubscribeHeader,
|
||||
}) =>
|
||||
Email(
|
||||
id: id,
|
||||
@@ -603,6 +608,7 @@ Email testEmail({
|
||||
isSeen: isSeen,
|
||||
isFlagged: isFlagged,
|
||||
hasAttachment: hasAttachment,
|
||||
listUnsubscribeHeader: listUnsubscribeHeader,
|
||||
);
|
||||
|
||||
class FakeSearchHistoryRepository implements SearchHistoryRepository {
|
||||
|
||||
@@ -41,6 +41,20 @@ void main() {
|
||||
expect(html, contains('https: http: data: blob:'));
|
||||
_expectLightMode(html);
|
||||
});
|
||||
|
||||
test('prevents horizontal overflow so wide HTML emails are not cut off',
|
||||
() {
|
||||
final html =
|
||||
buildEmailHtml('<table width="600"><tr><td>x</td></tr></table>');
|
||||
// Body clips overflow so fixed-width email tables don't escape the viewport.
|
||||
expect(html, contains('overflow-x: hidden'));
|
||||
// Tables are forced to full viewport width so fixed pixel widths don't overflow.
|
||||
expect(html, contains('table { width: 100%'));
|
||||
// All elements are capped at viewport width via max-width.
|
||||
expect(html, contains('max-width: 100%'));
|
||||
// Pre-formatted text wraps instead of stretching the page.
|
||||
expect(html, contains('white-space: pre-wrap'));
|
||||
});
|
||||
});
|
||||
|
||||
// On Linux (the test host) the widget falls back to plain text extracted via
|
||||
|
||||
Reference in New Issue
Block a user