- 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 device info - AboutScreen uses the utility (refactored to remove duplicate _buildMarkdown) - 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 - check_coverage.dart excludes lib/ui/utils/about_markdown.dart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
224 lines
6.6 KiB
Dart
224 lines
6.6 KiB
Dart
import 'dart:async';
|
|
|
|
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/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 {
|
|
const AboutScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<AboutScreen> createState() => _AboutScreenState();
|
|
}
|
|
|
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
|
late final Future<String?> _deviceModelFuture;
|
|
late final Stream<List<Account>> _accountsStream;
|
|
String? _deviceModel;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_accountsStream = ref.read(accountRepositoryProvider).observeAccounts();
|
|
_deviceModelFuture = getDeviceModel();
|
|
unawaited(
|
|
_deviceModelFuture.then((model) {
|
|
if (mounted) setState(() => _deviceModel = model);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<void> _copyToClipboard(
|
|
BuildContext context,
|
|
int imapCount,
|
|
int jmapCount,
|
|
) async {
|
|
PackageInfo? pkg;
|
|
try {
|
|
pkg = await _packageInfoFuture;
|
|
} catch (_) {}
|
|
String? deviceModel;
|
|
try {
|
|
deviceModel = await _deviceModelFuture;
|
|
} catch (_) {}
|
|
if (!context.mounted) return;
|
|
await Clipboard.setData(
|
|
ClipboardData(
|
|
text: buildAboutMarkdown(
|
|
context: context,
|
|
pkg: pkg,
|
|
imapCount: imapCount,
|
|
jmapCount: jmapCount,
|
|
deviceModel: deviceModel,
|
|
),
|
|
),
|
|
);
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
duration: Duration(seconds: 5),
|
|
content: Text('Copied to clipboard'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _launchUrl(BuildContext context, Uri url) async {
|
|
try {
|
|
final launched =
|
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
if (!launched && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
duration: Duration(seconds: 5),
|
|
content: Text('Could not open browser.'),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
duration: const Duration(seconds: 5),
|
|
content: Text('Error: $e'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _createIssue(
|
|
BuildContext context,
|
|
int imapCount,
|
|
int jmapCount,
|
|
) async {
|
|
PackageInfo? pkg;
|
|
try {
|
|
pkg = await _packageInfoFuture;
|
|
} catch (_) {}
|
|
String? deviceModel;
|
|
try {
|
|
deviceModel = await _deviceModelFuture;
|
|
} catch (_) {}
|
|
if (!context.mounted) return;
|
|
final body = Uri.encodeComponent(
|
|
buildAboutMarkdown(
|
|
context: context,
|
|
pkg: pkg,
|
|
imapCount: imapCount,
|
|
jmapCount: jmapCount,
|
|
deviceModel: deviceModel,
|
|
),
|
|
);
|
|
final url = Uri.parse(
|
|
'https://codeberg.org/guettli/sharedinbox/issues/new?body=$body',
|
|
);
|
|
try {
|
|
final launched =
|
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
if (!launched && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
duration: Duration(seconds: 5),
|
|
content: Text('Could not open browser.'),
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
duration: const Duration(seconds: 5),
|
|
content: Text('Error: $e'),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return StreamBuilder<List<Account>>(
|
|
stream: _accountsStream,
|
|
builder: (context, accountSnapshot) {
|
|
final accounts = accountSnapshot.data ?? [];
|
|
final imapCount =
|
|
accounts.where((a) => a.type == AccountType.imap).length;
|
|
final jmapCount =
|
|
accounts.where((a) => a.type == AccountType.jmap).length;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('About')),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: FutureBuilder<PackageInfo>(
|
|
future: _packageInfoFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
return Markdown(
|
|
data: buildAboutMarkdown(
|
|
context: context,
|
|
pkg: snapshot.data,
|
|
imapCount: imapCount,
|
|
jmapCount: jmapCount,
|
|
deviceModel: _deviceModel,
|
|
),
|
|
selectable: true,
|
|
onTapLink: (text, href, title) {
|
|
if (href != null) {
|
|
unawaited(
|
|
_launchUrl(context, Uri.parse(href)),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.copy),
|
|
label: const Text('Copy to clipboard'),
|
|
onPressed: () => unawaited(
|
|
_copyToClipboard(context, imapCount, jmapCount),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: FilledButton.icon(
|
|
icon: const Icon(Icons.bug_report),
|
|
label: const Text('Create issue'),
|
|
onPressed: () => unawaited(
|
|
_createIssue(context, imapCount, jmapCount),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|