Files
sharedinbox/lib/ui/screens/sync_log_screen.dart
T
Thomas SharedInboxandClaude Sonnet 4.6 33949b92c0 feat: syncLog add Copy button, stack trace, isPermanent, Android device info (#237)
- Schema v33: add error_stack_trace and is_permanent columns to sync_logs
- SyncLogEntry gains stackTrace and isPermanent fields; SyncLogRepository.log()
  gains matching optional parameters; IMAP and JMAP sync loops forward the
  stack trace string and isPermanent flag when writing error entries
- New lib/ui/utils/about_markdown.dart utility shared by AboutScreen and the
  sync log copy feature; builds the markdown table including Android device
  info (manufacturer, model, OS version) via device_info_plus
- AboutScreen uses the utility and adds Android device info row
- SyncLogScreen: subtitle shows "Error (permanent)" for permanent errors;
  expanded view shows stack trace in red monospace; each tile has a Copy
  button that copies a markdown summary of the entry plus the About section
- Migration test updated for v33; new repo test for stackTrace/isPermanent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 08:33:45 +02:00

356 lines
11 KiB
Dart

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');
String _fmtDuration(Duration d) {
final ms = d.inMilliseconds;
return ms < 1000 ? '${ms}ms' : '${(ms / 1000).toStringAsFixed(1)}s';
}
String _fmtBytes(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
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});
final String accountId;
@override
ConsumerState<SyncLogScreen> createState() => _SyncLogScreenState();
}
class _SyncLogScreenState extends ConsumerState<SyncLogScreen> {
List<SyncLogEntry> _entries = [];
bool _syncing = false;
int? _presynCount;
StreamSubscription<List<SyncLogEntry>>? _sub;
@override
void initState() {
super.initState();
_sub = ref
.read(syncLogRepositoryProvider)
.observeSyncLogs(widget.accountId)
.listen((entries) {
setState(() {
if (_syncing &&
_presynCount != null &&
entries.length > _presynCount!) {
_syncing = false;
_presynCount = null;
}
_entries = entries;
});
});
}
@override
void dispose() {
unawaited(_sub?.cancel());
super.dispose();
}
void _syncNow() {
setState(() {
_syncing = true;
_presynCount = _entries.length;
});
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 androidInfo = await getAndroidDeviceInfo();
if (!context.mounted) return;
final syncMd = _buildSyncEntryMarkdown(entry);
final aboutMd = buildAboutMarkdown(
context: context,
pkg: pkg,
imapCount: imapCount,
jmapCount: jmapCount,
androidInfo: androidInfo,
);
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(
appBar: AppBar(
title: const Text('Sync log'),
actions: [
if (_syncing)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync now',
onPressed: _syncNow,
),
],
),
body: _entries.isEmpty
? 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),
),
),
);
}
}
class _SyncLogTile extends StatelessWidget {
const _SyncLogTile({required this.entry, required this.onCopy});
final SyncLogEntry entry;
final VoidCallback onCopy;
@override
Widget build(BuildContext context) {
final durationLabel = _fmtDuration(entry.duration);
final proto =
entry.protocol.isEmpty ? '' : ' · ${entry.protocol.toUpperCase()}';
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,
color: entry.isOk ? Colors.green : errorColor,
),
title: Text(
'${_timeFmt.format(entry.startedAt)}$proto',
style: entry.isOk ? null : TextStyle(color: errorColor),
),
subtitle: Text(
subtitleText,
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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_row('Started', _timeFmt.format(entry.startedAt)),
_row('Finished', _timeFmt.format(entry.finishedAt)),
_row('Duration', durationLabel),
if (entry.protocol.isNotEmpty)
_row('Protocol', entry.protocol.toUpperCase()),
_row('Emails fetched', '${entry.emailsFetched}'),
_row('Emails up-to-date', '${entry.emailsSkipped}'),
_row('Mailboxes synced', '${entry.mailboxesSynced}'),
_row('Pending changes flushed', '${entry.pendingFlushed}'),
_row('Data transferred', _fmtBytes(entry.bytesTransferred)),
if (entry.mailboxStats.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.only(top: 6, bottom: 2),
child: Text(
'Per mailbox',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
),
for (final m in entry.mailboxStats)
_row(
' ${m.mailboxPath}',
[
'${m.fetched} new · ${m.skipped} up-to-date',
if (m.duration != null) _fmtDuration(m.duration!),
].join(' · '),
),
],
if (entry.errorMessage != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
entry.errorMessage!,
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),
child: Text(
'Protocol log',
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.protocolLog!,
style: const TextStyle(
fontSize: 10,
fontFamily: 'monospace',
color: Colors.greenAccent,
),
),
),
],
],
),
),
],
);
}
Widget _row(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
children: [
SizedBox(
width: 180,
child: Text(
label,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
),
Expanded(child: Text(value, style: const TextStyle(fontSize: 12))),
],
),
);
}