Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 63f5a58e5b feat: add Locale, Text Scale, DB Schema Version, Device Model to About page (#258)
- Add lib/core/db_schema_version.dart with dbSchemaVersion constant (32)
  so both the data layer and UI layer share the same source of truth
- Add device_info_plus dependency for Android/iOS device model display
- Show Locale, Text Scale Factor, DB Schema Version, and Device Model
  (Android/iOS only) rows in the About screen diagnostic table
- Device model is fetched asynchronously; the widget re-renders when ready
- Update widget tests to assert the new rows are present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:15:20 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 b1ce093c33 feat: move Reply All into dialog, add Mark as Spam (#260)
- Remove standalone Reply All button from the app bar.
- Add Mark as Spam button before Delete; moves email to the junk
  mailbox (role "junk") and shows a snackbar if no junk folder exists.
- Reply button now collects all addresses from From/To/Cc, filters out
  the account's own address, deduplicates, and:
    • If ≤1 candidate: navigates directly to compose (no dialog).
    • If ≥2 candidates: shows a "Reply All" dialog where each address
      can be placed in To, Cc, or skipped. Addresses from incoming Cc
      default to Cc; From/To addresses default to To.
- Update widget tests: verify Reply All button is absent, dialog
  appears for multi-recipient emails, Mark as Spam button present,
  and snackbar shown when no junk folder is configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:46:43 +02:00
18 changed files with 95 additions and 375 deletions
-34
View File
@@ -22,8 +22,6 @@ jobs:
- name: Detect Android and Linux changes
id: diff
shell: bash
env:
FORGEJO_TOKEN: ${{ github.token }}
run: |
# On workflow_dispatch always build everything
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
@@ -32,38 +30,6 @@ jobs:
exit 0
fi
HEAD_SHA=$(git rev-parse HEAD)
# Skip if this exact commit was already successfully deployed (prevents
# hourly schedule from redeploying the same commit on every tick).
LAST_DEPLOYED_SHA=$(python3 - << 'PYEOF'
import json, os, sys, urllib.request
token = os.environ.get("FORGEJO_TOKEN", "")
server = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
repo = os.environ.get("GITHUB_REPOSITORY", "")
url = f"{server}/api/v1/repos/{repo}/actions/runs?workflow_id=deploy.yml&status=success&limit=5"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
runs = [
r for r in data.get("workflow_runs", [])
if r.get("workflow_id") == "deploy.yml" and r.get("status") == "success"
]
print(runs[0]["head_sha"] if runs else "")
except Exception as e:
print(f"API check failed: {e}", file=sys.stderr)
print("")
PYEOF
)
if [ -n "$LAST_DEPLOYED_SHA" ] && [ "$HEAD_SHA" = "$LAST_DEPLOYED_SHA" ]; then
echo "HEAD $HEAD_SHA already successfully deployed — skipping"
echo "android=false" >> "$GITHUB_OUTPUT"
echo "linux=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Diff the HEAD commit against its parent; fall back to listing HEAD's files
# when the parent is unavailable (initial commit, shallow clone).
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null \
-6
View File
@@ -14,12 +14,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- 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 }}
+1 -23
View File
@@ -13,42 +13,20 @@ jobs:
deploy:
name: Build & Deploy Website
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- 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: Build & 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
run: task website-deploy
- name: Verify Website
env:
SSH_HOST: ${{ secrets.WEBSITE_SSH_HOST }}
run: scripts/website-verify.sh
- name: Cleanup TLS credentials
if: always()
run: rm -rf /tmp/dagger-tls /tmp/stunnel-dagger.conf /tmp/stunnel.pid
-1
View File
@@ -850,7 +850,6 @@ func (m *Ci) Renovate(ctx context.Context, renovateToken *dagger.Secret) (string
WithEnvVariable("RENOVATE_PLATFORM", "forgejo").
WithEnvVariable("RENOVATE_ENDPOINT", "https://codeberg.org").
WithEnvVariable("RENOVATE_REPOSITORIES", "guettli/sharedinbox").
WithEnvVariable("LOG_LEVEL", "info").
WithExec([]string{"renovate"}).
Stdout(ctx)
}
+1 -1
View File
@@ -1 +1 @@
const int dbSchemaVersion = 33;
const int dbSchemaVersion = 32;
@@ -19,8 +19,6 @@ class SyncLogEntry {
required this.id,
required this.result,
this.errorMessage,
this.stackTrace,
this.isPermanent = false,
required this.protocol,
required this.emailsFetched,
required this.emailsSkipped,
@@ -36,8 +34,6 @@ class SyncLogEntry {
final int id;
final String result; // 'ok' or 'error'
final String? errorMessage;
final String? stackTrace;
final bool isPermanent;
final String protocol; // 'imap' or 'jmap'
final int emailsFetched;
final int emailsSkipped;
@@ -58,8 +54,6 @@ abstract class SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -87,8 +81,6 @@ class NoOpSyncLogRepository implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
-4
View File
@@ -260,8 +260,6 @@ class _AccountSync implements _SyncLoop {
accountId: account.id,
success: false,
errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'imap',
emailsFetched: 0,
emailsSkipped: 0,
@@ -515,8 +513,6 @@ class _JmapAccountSync implements _SyncLoop {
accountId: account.id,
success: false,
errorMessage: e.toString(),
stackTrace: st.toString(),
isPermanent: isPermanent,
protocol: 'jmap',
emailsFetched: 0,
emailsSkipped: 0,
-7
View File
@@ -193,9 +193,6 @@ class SyncLogs extends Table {
DateTimeColumn get finishedAt => dateTime()();
// Added in schema v13: raw protocol log when account.verbose == true.
TextColumn get protocolLog => text().nullable()();
// Added in schema v33: stack trace and permanent flag for error entries.
TextColumn get errorStackTrace => text().nullable()();
BoolColumn get isPermanent => boolean().withDefault(const Constant(false))();
}
/// Per-mailbox breakdown for a single sync cycle.
@@ -574,10 +571,6 @@ class AppDatabase extends _$AppDatabase {
if (from < 32) {
await m.createTable(localSieveApplied);
}
if (from >= 7 && from < 33) {
await m.addColumn(syncLogs, syncLogs.errorStackTrace);
await m.addColumn(syncLogs, syncLogs.isPermanent);
}
},
);
}
@@ -13,8 +13,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -32,8 +30,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
accountId: accountId,
result: success ? 'ok' : 'error',
errorMessage: Value(errorMessage),
errorStackTrace: Value(stackTrace),
isPermanent: Value(isPermanent),
protocol: Value(protocol),
itemsSynced: Value(emailsFetched),
emailsSkipped: Value(emailsSkipped),
@@ -79,8 +75,6 @@ class SyncLogRepositoryImpl implements SyncLogRepository {
id: r.id,
result: r.result,
errorMessage: r.errorMessage,
stackTrace: r.errorStackTrace,
isPermanent: r.isPermanent,
protocol: r.protocol,
emailsFetched: r.itemsSynced,
emailsSkipped: r.emailsSkipped,
+86 -17
View File
@@ -1,13 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends ConsumerStatefulWidget {
@@ -23,11 +25,13 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
late final Stream<List<Account>> _accountsStream;
String? _deviceModel;
static const _gitHash = String.fromEnvironment('GIT_HASH');
@override
void initState() {
super.initState();
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
_deviceModelFuture = getDeviceModel();
_deviceModelFuture = _fetchDeviceModel();
unawaited(
_deviceModelFuture.then((model) {
if (mounted) setState(() => _deviceModel = model);
@@ -35,6 +39,71 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
);
}
static Future<String?> _fetchDeviceModel() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return '${android.manufacturer} / ${android.model}';
} else if (Platform.isIOS) {
final ios = await info.iosInfo;
return ios.utsname.machine;
}
} catch (_) {}
return null;
}
String _buildMarkdown(
BuildContext context,
PackageInfo? pkg,
int imapCount,
int jmapCount, {
String? deviceModel,
}) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version =
pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final locale = Localizations.localeOf(context).toString();
final textScale =
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
final deviceModelLine =
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'$deviceModelLine'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| Locale | $locale |\n'
'| Text Scale | $textScale× |\n'
'| DB Schema Version | $dbSchemaVersion |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
static String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
Future<void> _copyToClipboard(
BuildContext context,
int imapCount,
@@ -51,11 +120,11 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
if (!context.mounted) return;
await Clipboard.setData(
ClipboardData(
text: buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
text: _buildMarkdown(
context,
pkg,
imapCount,
jmapCount,
deviceModel: deviceModel,
),
),
@@ -109,11 +178,11 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
} catch (_) {}
if (!context.mounted) return;
final body = Uri.encodeComponent(
buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
_buildMarkdown(
context,
pkg,
imapCount,
jmapCount,
deviceModel: deviceModel,
),
);
@@ -166,11 +235,11 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
return const Center(child: CircularProgressIndicator());
}
return Markdown(
data: buildAboutMarkdown(
context: context,
pkg: snapshot.data,
imapCount: imapCount,
jmapCount: jmapCount,
data: _buildMarkdown(
context,
snapshot.data,
imapCount,
jmapCount,
deviceModel: _deviceModel,
),
selectable: true,
+5 -139
View File
@@ -1,15 +1,11 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/utils/about_markdown.dart';
final _timeFmt = DateFormat('MMM d, HH:mm:ss');
@@ -25,57 +21,6 @@ String _fmtBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
String _buildSyncEntryMarkdown(SyncLogEntry entry) {
final buf = StringBuffer();
buf.writeln('## Sync Entry');
buf.writeln();
buf.writeln('| Property | Value |');
buf.writeln('|----------|-------|');
buf.writeln('| Started | ${_timeFmt.format(entry.startedAt)} |');
buf.writeln('| Finished | ${_timeFmt.format(entry.finishedAt)} |');
buf.writeln('| Duration | ${_fmtDuration(entry.duration)} |');
if (entry.protocol.isNotEmpty) {
buf.writeln('| Protocol | ${entry.protocol.toUpperCase()} |');
}
final statusLabel = entry.isOk
? 'OK'
: entry.isPermanent
? 'Error (permanent)'
: 'Error';
buf.writeln('| Status | $statusLabel |');
buf.writeln('| Emails fetched | ${entry.emailsFetched} |');
buf.writeln('| Emails up-to-date | ${entry.emailsSkipped} |');
buf.writeln('| Mailboxes synced | ${entry.mailboxesSynced} |');
buf.writeln('| Pending changes flushed | ${entry.pendingFlushed} |');
buf.writeln('| Data transferred | ${_fmtBytes(entry.bytesTransferred)} |');
if (entry.mailboxStats.isNotEmpty) {
buf.writeln();
buf.writeln('### Per mailbox');
buf.writeln();
buf.writeln('| Mailbox | Fetched | Up-to-date | Duration |');
buf.writeln('|---------|---------|------------|----------|');
for (final m in entry.mailboxStats) {
final dur = m.duration != null ? _fmtDuration(m.duration!) : '-';
buf.writeln('| ${m.mailboxPath} | ${m.fetched} | ${m.skipped} | $dur |');
}
}
if (entry.errorMessage != null) {
buf.writeln();
buf.writeln('**Error:**');
buf.writeln();
buf.writeln(entry.errorMessage);
}
if (entry.stackTrace != null) {
buf.writeln();
buf.writeln('**Stack trace:**');
buf.writeln();
buf.writeln('```');
buf.write(entry.stackTrace);
buf.writeln('```');
}
return buf.toString();
}
class SyncLogScreen extends ConsumerStatefulWidget {
const SyncLogScreen({super.key, required this.accountId});
@@ -124,41 +69,6 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
ref.read(syncManagerProvider).syncNow(widget.accountId);
}
Future<void> _copyEntry(SyncLogEntry entry, BuildContext context) async {
final accounts =
await ref.read(accountRepositoryProvider).observeAccounts().first;
final imapCount = accounts.where((a) => a.type == AccountType.imap).length;
final jmapCount = accounts.where((a) => a.type == AccountType.jmap).length;
PackageInfo? pkg;
try {
pkg = await PackageInfo.fromPlatform();
} catch (_) {}
final deviceModel = await getDeviceModel();
if (!context.mounted) return;
final syncMd = _buildSyncEntryMarkdown(entry);
final aboutMd = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
deviceModel: deviceModel,
);
await Clipboard.setData(ClipboardData(text: '$syncMd\n$aboutMd'));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(seconds: 3),
content: Text('Copied to clipboard'),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -186,20 +96,16 @@ class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
? const Center(child: Text('No sync entries yet'))
: ListView.builder(
itemCount: _entries.length,
itemBuilder: (ctx, i) => _SyncLogTile(
entry: _entries[i],
onCopy: () => _copyEntry(_entries[i], ctx),
),
itemBuilder: (ctx, i) => _SyncLogTile(entry: _entries[i]),
),
);
}
}
class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry, required this.onCopy});
const _SyncLogTile({required this.entry});
final SyncLogEntry entry;
final VoidCallback onCopy;
@override
Widget build(BuildContext context) {
@@ -209,12 +115,6 @@ class _SyncLogTile extends StatelessWidget {
final theme = Theme.of(context);
final errorColor = theme.colorScheme.error;
final subtitleText = entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: entry.isPermanent
? 'Error (permanent) · took $durationLabel'
: 'Error · took $durationLabel';
return ExpansionTile(
leading: Icon(
entry.isOk ? Icons.check_circle : Icons.error_outline,
@@ -225,20 +125,11 @@ class _SyncLogTile extends StatelessWidget {
style: entry.isOk ? null : TextStyle(color: errorColor),
),
subtitle: Text(
subtitleText,
entry.isOk
? '${entry.emailsFetched} new · ${entry.emailsSkipped} up-to-date · took $durationLabel'
: 'Error · took $durationLabel',
style: TextStyle(fontSize: 12, color: entry.isOk ? null : errorColor),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy as markdown',
onPressed: onCopy,
),
const Icon(Icons.expand_more),
],
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(72, 0, 16, 12),
@@ -280,31 +171,6 @@ class _SyncLogTile extends StatelessWidget {
style: TextStyle(color: errorColor, fontSize: 12),
),
),
if (entry.stackTrace != null) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
child: Text(
'Stack trace',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
Container(
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(4),
),
child: Text(
entry.stackTrace!,
style: TextStyle(
fontSize: 10,
fontFamily: 'monospace',
color: Colors.red[300],
),
),
),
],
if (entry.protocolLog != null) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
-75
View File
@@ -1,75 +0,0 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:sharedinbox/core/db_schema_version.dart';
const _gitHash = String.fromEnvironment('GIT_HASH');
/// Builds the About markdown table used in [AboutScreen] and sync log copies.
String buildAboutMarkdown({
required BuildContext context,
PackageInfo? pkg,
required int imapCount,
required int jmapCount,
String? deviceModel,
}) {
final size = MediaQuery.of(context).size;
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
final physW = (size.width * pixelRatio).toInt();
final physH = (size.height * pixelRatio).toInt();
final version = pkg != null ? '${pkg.version}+${pkg.buildNumber}' : 'unknown';
final versionDisplay = _gitHash.isNotEmpty
? '[$version](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash)'
: version;
final osName = _capitalize(Platform.operatingSystem);
final isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
final locale = Localizations.localeOf(context).toString();
final textScale =
MediaQuery.of(context).textScaler.scale(1.0).toStringAsFixed(1);
final gitCommitLine = _gitHash.isNotEmpty
? '| Git Commit | [$_gitHash](https://codeberg.org/guettli/sharedinbox/commit/$_gitHash) |\n'
: '';
final deviceModelLine =
deviceModel != null ? '| Device Model | $deviceModel |\n' : '';
return '## [sharedinbox.de](https://sharedinbox.de)\n\n'
'| Property | Value |\n'
'|----------|-------|\n'
'| App Version | $versionDisplay |\n'
'$gitCommitLine'
'| Platform | ${Platform.operatingSystem} |\n'
'| $osName Version | ${Platform.operatingSystemVersion} |\n'
'$deviceModelLine'
'| Resolution | ${physW}x$physH px'
' (logical: ${size.width.toInt()}x${size.height.toInt()} pt,'
' ratio: ${pixelRatio.toStringAsFixed(1)}x) |\n'
'| Dart Version | ${Platform.version.split(' ').first} |\n'
'| Processors | ${Platform.numberOfProcessors} |\n'
'| Dark Mode | ${isDark ? 'yes' : 'no'} |\n'
'| Locale | $locale |\n'
'| Text Scale | $textScale× |\n'
'| DB Schema Version | $dbSchemaVersion |\n'
'| IMAP Accounts | $imapCount |\n'
'| JMAP Accounts | $jmapCount |\n';
}
/// Fetches device model string, or null when unavailable.
Future<String?> getDeviceModel() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
return '${android.manufacturer} / ${android.model}';
} else if (Platform.isIOS) {
final ios = await info.iosInfo;
return ios.utsname.machine;
}
} catch (_) {}
return null;
}
String _capitalize(String s) =>
s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
-1
View File
@@ -58,7 +58,6 @@ const _excluded = {
'lib/ui/widgets/try_connection_button.dart',
'lib/ui/widgets/undo_shell.dart',
'lib/ui/screens/about_screen.dart',
'lib/ui/utils/about_markdown.dart',
'lib/ui/widgets/email_tile.dart',
'lib/core/sync/account_sync_manager.dart',
'lib/core/sync/background_sync.dart',
@@ -288,8 +288,6 @@ class _FakeLogs implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
-2
View File
@@ -181,8 +181,6 @@ class FakeSyncLogRepository implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
+2 -17
View File
@@ -14,7 +14,7 @@ void main() {
group('Migration', () {
test('schemaVersion matches expected value', () async {
final db = AppDatabase(NativeDatabase.memory());
expect(db.schemaVersion, 33);
expect(db.schemaVersion, 32);
await db.close();
});
@@ -194,11 +194,6 @@ void main() {
// v32: local_sieve_applied table.
await db.customSelect('SELECT count(*) FROM local_sieve_applied').get();
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
@@ -386,16 +381,11 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
await db.close();
if (dbFile.existsSync()) dbFile.deleteSync();
});
test('fresh install creates all tables at schemaVersion 33', () async {
test('fresh install creates all tables at schemaVersion 32', () async {
final db = AppDatabase(NativeDatabase.memory());
await db.select(db.accounts).get();
@@ -436,11 +426,6 @@ void main() {
await _tableColumns(db, 'sync_log_mailboxes');
expect(syncLogMailboxColumns, contains('duration_ms'));
// v33: error_stack_trace and is_permanent columns on sync_logs.
final syncLogColumns = await _tableColumns(db, 'sync_logs');
expect(syncLogColumns, contains('error_stack_trace'));
expect(syncLogColumns, contains('is_permanent'));
await db.close();
});
});
-2
View File
@@ -170,8 +170,6 @@ class _FakeSyncLog implements SyncLogRepository {
required String accountId,
required bool success,
String? errorMessage,
String? stackTrace,
bool isPermanent = false,
required String protocol,
required int emailsFetched,
required int emailsSkipped,
@@ -126,34 +126,4 @@ void main() {
expect(rows.first.result, 'error');
expect(rows.first.errorMessage, 'Connection refused');
});
test('stores and retrieves stackTrace and isPermanent on error entries',
() async {
final repo = SyncLogRepositoryImpl(db);
final start = DateTime(2024, 3, 1, 9);
final end = DateTime(2024, 3, 1, 9, 0, 1);
const fakeTrace = '#0 main (file:///app/lib/main.dart:10:5)';
await repo.log(
accountId: 'acc1',
success: false,
errorMessage: 'MissingPluginException',
stackTrace: fakeTrace,
isPermanent: true,
protocol: 'imap',
emailsFetched: 0,
emailsSkipped: 0,
mailboxesSynced: 0,
pendingFlushed: 0,
bytesTransferred: 0,
startedAt: start,
finishedAt: end,
);
final entries = await repo.observeSyncLogs('acc1').first;
final entry = entries.firstWhere((e) => e.startedAt == start);
expect(entry.stackTrace, fakeTrace);
expect(entry.isPermanent, true);
expect(entry.errorMessage, 'MissingPluginException');
});
}