diff --git a/lib/core/error_reporting/crash_report_bundle.dart b/lib/core/error_reporting/crash_report_bundle.dart new file mode 100644 index 0000000..1df579b --- /dev/null +++ b/lib/core/error_reporting/crash_report_bundle.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:lazurite/core/logging/log_redactor.dart'; + +class CrashReportBundle { + const CrashReportBundle({ + required this.generatedAt, + required this.error, + required this.stackTrace, + required this.library, + required this.context, + required this.information, + required this.relevantLogs, + }); + + factory CrashReportBundle.fallbackFromFlutterErrorDetails( + FlutterErrorDetails details, { + Object? reportError, + StackTrace? reportStackTrace, + DateTime? generatedAt, + }) { + return CrashReportBundle( + generatedAt: generatedAt ?? DateTime.now(), + error: LogRedactor.redact(details.exceptionAsString()), + stackTrace: LogRedactor.redact(details.stack?.toString() ?? StackTrace.current.toString()), + library: _redactNullable(details.library), + context: _redactNullable(details.context?.toDescription()), + information: [ + ..._collectInformation(details), + if (reportError != null) LogRedactor.redact('Crash report generation failed: $reportError'), + if (reportStackTrace != null) LogRedactor.redact(reportStackTrace.toString()), + ], + relevantLogs: '', + ); + } + + static const int maxLogLines = 160; + static const int maxLogCharacters = 24000; + static const int _maxLogBytesToRead = 96 * 1024; + + final DateTime generatedAt; + final String error; + final String stackTrace; + final String? library; + final String? context; + final List information; + final String relevantLogs; + + static Future fromFlutterErrorDetails( + FlutterErrorDetails details, { + required Future Function() todaysLogFileProvider, + DateTime? generatedAt, + }) async { + return CrashReportBundle( + generatedAt: generatedAt ?? DateTime.now(), + error: LogRedactor.redact(details.exceptionAsString()), + stackTrace: LogRedactor.redact(details.stack?.toString() ?? StackTrace.current.toString()), + library: _redactNullable(details.library), + context: _redactNullable(details.context?.toDescription()), + information: _collectInformation(details), + relevantLogs: await _loadRelevantLogs(todaysLogFileProvider), + ); + } + + String get copyText { + final buffer = StringBuffer() + ..writeln('Lazurite crash report') + ..writeln('Generated: ${generatedAt.toIso8601String()}') + ..writeln('Platform: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}') + ..writeln() + ..writeln('Error:') + ..writeln(error) + ..writeln(); + + if (library != null && library!.isNotEmpty) { + buffer + ..writeln('Library:') + ..writeln(library) + ..writeln(); + } + + if (context != null && context!.isNotEmpty) { + buffer + ..writeln('Context:') + ..writeln(context) + ..writeln(); + } + + if (information.isNotEmpty) { + buffer + ..writeln('Diagnostics:') + ..writeln(information.join('\n')) + ..writeln(); + } + + buffer + ..writeln('Stack trace:') + ..writeln(stackTrace) + ..writeln() + ..writeln('Relevant logs:') + ..writeln(relevantLogs.isEmpty ? 'No recent log lines were available.' : relevantLogs); + + return buffer.toString(); + } + + static String? _redactNullable(String? value) { + if (value == null || value.isEmpty) { + return null; + } + return LogRedactor.redact(value); + } + + static List _collectInformation(FlutterErrorDetails details) { + final collector = details.informationCollector; + if (collector == null) { + return const []; + } + + return collector() + .map((node) => LogRedactor.redact(node.toStringDeep(minLevel: DiagnosticLevel.info).trim())) + .where((line) => line.isNotEmpty) + .toList(growable: false); + } + + static Future _loadRelevantLogs(Future Function() todaysLogFileProvider) async { + final file = await todaysLogFileProvider(); + if (file == null || !await file.exists()) { + return ''; + } + + final bytes = await _readTailBytes(file); + if (bytes.isEmpty) { + return ''; + } + + final fileLength = await file.length(); + var decoded = utf8.decode(bytes, allowMalformed: true); + if (fileLength > bytes.length) { + final firstNewline = decoded.indexOf('\n'); + decoded = firstNewline == -1 ? '' : decoded.substring(firstNewline + 1); + } + + final lines = decoded + .split('\n') + .where((line) => line.trim().isNotEmpty) + .map(LogRedactor.redact) + .toList(growable: false); + if (lines.isEmpty) { + return ''; + } + + var selected = lines.length > maxLogLines ? lines.sublist(lines.length - maxLogLines) : lines; + var joined = selected.join('\n'); + if (joined.length <= maxLogCharacters) { + return joined; + } + + final trimmed = joined.substring(joined.length - maxLogCharacters); + final firstNewline = trimmed.indexOf('\n'); + joined = firstNewline == -1 ? trimmed : trimmed.substring(firstNewline + 1); + selected = joined.split('\n'); + return '[Earlier log lines omitted]\n${selected.join('\n')}'; + } + + static Future> _readTailBytes(File file) async { + final fileLength = await file.length(); + if (fileLength <= 0) { + return const []; + } + + final bytesToRead = math.min(fileLength, _maxLogBytesToRead); + final start = fileLength - bytesToRead; + final randomAccessFile = await file.open(); + try { + await randomAccessFile.setPosition(start); + return await randomAccessFile.read(bytesToRead); + } finally { + await randomAccessFile.close(); + } + } +} diff --git a/lib/core/error_reporting/crash_report_screen.dart b/lib/core/error_reporting/crash_report_screen.dart new file mode 100644 index 0000000..7ac582a --- /dev/null +++ b/lib/core/error_reporting/crash_report_screen.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lazurite/core/error_reporting/crash_report_bundle.dart'; +import 'package:lazurite/core/l10n/app_localizations.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:url_launcher/url_launcher.dart'; + +typedef CrashReportBuilder = Future Function(FlutterErrorDetails details); +typedef CrashReportEmailLauncher = Future Function(Uri uri); + +class CrashReportScreen extends StatefulWidget { + const CrashReportScreen({ + super.key, + required this.details, + CrashReportBuilder? reportBuilder, + CrashReportEmailLauncher? emailLauncher, + }) : _reportBuilder = reportBuilder, + _emailLauncher = emailLauncher; + + static const String supportEmail = 'info@stormlightlabs.org'; + + final FlutterErrorDetails details; + final CrashReportBuilder? _reportBuilder; + final CrashReportEmailLauncher? _emailLauncher; + + @override + State createState() => _CrashReportScreenState(); +} + +class _CrashReportScreenState extends State { + static const int _maxEmailStackTraceCharacters = 4000; + + late Future _reportFuture; + var _reportBuildFailed = false; + + @override + void initState() { + super.initState(); + _reportFuture = _createReportFuture(); + } + + Future _buildReport() { + final builder = widget._reportBuilder; + if (builder != null) { + return builder(widget.details); + } + return CrashReportBundle.fromFlutterErrorDetails(widget.details, todaysLogFileProvider: log.getTodaysLogFile); + } + + Future _createReportFuture() async { + await Future.delayed(Duration.zero); + try { + return await _buildReport(); + } catch (error, stackTrace) { + _reportBuildFailed = true; + return CrashReportBundle.fallbackFromFlutterErrorDetails( + widget.details, + reportError: error, + reportStackTrace: stackTrace, + ); + } + } + + Future _copyReport(CrashReportBundle report) async { + final messenger = ScaffoldMessenger.maybeOf(context); + await Clipboard.setData(ClipboardData(text: report.copyText)); + if (!mounted) { + return; + } + messenger?.showSnackBar(SnackBar(content: Text(context.l10n.messageCrashReportCopied))); + } + + Future _emailReport(CrashReportBundle report) async { + final messenger = ScaffoldMessenger.maybeOf(context); + final l10n = context.l10n; + final uri = Uri( + scheme: 'mailto', + path: CrashReportScreen.supportEmail, + queryParameters: { + 'subject': l10n.subjectLazuriteCrashReport, + 'body': l10n.formatCrashReportEmailBody(report.error, _stackTraceForEmail(report.stackTrace, l10n)), + }, + ); + + final launcher = widget._emailLauncher ?? ((uri) => launchUrl(uri, mode: LaunchMode.externalApplication)); + final launched = await launcher(uri); + if (!mounted) { + return; + } + if (!launched) { + messenger?.showSnackBar(SnackBar(content: Text(l10n.messageUnableToOpenEmailApp))); + } + } + + void _retryReport() { + setState(() { + _reportBuildFailed = false; + _reportFuture = _createReportFuture(); + }); + } + + String _stackTraceForEmail(String stackTrace, AppLocalizations l10n) { + if (stackTrace.length <= _maxEmailStackTraceCharacters) { + return stackTrace; + } + return '${stackTrace.substring(0, _maxEmailStackTraceCharacters)}\n${l10n.messageCrashReportEmailStackTraceTruncated}'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = context.l10n; + + return Material( + color: colorScheme.surface, + child: SafeArea( + child: FutureBuilder( + future: _reportFuture, + builder: (context, snapshot) { + final report = + snapshot.data ?? + (snapshot.hasError + ? CrashReportBundle.fallbackFromFlutterErrorDetails( + widget.details, + reportError: snapshot.error, + reportStackTrace: snapshot.stackTrace, + ) + : null); + final errorText = report?.error ?? widget.details.exceptionAsString(); + final reportBuildFailed = snapshot.hasError || _reportBuildFailed; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon(Icons.error_outline, size: 48, color: colorScheme.error), + const SizedBox(height: 16), + Text(l10n.errorGenericTitle, style: theme.textTheme.headlineSmall, textAlign: TextAlign.center), + const SizedBox(height: 8), + Text( + l10n.messageCrashReportInstructions, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + if (reportBuildFailed) ...[ + const SizedBox(height: 12), + Text( + l10n.messageCrashReportPartial, + style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.error), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 20), + Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: report == null ? null : () => unawaited(_copyReport(report)), + icon: const Icon(Icons.copy_outlined), + label: Text(l10n.buttonCopyReport), + ), + OutlinedButton.icon( + onPressed: report == null ? null : () => unawaited(_emailReport(report)), + icon: const Icon(Icons.email_outlined), + label: Text(l10n.buttonEmailReport), + ), + if (reportBuildFailed) + TextButton.icon( + onPressed: _retryReport, + icon: const Icon(Icons.refresh_outlined), + label: Text(l10n.buttonRetry), + ), + ], + ), + const SizedBox(height: 20), + _ReportPreview(title: l10n.labelCrashReportError, text: errorText), + const SizedBox(height: 12), + if (report == null) + const Center(child: CircularProgressIndicator()) + else ...[ + _ReportPreview(title: l10n.labelCrashReportStackTrace, text: report.stackTrace), + const SizedBox(height: 12), + _ReportPreview( + title: l10n.labelCrashReportRelevantLogs, + text: report.relevantLogs.isEmpty ? l10n.messageNoRecentLogLinesAvailable : report.relevantLogs, + ), + ], + ], + ), + ), + ); + }, + ), + ), + ); + } +} + +class _ReportPreview extends StatelessWidget { + const _ReportPreview({required this.title, required this.text}); + + final String title; + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + SelectableText(text, style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace')), + ], + ), + ), + ); + } +} diff --git a/lib/core/l10n/app_localizations.dart b/lib/core/l10n/app_localizations.dart index 47e5e17..c8adcc3 100644 --- a/lib/core/l10n/app_localizations.dart +++ b/lib/core/l10n/app_localizations.dart @@ -175,12 +175,24 @@ abstract class AppLocalizations { /// **'Clear'** String get buttonClear; + /// Button label to copy an in-app crash report + /// + /// In en, this message translates to: + /// **'Copy report'** + String get buttonCopyReport; + /// Clear all filters button label /// /// In en, this message translates to: /// **'Clear all'** String get buttonClearAll; + /// Button label to open an email draft for an in-app crash report + /// + /// In en, this message translates to: + /// **'Email report'** + String get buttonEmailReport; + /// Open button label /// /// In en, this message translates to: @@ -835,6 +847,30 @@ abstract class AppLocalizations { /// **'Crashlytics Test Crash'** String get labelCrashlyticsTestCrash; + /// Developer setting to trigger a recoverable Flutter crash report screen + /// + /// In en, this message translates to: + /// **'Crash Report Screen Test'** + String get labelCrashReportScreenTest; + + /// Crash report section title for the error message + /// + /// In en, this message translates to: + /// **'Error'** + String get labelCrashReportError; + + /// Crash report section title for related app logs + /// + /// In en, this message translates to: + /// **'Relevant logs'** + String get labelCrashReportRelevantLogs; + + /// Crash report section title for the stack trace + /// + /// In en, this message translates to: + /// **'Stack trace'** + String get labelCrashReportStackTrace; + /// Cross-provider fallback setting label /// /// In en, this message translates to: @@ -1543,6 +1579,42 @@ abstract class AppLocalizations { /// **'Intentionally crash to validate Crashlytics reports'** String get messageCrashlyticsTestCrashSubtitle; + /// Developer setting subtitle for testing the in-app crash report screen + /// + /// In en, this message translates to: + /// **'Open a recoverable Flutter error screen with copy and email actions'** + String get messageCrashReportScreenTestSubtitle; + + /// Snackbar message after copying a crash report + /// + /// In en, this message translates to: + /// **'Crash report copied'** + String get messageCrashReportCopied; + + /// Instructions shown on the in-app crash report screen + /// + /// In en, this message translates to: + /// **'You can copy the crash report or open an email to send a summary to Stormlight Labs.'** + String get messageCrashReportInstructions; + + /// Message shown when the in-app crash report could not load all diagnostics + /// + /// In en, this message translates to: + /// **'Some report details could not be loaded. A minimal report is still available.'** + String get messageCrashReportPartial; + + /// Marker appended when a crash report stack trace is shortened for an email draft + /// + /// In en, this message translates to: + /// **'[Stack trace truncated for email]'** + String get messageCrashReportEmailStackTraceTruncated; + + /// Email body for an in-app crash report. Keep this compact because it is encoded into a mailto URL. + /// + /// In en, this message translates to: + /// **'A Lazurite screen crashed.\n\nThe full report may be too large for email. Please use Copy report in the app if support asks for the full details.\n\nError:\n{error}\n\nStack trace:\n{stackTrace}'** + String formatCrashReportEmailBody(String error, String stackTrace); + /// Settings subtitle for cross-provider fallback /// /// In en, this message translates to: @@ -1567,6 +1639,12 @@ abstract class AppLocalizations { /// **'Loading saved accounts...'** String get messageLoadingSavedAccounts; + /// Crash report message when there are no relevant log lines + /// + /// In en, this message translates to: + /// **'No recent log lines were available.'** + String get messageNoRecentLogLinesAvailable; + /// Feed layout compact option /// /// In en, this message translates to: @@ -4285,6 +4363,12 @@ abstract class AppLocalizations { /// **'Unable to open share sheet. Please try again.'** String get messageUnableToOpenShareSheet; + /// Snackbar when the crash report email draft cannot open + /// + /// In en, this message translates to: + /// **'Unable to open email app. Please copy the report instead.'** + String get messageUnableToOpenEmailApp; + /// Devtools search input placeholder /// /// In en, this message translates to: @@ -4309,6 +4393,12 @@ abstract class AppLocalizations { /// **'Lazurite logs'** String get subjectLazuriteLogs; + /// Email subject for an in-app crash report + /// + /// In en, this message translates to: + /// **'Lazurite crash report'** + String get subjectLazuriteCrashReport; + /// Tooltip for clearing all log files /// /// In en, this message translates to: diff --git a/lib/core/l10n/app_localizations_en.dart b/lib/core/l10n/app_localizations_en.dart index 8109773..7ee47ef 100644 --- a/lib/core/l10n/app_localizations_en.dart +++ b/lib/core/l10n/app_localizations_en.dart @@ -50,9 +50,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get buttonClear => 'Clear'; + @override + String get buttonCopyReport => 'Copy report'; + @override String get buttonClearAll => 'Clear all'; + @override + String get buttonEmailReport => 'Email report'; + @override String get buttonOpen => 'Open'; @@ -460,6 +466,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelCrashlyticsTestCrash => 'Crashlytics Test Crash'; + @override + String get labelCrashReportScreenTest => 'Crash Report Screen Test'; + + @override + String get labelCrashReportError => 'Error'; + + @override + String get labelCrashReportRelevantLogs => 'Relevant logs'; + + @override + String get labelCrashReportStackTrace => 'Stack trace'; + @override String get labelCrossProviderFallback => 'Cross-Provider Fallback'; @@ -816,6 +834,29 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messageCrashlyticsTestCrashSubtitle => 'Intentionally crash to validate Crashlytics reports'; + @override + String get messageCrashReportScreenTestSubtitle => + 'Open a recoverable Flutter error screen with copy and email actions'; + + @override + String get messageCrashReportCopied => 'Crash report copied'; + + @override + String get messageCrashReportInstructions => + 'You can copy the crash report or open an email to send a summary to Stormlight Labs.'; + + @override + String get messageCrashReportPartial => + 'Some report details could not be loaded. A minimal report is still available.'; + + @override + String get messageCrashReportEmailStackTraceTruncated => '[Stack trace truncated for email]'; + + @override + String formatCrashReportEmailBody(String error, String stackTrace) { + return 'A Lazurite screen crashed.\n\nThe full report may be too large for email. Please use Copy report in the app if support asks for the full details.\n\nError:\n$error\n\nStack trace:\n$stackTrace'; + } + @override String get messageCrossProviderFallbackSubtitle => 'Retry public reads on the alternate AppView when transient errors occur'; @@ -829,6 +870,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messageLoadingSavedAccounts => 'Loading saved accounts...'; + @override + String get messageNoRecentLogLinesAvailable => 'No recent log lines were available.'; + @override String get messageFeedLayoutCompact => 'Compact'; @@ -2344,6 +2388,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messageUnableToOpenShareSheet => 'Unable to open share sheet. Please try again.'; + @override + String get messageUnableToOpenEmailApp => 'Unable to open email app. Please copy the report instead.'; + @override String get placeholderHandleDidOrAtUri => 'Handle, DID, or at:// URI'; @@ -2356,6 +2403,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get subjectLazuriteLogs => 'Lazurite logs'; + @override + String get subjectLazuriteCrashReport => 'Lazurite crash report'; + @override String get tooltipClearAllLogs => 'Clear all logs'; diff --git a/lib/core/l10n/intl_en.arb b/lib/core/l10n/intl_en.arb index cac9bc0..9adbd67 100644 --- a/lib/core/l10n/intl_en.arb +++ b/lib/core/l10n/intl_en.arb @@ -57,10 +57,18 @@ "@buttonClear": { "description": "Clear button label" }, + "buttonCopyReport": "Copy report", + "@buttonCopyReport": { + "description": "Button label to copy an in-app crash report" + }, "buttonClearAll": "Clear all", "@buttonClearAll": { "description": "Clear all filters button label" }, + "buttonEmailReport": "Email report", + "@buttonEmailReport": { + "description": "Button label to open an email draft for an in-app crash report" + }, "buttonOpen": "Open", "@buttonOpen": { "description": "Open button label" @@ -675,6 +683,22 @@ "@labelCrashlyticsTestCrash": { "description": "Developer setting to trigger a test crash" }, + "labelCrashReportScreenTest": "Crash Report Screen Test", + "@labelCrashReportScreenTest": { + "description": "Developer setting to trigger a recoverable Flutter crash report screen" + }, + "labelCrashReportError": "Error", + "@labelCrashReportError": { + "description": "Crash report section title for the error message" + }, + "labelCrashReportRelevantLogs": "Relevant logs", + "@labelCrashReportRelevantLogs": { + "description": "Crash report section title for related app logs" + }, + "labelCrashReportStackTrace": "Stack trace", + "@labelCrashReportStackTrace": { + "description": "Crash report section title for the stack trace" + }, "labelCrossProviderFallback": "Cross-Provider Fallback", "@labelCrossProviderFallback": { "description": "Cross-provider fallback setting label" @@ -1147,6 +1171,40 @@ "@messageCrashlyticsTestCrashSubtitle": { "description": "Developer setting subtitle for test crash" }, + "messageCrashReportScreenTestSubtitle": "Open a recoverable Flutter error screen with copy and email actions", + "@messageCrashReportScreenTestSubtitle": { + "description": "Developer setting subtitle for testing the in-app crash report screen" + }, + "messageCrashReportCopied": "Crash report copied", + "@messageCrashReportCopied": { + "description": "Snackbar message after copying a crash report" + }, + "messageCrashReportInstructions": "You can copy the crash report or open an email to send a summary to Stormlight Labs.", + "@messageCrashReportInstructions": { + "description": "Instructions shown on the in-app crash report screen" + }, + "messageCrashReportPartial": "Some report details could not be loaded. A minimal report is still available.", + "@messageCrashReportPartial": { + "description": "Message shown when the in-app crash report could not load all diagnostics" + }, + "messageCrashReportEmailStackTraceTruncated": "[Stack trace truncated for email]", + "@messageCrashReportEmailStackTraceTruncated": { + "description": "Marker appended when a crash report stack trace is shortened for an email draft" + }, + "formatCrashReportEmailBody": "A Lazurite screen crashed.\n\nThe full report may be too large for email. Please use Copy report in the app if support asks for the full details.\n\nError:\n{error}\n\nStack trace:\n{stackTrace}", + "@formatCrashReportEmailBody": { + "description": "Email body for an in-app crash report. Keep this compact because it is encoded into a mailto URL.", + "placeholders": { + "error": { + "type": "String", + "example": "Bad state: failed to load" + }, + "stackTrace": { + "type": "String", + "example": "#0 Widget.build" + } + } + }, "messageCrossProviderFallbackSubtitle": "Retry public reads on the alternate AppView when transient errors occur", "@messageCrossProviderFallbackSubtitle": { "description": "Settings subtitle for cross-provider fallback" @@ -1163,6 +1221,10 @@ "@messageLoadingSavedAccounts": { "description": "Loading message while saved accounts load" }, + "messageNoRecentLogLinesAvailable": "No recent log lines were available.", + "@messageNoRecentLogLinesAvailable": { + "description": "Crash report message when there are no relevant log lines" + }, "messageFeedLayoutCompact": "Compact", "@messageFeedLayoutCompact": { "description": "Feed layout compact option" @@ -3335,6 +3397,10 @@ "@messageUnableToOpenShareSheet": { "description": "Snackbar when the log share sheet cannot open" }, + "messageUnableToOpenEmailApp": "Unable to open email app. Please copy the report instead.", + "@messageUnableToOpenEmailApp": { + "description": "Snackbar when the crash report email draft cannot open" + }, "placeholderHandleDidOrAtUri": "Handle, DID, or at:// URI", "@placeholderHandleDidOrAtUri": { "description": "Devtools search input placeholder" @@ -3351,6 +3417,10 @@ "@subjectLazuriteLogs": { "description": "Share sheet subject for log files" }, + "subjectLazuriteCrashReport": "Lazurite crash report", + "@subjectLazuriteCrashReport": { + "description": "Email subject for an in-app crash report" + }, "tooltipClearAllLogs": "Clear all logs", "@tooltipClearAllLogs": { "description": "Tooltip for clearing all log files" diff --git a/lib/features/settings/presentation/screens/recoverable_crash_test_screen.dart b/lib/features/settings/presentation/screens/recoverable_crash_test_screen.dart new file mode 100644 index 0000000..b72170a --- /dev/null +++ b/lib/features/settings/presentation/screens/recoverable_crash_test_screen.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class RecoverableCrashTestScreen extends StatelessWidget { + const RecoverableCrashTestScreen({super.key, required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(title)), + body: const _RecoverableCrashTestBody(), + ); + } +} + +class _RecoverableCrashTestBody extends StatelessWidget { + const _RecoverableCrashTestBody(); + + @override + Widget build(BuildContext context) { + throw StateError('Recoverable crash report screen test'); + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 717f157..cba03bd 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -8,7 +8,6 @@ import 'package:lazurite/core/cache/local_cache_maintenance_service.dart'; import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; -import 'package:lazurite/core/network/atproto_host_resolver.dart'; import 'package:lazurite/core/network/xrpc_network_interceptor.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; @@ -20,6 +19,11 @@ import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; +import 'package:lazurite/features/settings/presentation/screens/recoverable_crash_test_screen.dart'; +import 'package:lazurite/features/settings/presentation/widgets/atproto_connection.dart'; +import 'package:lazurite/features/settings/presentation/widgets/connection_detail.dart'; +import 'package:lazurite/features/settings/presentation/widgets/settings_tiles.dart'; +import 'package:lazurite/features/settings/presentation/widgets/theme_palette_row.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; @@ -101,21 +105,21 @@ class SettingsScreen extends StatelessWidget { if (showAccountSettings) ...[ const SizedBox(height: 24), _buildSectionHeader(context, l10n.labelAccount), - const _AtProtocolConnectionCard(), + const AtProtoConnectionCard(), const SizedBox(height: 12), - _SettingsTile( + SettingsTile( icon: Icons.dynamic_feed_outlined, title: l10n.labelFeeds, subtitle: l10n.messageFeedsSubtitle, onTap: () => context.push('/feeds'), ), - _SettingsTile( + SettingsTile( icon: Icons.bookmark_outline, title: l10n.labelBookmarksAndLikes, subtitle: l10n.messageBookmarksAndLikesSubtitle, onTap: () => context.push('/bookmarks'), ), - _SettingsTile( + SettingsTile( icon: Icons.videocam_outlined, title: l10n.labelVideoUploadLimits, subtitle: l10n.messageVideoUploadLimitsSubtitle, @@ -123,7 +127,7 @@ class SettingsScreen extends StatelessWidget { ), const SizedBox(height: 24), _buildSectionHeader(context, l10n.labelAccountMaintenance), - _SettingsTile( + SettingsTile( icon: Icons.cleaning_services_outlined, title: l10n.labelCleanFollows, subtitle: l10n.messageCleanFollowsSubtitle, @@ -137,31 +141,31 @@ class SettingsScreen extends StatelessWidget { _buildSectionHeader(context, l10n.labelTroubleshooting), _buildTroubleshootingSettings(context), const SizedBox(height: 24), - if (!kReleaseMode || kDebugMode) ...[ + if (!kReleaseMode) ...[ _buildSectionHeader(context, l10n.labelDeveloper), _buildDeveloperSettings(context), const SizedBox(height: 24), ], _buildSectionHeader(context, l10n.labelAbout), - _SettingsTile( + SettingsTile( icon: Icons.explore_outlined, title: l10n.labelAtExplorer, subtitle: 'View PDS Records', onTap: () => context.push('/settings/devtools'), ), - _SettingsTile( + SettingsTile( icon: Icons.info_outline, title: l10n.labelAbout, subtitle: 'Stormlight Labs', onTap: () => context.push('/settings/about'), ), - _SettingsTile( + SettingsTile( icon: Icons.gavel_outlined, title: l10n.labelTermsOfService, subtitle: 'Usage rules and responsibilities', onTap: () => context.push('/terms'), ), - _SettingsTile( + SettingsTile( icon: Icons.privacy_tip_outlined, title: l10n.labelPrivacyPolicy, subtitle: 'How Lazurite handles data', @@ -170,7 +174,7 @@ class SettingsScreen extends StatelessWidget { if (showAccountSettings) ...[ const SizedBox(height: 24), _buildSectionHeader(context, l10n.labelDangerZone), - _SettingsTile( + SettingsTile( icon: Icons.logout, title: l10n.labelLogOut, isDestructive: true, @@ -217,26 +221,26 @@ class SettingsScreen extends StatelessWidget { Padding( padding: const EdgeInsets.all(16), child: Center( - child: SegmentedButton<_AppearanceMode>( + child: SegmentedButton( style: SegmentedButton.styleFrom( selectedBackgroundColor: theme.colorScheme.primary, selectedForegroundColor: theme.colorScheme.onPrimary, ), segments: [ - ButtonSegment(value: _AppearanceMode.system, label: Text(l10n.labelSystem)), - ButtonSegment(value: _AppearanceMode.light, label: Text(l10n.labelLight)), - ButtonSegment(value: _AppearanceMode.dark, label: Text(l10n.labelDark)), + ButtonSegment(value: AppearanceMode.system, label: Text(l10n.labelSystem)), + ButtonSegment(value: AppearanceMode.light, label: Text(l10n.labelLight)), + ButtonSegment(value: AppearanceMode.dark, label: Text(l10n.labelDark)), ], - selected: {_AppearanceMode.fromState(state)}, + selected: {AppearanceMode.fromState(state)}, onSelectionChanged: (selected) { final mode = selected.first; switch (mode) { - case _AppearanceMode.system: + case AppearanceMode.system: settingsCubit.setUseSystemTheme(true); - case _AppearanceMode.light: + case AppearanceMode.light: settingsCubit.setUseSystemTheme(false); settingsCubit.setThemeVariant(AppThemeVariant.light); - case _AppearanceMode.dark: + case AppearanceMode.dark: settingsCubit.setUseSystemTheme(false); settingsCubit.setThemeVariant(AppThemeVariant.dark); } @@ -256,7 +260,7 @@ class SettingsScreen extends StatelessWidget { ), ), for (final palette in AppThemePalette.values) - _ThemePaletteRow( + ThemePaletteRow( palette: palette, isSelected: state.themePalette == palette, onTap: () => settingsCubit.setThemePalette(palette), @@ -286,7 +290,7 @@ class SettingsScreen extends StatelessWidget { ), child: Column( children: [ - _SettingsDropdownTile( + SettingsDropdownTile( title: l10n.labelFeedLayout, value: state.feedLayout, options: FeedLayout.values, @@ -301,7 +305,7 @@ class SettingsScreen extends StatelessWidget { }, ), const Divider(height: 1), - _SettingsDropdownTile( + SettingsDropdownTile( title: l10n.labelThreadAutoCollapse, subtitle: l10n.messageThreadAutoCollapseSubtitle, value: state.threadAutoCollapseDepth, @@ -310,7 +314,7 @@ class SettingsScreen extends StatelessWidget { onChanged: settingsCubit.setThreadAutoCollapseDepth, ), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.motion_photos_off_outlined, title: l10n.labelAnimations, subtitle: l10n.messageTurnOffNonEssentialMotion, @@ -369,7 +373,7 @@ class SettingsScreen extends StatelessWidget { ), const Divider(height: 1), ], - _SettingsTile( + SettingsTile( icon: Icons.manage_search_outlined, title: l10n.labelSemanticSearch, subtitle: l10n.messageManageSemanticSearchSubtitle, @@ -398,23 +402,35 @@ class SettingsScreen extends StatelessWidget { ), child: Column( children: [ - _SettingsTile( + SettingsTile( icon: Icons.cloud_off_outlined, title: l10n.labelGoOffline, subtitle: l10n.messageDeveloperGoOfflineSubtitle, trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), ), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.bug_report_outlined, title: l10n.labelCrashlyticsTestCrash, subtitle: l10n.messageCrashlyticsTestCrashSubtitle, trailing: const Icon(Icons.warning_amber_rounded), onTap: crashReportingService?.crash, ), + const Divider(height: 1), + SettingsTile( + icon: Icons.integration_instructions_outlined, + title: l10n.labelCrashReportScreenTest, + subtitle: l10n.messageCrashReportScreenTestSubtitle, + trailing: const Icon(Icons.open_in_new_outlined), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => RecoverableCrashTestScreen(title: l10n.labelCrashReportScreenTest), + ), + ), + ), if (kDebugMode || kProfileMode) ...[ const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.lock_reset_outlined, title: l10n.labelForceNextXrpc401, subtitle: l10n.messageForceNextXrpc401Subtitle, @@ -456,16 +472,16 @@ class SettingsScreen extends StatelessWidget { ), child: Column( children: [ - _SettingsTile( + SettingsTile( icon: Icons.description_outlined, title: l10n.labelLogs, subtitle: 'View app log files', onTap: () => context.push('/settings/logs'), ), const Divider(height: 1), - _ConstellationUrlTile(currentUrl: state.constellationUrl), + ConstellationUrlTile(currentUrl: state.constellationUrl), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.route_outlined, title: l10n.labelAppViewProvider, subtitle: _appViewSubtitle(context, state.appViewProvider), @@ -492,7 +508,7 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.compare_arrows_outlined, title: l10n.labelCrossProviderFallback, subtitle: l10n.messageCrossProviderFallbackSubtitle, @@ -502,7 +518,7 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.alt_route_outlined, title: l10n.labelSlingshotIdentityFallback, subtitle: l10n.messageSlingshotIdentityFallbackSubtitle, @@ -512,7 +528,7 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.bug_report_outlined, title: l10n.labelCrashReporting, subtitle: state.crashReportingEnabled @@ -524,33 +540,33 @@ class SettingsScreen extends StatelessWidget { ), ), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.monitor_heart_outlined, title: l10n.labelProviderDiagnostics, subtitle: l10n.messageProviderDiagnosticsSubtitle, ), - _ConnectionDetailRow( + ConnectionDetailRow( label: l10n.labelActiveProvider, value: AppViewProviders.providerDisplayName(state.appViewProvider), ), const Divider(height: 1), - _ConnectionDetailRow( + ConnectionDetailRow( label: l10n.labelHealth, value: state.appViewHealthSummary ?? l10n.commonNotCheckedYet, ), const Divider(height: 1), - _ConnectionDetailRow( + ConnectionDetailRow( label: l10n.labelLastHealthCheck, value: state.appViewHealthCheckedAt == null ? l10n.commonNever : formatTimestamp(state.appViewHealthCheckedAt!.toLocal()), ), const Divider(height: 1), - _ConnectionDetailRow(label: l10n.labelLastFallback, value: state.appViewLastFallback ?? l10n.commonNone), + ConnectionDetailRow(label: l10n.labelLastFallback, value: state.appViewLastFallback ?? l10n.commonNone), const Divider(height: 1), - _ConnectionDetailRow(label: l10n.labelLastError, value: state.appViewLastError ?? l10n.commonNone), + ConnectionDetailRow(label: l10n.labelLastError, value: state.appViewLastError ?? l10n.commonNone), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.medical_information_outlined, title: l10n.labelRefreshProviderHealth, subtitle: l10n.messageRefreshProviderHealthSubtitle, @@ -626,14 +642,14 @@ class SettingsScreen extends StatelessWidget { ), child: Column( children: [ - _SettingsTile( + SettingsTile( icon: Icons.cached_outlined, title: l10n.labelClearCache, subtitle: l10n.messageClearCacheSubtitle, onTap: () => unawaited(_confirmAndClearCaches(context)), ), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.manage_accounts_outlined, title: l10n.labelResetSignInData, subtitle: l10n.messageResetSignInDataSubtitle, @@ -749,7 +765,7 @@ class _ModerationSettingsPreviewState extends State<_ModerationSettingsPreview> final service = _service; final l10n = context.l10n; if (service == null) { - return _SettingsTile( + return SettingsTile( icon: Icons.shield_outlined, title: l10n.labelContentModeration, subtitle: l10n.messageContentModerationSubtitle, @@ -767,14 +783,14 @@ class _ModerationSettingsPreviewState extends State<_ModerationSettingsPreview> return Column( children: [ - _SettingsTile( + SettingsTile( icon: Icons.visibility_outlined, title: l10n.labelAdultContent, subtitle: adultEnabled ? l10n.messageAdultContentEnabled : l10n.messageAdultContentRequired, trailing: Switch.adaptive(value: adultEnabled, onChanged: _isUpdating ? null : _toggleAdultContent), ), const Divider(height: 1), - _SettingsTile( + SettingsTile( icon: Icons.policy_outlined, title: l10n.labelContentModeration, subtitle: l10n.formatContentModerationCustomLabelers(customLabelers), @@ -786,224 +802,3 @@ class _ModerationSettingsPreviewState extends State<_ModerationSettingsPreview> ); } } - -class _AtProtocolConnectionCard extends StatelessWidget { - const _AtProtocolConnectionCard(); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, authState) { - final tokens = authState.tokens; - if (!authState.isAuthenticated || tokens == null) { - return const SizedBox.shrink(); - } - - final pds = resolvePdsHost(tokens); - final theme = Theme.of(context); - final l10n = context.l10n; - return Container( - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.dividerColor), - bottom: BorderSide(color: theme.dividerColor), - ), - color: theme.cardColor, - ), - child: SelectionArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), - child: Text(l10n.labelAtProtocolConnection, style: context.textTheme.titleMedium), - ), - const Divider(height: 1), - _ConnectionDetailRow(label: 'Handle', value: '@${tokens.handle}'), - const Divider(height: 1), - _ConnectionDetailRow( - label: 'DID', - value: tokens.did, - onTap: () => context.push('/settings/devtools?query=${Uri.encodeQueryComponent(tokens.did)}'), - ), - const Divider(height: 1), - _ConnectionDetailRow(label: 'PDS', value: pds), - ], - ), - ), - ); - }, - ); - } -} - -enum _AppearanceMode { - system, - light, - dark; - - static _AppearanceMode fromState(SettingsState state) { - if (state.useSystemTheme) return system; - return state.themeVariant == AppThemeVariant.light ? light : dark; - } -} - -class _ThemePaletteRow extends StatelessWidget { - const _ThemePaletteRow({required this.palette, required this.isSelected, required this.onTap}); - - final AppThemePalette palette; - final bool isSelected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final swatches = AppTheme.getSwatchColors(palette); - - return ListTile( - onTap: onTap, - title: Text(AppTheme.getPaletteName(palette)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final color in swatches) - Padding( - padding: const EdgeInsets.only(left: 4), - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)), - ), - ), - if (isSelected) ...[ - const SizedBox(width: 12), - Icon(Icons.check, color: context.colorScheme.primary, size: 20), - ], - ], - ), - ); - } -} - -class _SettingsDropdownTile extends StatelessWidget { - const _SettingsDropdownTile({ - required this.title, - required this.value, - required this.options, - required this.labelBuilder, - required this.onChanged, - this.subtitle, - }); - - final String title; - final String? subtitle; - final T value; - final List options; - final String Function(T value) labelBuilder; - final ValueChanged onChanged; - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(title), - subtitle: subtitle != null ? Text(subtitle!) : null, - trailing: DropdownButtonHideUnderline( - child: DropdownButton( - value: value, - onChanged: onChanged, - items: [for (final option in options) DropdownMenuItem(value: option, child: Text(labelBuilder(option)))], - ), - ), - ); - } -} - -class _ConnectionDetailRow extends StatelessWidget { - const _ConnectionDetailRow({required this.label, required this.value, this.onTap}); - - final String label; - final String value; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final content = Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label.toUpperCase(), - style: theme.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), - ), - const SizedBox(height: 4), - Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'JetBrains Mono')), - ], - ), - ), - if (onTap != null) ...[ - const SizedBox(width: 12), - Icon(Icons.open_in_new, size: 18, color: theme.colorScheme.onSurfaceVariant), - ], - ], - ), - ); - - if (onTap == null) { - return content; - } - - return InkWell(onTap: onTap, child: content); - } -} - -class _ConstellationUrlTile extends StatelessWidget { - const _ConstellationUrlTile({required this.currentUrl}); - - final String currentUrl; - - @override - Widget build(BuildContext context) { - return ListTile( - leading: const Icon(Icons.hub_outlined), - title: const Text('Constellation URL'), - subtitle: Text(currentUrl, maxLines: 1, overflow: TextOverflow.ellipsis), - ); - } -} - -class _SettingsTile extends StatelessWidget { - const _SettingsTile({ - required this.title, - this.subtitle, - this.icon, - this.trailing, - this.isDestructive = false, - this.onTap, - }); - - final String title; - final String? subtitle; - final IconData? icon; - final Widget? trailing; - final bool isDestructive; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final color = isDestructive ? context.colorScheme.error : null; - - return ListTile( - leading: icon != null ? Icon(icon, color: color) : null, - title: Text(title, style: TextStyle(color: color)), - subtitle: subtitle != null ? Text(subtitle!) : null, - trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right) : null), - onTap: onTap, - ); - } -} diff --git a/lib/features/settings/presentation/widgets/atproto_connection.dart b/lib/features/settings/presentation/widgets/atproto_connection.dart new file mode 100644 index 0000000..ddf86e9 --- /dev/null +++ b/lib/features/settings/presentation/widgets/atproto_connection.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/network/atproto_host_resolver.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; +import 'package:lazurite/features/settings/presentation/widgets/connection_detail.dart'; + +class AtProtoConnectionCard extends StatelessWidget { + const AtProtoConnectionCard({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, authState) { + final tokens = authState.tokens; + if (!authState.isAuthenticated || tokens == null) { + return const SizedBox.shrink(); + } + + final pds = resolvePdsHost(tokens); + final theme = Theme.of(context); + final l10n = context.l10n; + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.dividerColor), + bottom: BorderSide(color: theme.dividerColor), + ), + color: theme.cardColor, + ), + child: SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Text(l10n.labelAtProtocolConnection, style: context.textTheme.titleMedium), + ), + const Divider(height: 1), + ConnectionDetailRow(label: 'Handle', value: '@${tokens.handle}'), + const Divider(height: 1), + ConnectionDetailRow( + label: 'DID', + value: tokens.did, + onTap: () => context.push('/settings/devtools?query=${Uri.encodeQueryComponent(tokens.did)}'), + ), + const Divider(height: 1), + ConnectionDetailRow(label: 'PDS', value: pds), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/settings/presentation/widgets/connection_detail.dart b/lib/features/settings/presentation/widgets/connection_detail.dart new file mode 100644 index 0000000..e091001 --- /dev/null +++ b/lib/features/settings/presentation/widgets/connection_detail.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class ConnectionDetailRow extends StatelessWidget { + const ConnectionDetailRow({super.key, required this.label, required this.value, this.onTap}); + + final String label; + final String value; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final content = Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), + ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'JetBrains Mono')), + ], + ), + ), + if (onTap != null) ...[ + const SizedBox(width: 12), + Icon(Icons.open_in_new, size: 18, color: theme.colorScheme.onSurfaceVariant), + ], + ], + ), + ); + + if (onTap == null) { + return content; + } + + return InkWell(onTap: onTap, child: content); + } +} diff --git a/lib/features/settings/presentation/widgets/contact_section.dart b/lib/features/settings/presentation/widgets/contact_section.dart index 894addd..0f66598 100644 --- a/lib/features/settings/presentation/widgets/contact_section.dart +++ b/lib/features/settings/presentation/widgets/contact_section.dart @@ -8,33 +8,27 @@ class _ContactLink extends StatelessWidget { final VoidCallback onTap; @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; - - return Row( - children: [ - Text(pre, style: textTheme.bodyMedium), - const SizedBox(width: 4), - InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text( - label, - style: textTheme.bodyMedium?.copyWith( - color: colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: colorScheme.primary, - ), + Widget build(BuildContext context) => Row( + children: [ + Text(pre, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(width: 4), + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: Theme.of(context).colorScheme.primary, ), ), ), - ], - ); - } + ), + ], + ); } class ContactSection extends StatelessWidget { @@ -44,26 +38,22 @@ class ContactSection extends StatelessWidget { final VoidCallback onEmailTap; @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; - - return Padding( - padding: const EdgeInsets.only(bottom: 18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Contact', - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: colorScheme.primary), - ), - const SizedBox(height: 6), - _ContactLink(pre: 'Visit our website:', label: 'Stormlight Labs', onTap: onStormlightLabsTap), - const SizedBox(height: 6), - _ContactLink(pre: 'Email us at', label: 'info@stormlightlabs.org', onTap: onEmailTap), - ], - ), - ); - } + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Contact', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.primary), + ), + const SizedBox(height: 6), + _ContactLink(pre: 'Visit our website:', label: 'Stormlight Labs', onTap: onStormlightLabsTap), + const SizedBox(height: 6), + _ContactLink(pre: 'Email us at', label: 'info@stormlightlabs.org', onTap: onEmailTap), + ], + ), + ); } diff --git a/lib/features/settings/presentation/widgets/settings_tiles.dart b/lib/features/settings/presentation/widgets/settings_tiles.dart new file mode 100644 index 0000000..a403a5e --- /dev/null +++ b/lib/features/settings/presentation/widgets/settings_tiles.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; + +class SettingsTile extends StatelessWidget { + const SettingsTile({ + super.key, + required this.title, + this.subtitle, + this.icon, + this.trailing, + this.isDestructive = false, + this.onTap, + }); + + final String title; + final String? subtitle; + final IconData? icon; + final Widget? trailing; + final bool isDestructive; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final color = isDestructive ? context.colorScheme.error : null; + + return ListTile( + leading: icon != null ? Icon(icon, color: color) : null, + title: Text(title, style: TextStyle(color: color)), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right) : null), + onTap: onTap, + ); + } +} + +class ConstellationUrlTile extends StatelessWidget { + const ConstellationUrlTile({super.key, required this.currentUrl}); + + final String currentUrl; + + @override + Widget build(BuildContext context) => ListTile( + leading: const Icon(Icons.hub_outlined), + title: const Text('Constellation URL'), + subtitle: Text(currentUrl, maxLines: 1, overflow: TextOverflow.ellipsis), + ); +} + +class SettingsDropdownTile extends StatelessWidget { + const SettingsDropdownTile({ + super.key, + required this.title, + required this.value, + required this.options, + required this.labelBuilder, + required this.onChanged, + this.subtitle, + }); + + final String title; + final String? subtitle; + final T value; + final List options; + final String Function(T value) labelBuilder; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => ListTile( + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + onChanged: onChanged, + items: [for (final option in options) DropdownMenuItem(value: option, child: Text(labelBuilder(option)))], + ), + ), + ); +} diff --git a/lib/features/settings/presentation/widgets/theme_palette_row.dart b/lib/features/settings/presentation/widgets/theme_palette_row.dart new file mode 100644 index 0000000..2fcbd47 --- /dev/null +++ b/lib/features/settings/presentation/widgets/theme_palette_row.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:lazurite/core/theme/app_theme.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/features/settings/bloc/settings_state.dart'; + +enum AppearanceMode { + system, + light, + dark; + + static AppearanceMode fromState(SettingsState state) => + (state.useSystemTheme ? system : (state.themeVariant == AppThemeVariant.light ? light : dark)); +} + +class ThemePaletteRow extends StatelessWidget { + const ThemePaletteRow({super.key, required this.palette, required this.isSelected, required this.onTap}); + + final AppThemePalette palette; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => ListTile( + onTap: onTap, + title: Text(AppTheme.getPaletteName(palette)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final color in AppTheme.getSwatchColors(palette)) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(4)), + ), + ), + if (isSelected) ...[const SizedBox(width: 12), Icon(Icons.check, color: context.colorScheme.primary, size: 20)], + ], + ), + ); +} diff --git a/lib/main.dart b/lib/main.dart index 1aeef8a..79a0ecb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'package:lazurite/core/cache/offline_cache_policy.dart'; import 'package:lazurite/core/crash_reporting/crash_reporting_service.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/embedding/embedding_service.dart'; +import 'package:lazurite/core/error_reporting/crash_report_screen.dart'; import 'package:lazurite/core/l10n/app_localizations.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/logging/logging_bloc_observer.dart'; @@ -96,9 +97,12 @@ Future main() async { final previousFlutterErrorHandler = FlutterError.onError; FlutterError.onError = (details) { previousFlutterErrorHandler?.call(details); + log.f('Flutter fatal error', error: details.exception, stackTrace: details.stack); crashReportingService.recordFlutterFatalError(details); }; + ErrorWidget.builder = (details) => CrashReportScreen(details: details); PlatformDispatcher.instance.onError = (error, stackTrace) { + log.f('Platform fatal error', error: error, stackTrace: stackTrace); unawaited(crashReportingService.recordError(error, stackTrace, fatal: true)); return true; }; @@ -181,6 +185,7 @@ Future main() async { ); }, (error, stackTrace) { + log.f('Zone fatal error', error: error, stackTrace: stackTrace); unawaited(crashReportingService.recordError(error, stackTrace, fatal: true)); }, ); diff --git a/test/core/error_reporting/crash_report_bundle_test.dart b/test/core/error_reporting/crash_report_bundle_test.dart new file mode 100644 index 0000000..2ca20fa --- /dev/null +++ b/test/core/error_reporting/crash_report_bundle_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/error_reporting/crash_report_bundle.dart'; + +void main() { + group('CrashReportBundle', () { + test('includes error, stack trace, diagnostics, platform, and redacted relevant logs', () async { + final tempDir = await Directory.systemTemp.createTemp('lazurite_crash_report_test_'); + final logFile = File('${tempDir.path}/lazurite_2026-05-08.log'); + await logFile.writeAsString( + '[I] TIME: 2026-05-08T10:00:00.000 App started\n' + '[E] TIME: 2026-05-08T10:01:00.000 Failed url=/oauth/callback?code=abc&state=xyz\n', + ); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final details = FlutterErrorDetails( + exception: StateError('boom access_token=secret'), + stack: StackTrace.fromString('#0 main (file:///app/main.dart:1:1)'), + library: 'widgets library', + context: ErrorDescription('building ProfileScreen'), + informationCollector: () => [ErrorDescription('route token=hidden')], + ); + + final report = await CrashReportBundle.fromFlutterErrorDetails( + details, + todaysLogFileProvider: () async => logFile, + generatedAt: DateTime.utc(2026, 5, 8, 12), + ); + + expect(report.error, contains('Bad state: boom access_token: [REDACTED]')); + expect(report.stackTrace, contains('#0 main')); + expect(report.library, 'widgets library'); + expect(report.context, 'building ProfileScreen'); + expect(report.information.single, 'route token: [REDACTED]'); + expect(report.relevantLogs, contains('App started')); + expect(report.relevantLogs, contains('code=[REDACTED]')); + expect(report.relevantLogs, contains('state=[REDACTED]')); + expect(report.relevantLogs, isNot(contains('abc'))); + expect(report.relevantLogs, isNot(contains('xyz'))); + expect(report.copyText, contains('Platform:')); + expect(report.copyText, contains('Generated: 2026-05-08T12:00:00.000Z')); + }); + + test('tails long log files to the latest relevant lines', () async { + final tempDir = await Directory.systemTemp.createTemp('lazurite_crash_report_tail_test_'); + final logFile = File('${tempDir.path}/lazurite_2026-05-08.log'); + await logFile.writeAsString( + [ + '[I] very early line', + ...List.generate(6000, (index) => '[I] filler line $index ${List.filled(80, 'x').join()}'), + ...List.generate(200, (index) => '[I] tail line $index'), + ].join('\n'), + ); + addTearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + final report = await CrashReportBundle.fromFlutterErrorDetails( + FlutterErrorDetails(exception: Exception('boom'), stack: StackTrace.empty), + todaysLogFileProvider: () async => logFile, + ); + + expect(report.relevantLogs, isNot(contains('[I] very early line'))); + expect(report.relevantLogs, isNot(contains('[I] filler line 0'))); + expect(report.relevantLogs, contains('[I] tail line 199')); + expect(report.relevantLogs.split('\n'), hasLength(CrashReportBundle.maxLogLines)); + }); + + test('builds fallback report when full report generation fails', () { + final details = FlutterErrorDetails( + exception: Exception('screen failed token=secret'), + stack: StackTrace.fromString('#0 BrokenWidget.build'), + ); + + final report = CrashReportBundle.fallbackFromFlutterErrorDetails( + details, + reportError: const FileSystemException('cannot read log', '/tmp/log'), + ); + + expect(report.error, contains('token: [REDACTED]')); + expect(report.stackTrace, contains('#0 BrokenWidget.build')); + expect(report.relevantLogs, isEmpty); + expect(report.information.single, contains('Crash report generation failed')); + }); + }); +} diff --git a/test/core/error_reporting/crash_report_screen_test.dart b/test/core/error_reporting/crash_report_screen_test.dart new file mode 100644 index 0000000..0948c1e --- /dev/null +++ b/test/core/error_reporting/crash_report_screen_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/error_reporting/crash_report_bundle.dart'; +import 'package:lazurite/core/error_reporting/crash_report_screen.dart'; + +void main() { + CrashReportBundle reportFor(FlutterErrorDetails details, {String stackTrace = '#0 BrokenWidget.build'}) => + CrashReportBundle( + generatedAt: DateTime.utc(2026, 5, 8, 12), + error: details.exceptionAsString(), + stackTrace: stackTrace, + library: 'widgets library', + context: 'building BrokenWidget', + information: const ['route=/profile'], + relevantLogs: '[FATAL] Flutter fatal error', + ); + + Widget buildSubject({ + required FlutterErrorDetails details, + CrashReportBuilder? reportBuilder, + CrashReportEmailLauncher? emailLauncher, + }) { + return MaterialApp( + home: Scaffold( + body: CrashReportScreen( + details: details, + reportBuilder: reportBuilder ?? (details) async => reportFor(details), + emailLauncher: emailLauncher, + ), + ), + ); + } + + group('CrashReportScreen', () { + testWidgets('renders copyable crash details with stack trace and logs', (tester) async { + await tester.pumpWidget(buildSubject(details: FlutterErrorDetails(exception: Exception('screen failed')))); + await tester.pumpAndSettle(); + + expect(find.text('Something went wrong'), findsOneWidget); + expect(find.text('Copy report'), findsOneWidget); + expect(find.text('Email report'), findsOneWidget); + expect(find.textContaining('Exception: screen failed'), findsAtLeastNWidgets(1)); + expect(find.textContaining('#0 BrokenWidget.build'), findsOneWidget); + expect(find.textContaining('[FATAL] Flutter fatal error'), findsOneWidget); + expect(find.byType(SelectableText), findsNWidgets(3)); + }); + + testWidgets('email action opens a compact mailto URL to support', (tester) async { + final launchedUris = []; + + await tester.pumpWidget( + buildSubject( + details: FlutterErrorDetails(exception: Exception('screen failed')), + reportBuilder: (details) async => + reportFor(details, stackTrace: '#0 BrokenWidget.build\n${List.filled(6000, 'frame').join('\n')}'), + emailLauncher: (uri) async { + launchedUris.add(uri); + return true; + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Email report')); + await tester.pump(); + + expect(launchedUris, hasLength(1)); + expect(launchedUris.single.scheme, 'mailto'); + expect(launchedUris.single.path, CrashReportScreen.supportEmail); + expect(launchedUris.single.queryParameters['subject'], 'Lazurite crash report'); + expect(launchedUris.single.queryParameters['body'], contains('#0 BrokenWidget.build')); + expect(launchedUris.single.queryParameters['body'], contains('[Stack trace truncated for email]')); + expect(launchedUris.single.queryParameters['body'], isNot(contains('[FATAL] Flutter fatal error'))); + expect(launchedUris.single.toString().length, lessThan(7000)); + }); + + testWidgets('report generation errors render a minimal report with retry', (tester) async { + var attempts = 0; + + await tester.pumpWidget( + buildSubject( + details: FlutterErrorDetails( + exception: Exception('screen failed'), + stack: StackTrace.fromString('#0 BrokenWidget.build'), + ), + reportBuilder: (_) async { + attempts += 1; + throw Exception('log read failed'); + }, + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Some report details could not be loaded. A minimal report is still available.'), + findsOneWidget, + ); + expect(find.text('Copy report'), findsOneWidget); + expect(find.text('Email report'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.textContaining('#0 BrokenWidget.build'), findsOneWidget); + + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(attempts, 2); + }); + }); +} diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index a937d1f..e3e2e4a 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -257,7 +257,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('AT Protocol Connection'), findsOneWidget); - expect(find.text('HANDLE'), findsOneWidget); + expect(find.text('Handle'), findsOneWidget); expect(find.text('@owais.bsky.social'), findsOneWidget); expect(find.text('DID'), findsOneWidget); expect(find.text('did:plc:lazurite123'), findsOneWidget); @@ -450,6 +450,28 @@ void main() { expect(crashReportingService.crashCalls, 1); }); + testWidgets('developer recoverable crash row opens Flutter error route without Crashlytics crash', (tester) async { + final previousErrorWidgetBuilder = ErrorWidget.builder; + ErrorWidget.builder = (_) => const Text('Recoverable crash report rendered'); + + try { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible(find.text('Crash Report Screen Test'), 300); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Crash Report Screen Test')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isA()); + expect(find.text('Recoverable crash report rendered'), findsOneWidget); + expect(crashReportingService.crashCalls, 0); + } finally { + ErrorWidget.builder = previousErrorWidgetBuilder; + } + }); + testWidgets('provider change confirmation can be cancelled', (tester) async { when(() => settingsCubit.state).thenReturn( const SettingsState( diff --git a/test/features/settings/presentation/widgets/connection_detail_test.dart b/test/features/settings/presentation/widgets/connection_detail_test.dart new file mode 100644 index 0000000..0fc4ffa --- /dev/null +++ b/test/features/settings/presentation/widgets/connection_detail_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/features/settings/presentation/widgets/connection_detail.dart'; + +void main() { + Widget buildSubject(Widget child) { + return MaterialApp(home: Scaffold(body: child)); + } + + testWidgets('keeps localized label casing unchanged', (tester) async { + await tester.pumpWidget(buildSubject(const ConnectionDetailRow(label: 'identity', value: 'did:plc:abc'))); + + expect(find.text('identity'), findsOneWidget); + expect(find.text('IDENTITY'), findsNothing); + expect(find.text('did:plc:abc'), findsOneWidget); + }); +}