@tduncan / Cortana / commits / 4544bba

feat: implement crash reporting service and user-facing error UI with GitHub integration

abdulmominsakib committed Jul 15, 2026 at 15:05 UTC 4544bbae304742c28571387281f2bfc395420211
5 files changed +603 -6
.github/ISSUE_TEMPLATE/crash_report.md new
+36
@@ -0,0 +1,36 @@
1 +---
2 +name: Crash report
3 +about: Report an unexpected LocalMind crash
4 +title: "Crash: "
5 +labels: "crash, bug"
6 +---
7 +
8 +## App & Device
9 +
10 +<!-- Auto-filled by LocalMind when opened from the crash screen. -->
11 +
12 +## Crash Details
13 +
14 +<!-- Auto-filled by LocalMind. -->
15 +
16 +## Stack Trace
17 +
18 +<!-- Auto-filled by LocalMind. -->
19 +
20 +## Steps to Reproduce
21 +
22 +1.
23 +2.
24 +3.
25 +
26 +## Expected Behavior
27 +
28 +<!-- What did you expect to happen? -->
29 +
30 +## Actual Behavior
31 +
32 +<!-- What happened instead? -->
33 +
34 +## Additional Context
35 +
36 +<!-- Add any other relevant details. Please remove sensitive chat or model data before submitting. -->
lib/core/services/crash_report_service.dart new
+273
@@ -0,0 +1,273 @@
1 +import 'dart:async';
2 +import 'dart:io' show Platform;
3 +
4 +import 'package:device_info_plus/device_info_plus.dart';
5 +import 'package:flutter/foundation.dart';
6 +import 'package:package_info_plus/package_info_plus.dart';
7 +
8 +/// Immutable payload describing a captured crash.
9 +class CrashReport {
10 + CrashReport({
11 + required this.error,
12 + required this.errorType,
13 + required this.stackTrace,
14 + required this.timestamp,
15 + required this.appVersion,
16 + required this.buildNumber,
17 + required this.platform,
18 + required this.osVersion,
19 + required this.deviceManufacturer,
20 + required this.deviceModel,
21 + required this.locale,
22 + this.errorWidgetPayload,
23 + });
24 +
25 + final Object error;
26 + final String errorType;
27 + final StackTrace stackTrace;
28 + final DateTime timestamp;
29 + final String appVersion;
30 + final String buildNumber;
31 + final String platform;
32 + final String osVersion;
33 + final String deviceManufacturer;
34 + final String deviceModel;
35 + final String locale;
36 + final String? errorWidgetPayload;
37 +
38 + /// First non-empty line of the error, truncated. Used for the issue title.
39 + String get shortError {
40 + final raw = error.toString().trim();
41 + if (raw.isEmpty) return errorType;
42 + final firstLine = raw.split('\n').first.trim();
43 + if (firstLine.length <= 80) return firstLine;
44 + return '${firstLine.substring(0, 77)}...';
45 + }
46 +
47 + /// Issue body markdown for GitHub. Pre-built at capture time so URL
48 + /// generation is synchronous and side-effect-free.
49 + String get markdownBody {
50 + final stack = _truncate(stackTrace.toString(), 6000);
51 + final cleanStack = _stripBackticks(stack);
52 + final ts = timestamp.toUtc().toIso8601String();
53 +
54 + final buffer = StringBuffer()
55 + ..writeln('## App Info')
56 + ..writeln('- **Version:** $appVersion')
57 + ..writeln('- **Build:** $buildNumber')
58 + ..writeln('- **Timestamp (UTC):** $ts')
59 + ..writeln('- **Locale:** $locale')
60 + ..writeln()
61 + ..writeln('## Device Info')
62 + ..writeln('- **Platform:** $platform ($osVersion)')
63 + ..writeln('- **Device:** $deviceManufacturer $deviceModel')
64 + ..writeln()
65 + ..writeln('## Crash Details')
66 + ..writeln()
67 + ..writeln('```text')
68 + ..writeln(_stripBackticks(error.toString()))
69 + ..writeln('```');
70 +
71 + if (errorWidgetPayload != null && errorWidgetPayload!.isNotEmpty) {
72 + buffer
73 + ..writeln()
74 + ..writeln('**FlutterErrorDetails:**')
75 + ..writeln()
76 + ..writeln('```text')
77 + ..writeln(_stripBackticks(_truncate(errorWidgetPayload!, 2000)))
78 + ..writeln('```');
79 + }
80 +
81 + buffer
82 + ..writeln()
83 + ..writeln('## Stack Trace')
84 + ..writeln()
85 + ..writeln('```text')
86 + ..writeln(cleanStack)
87 + ..writeln('```')
88 + ..writeln()
89 + ..writeln('## Steps to Reproduce')
90 + ..writeln('1. ')
91 + ..writeln('2. ')
92 + ..writeln()
93 + ..writeln('## Expected Behavior')
94 + ..writeln()
95 + ..writeln('<!-- What did you expect to happen? -->')
96 + ..writeln()
97 + ..writeln('## Actual Behavior')
98 + ..writeln()
99 + ..writeln('<!-- What happened instead? -->')
100 + ..writeln()
101 + ..writeln('## Additional Context')
102 + ..writeln()
103 + ..writeln(
104 + '<!-- Please remove any sensitive chat or model data before submitting. -->',
105 + );
106 +
107 + return buffer.toString();
108 + }
109 +
110 + String _truncate(String input, int max) {
111 + if (input.length <= max) return input;
112 + return '${input.substring(0, max)}\n\n…(truncated)…';
113 + }
114 +
115 + String _stripBackticks(String input) => input.replaceAll('```', "'''");
116 +}
117 +
118 +/// Singleton that captures, dedupes, and exposes crashes for the UI layer.
119 +///
120 +/// Capture path is wired in `lib/main.dart`:
121 +/// * `FlutterError.onError` — framework errors
122 +/// * `PlatformDispatcher.instance.onError` — async zone errors
123 +/// * `ErrorWidget.builder` — build-time errors
124 +/// * `runZonedGuarded` callback — top-level uncaught
125 +class CrashReportService {
126 + CrashReportService._();
127 +
128 + static final CrashReportService instance = CrashReportService._();
129 +
130 + static const String repoOwner = 'abdulmominsakib';
131 + static const String repoName = 'localmind';
132 + static const String issueTemplate = 'crash_report.md';
133 +
134 + final ValueNotifier<CrashReport?> _currentCrash = ValueNotifier<CrashReport?>(
135 + null,
136 + );
137 + final List<CrashReport> _recentCrashes = <CrashReport>[];
138 +
139 + PackageInfo? _packageInfo;
140 + String _appVersion = 'unknown';
141 + String _buildNumber = 'unknown';
142 + String _platformLabel = _platformFromDart();
143 + String _osVersion = 'unknown';
144 + String _deviceManufacturer = 'unknown';
145 + String _deviceModel = 'unknown';
146 + bool _initialized = false;
147 +
148 + /// Listenable for the UI. Wrap your root with a `ValueListenableBuilder`
149 + /// so an async crash can take over the entire app.
150 + ValueListenable<CrashReport?> get currentCrash => _currentCrash;
151 +
152 + /// Maximum time window (ms) within which two identical errors collapse
153 + /// into a single report — `FlutterError.onError` and `ErrorWidget.builder`
154 + /// frequently fire for the same exception.
155 + static const Duration _dedupeWindow = Duration(seconds: 2);
156 +
157 + /// Initialize device + app metadata. Safe to call multiple times.
158 + Future<void> initialize() async {
159 + if (_initialized) return;
160 + _initialized = true;
161 +
162 + try {
163 + _packageInfo = await PackageInfo.fromPlatform();
164 + _appVersion = _packageInfo?.version ?? 'unknown';
165 + _buildNumber = _packageInfo?.buildNumber ?? 'unknown';
166 + } catch (_) {}
167 +
168 + try {
169 + final info = DeviceInfoPlugin();
170 + if (Platform.isAndroid) {
171 + final android = await info.androidInfo;
172 + _platformLabel = 'Android';
173 + _osVersion =
174 + 'Android ${android.version.release} (SDK ${android.version.sdkInt})';
175 + _deviceManufacturer = android.manufacturer;
176 + _deviceModel = android.model;
177 + } else if (Platform.isIOS) {
178 + final ios = await info.iosInfo;
179 + _platformLabel = 'iOS';
180 + _osVersion = '${ios.systemName} ${ios.systemVersion}';
181 + _deviceManufacturer = 'Apple';
182 + _deviceModel = ios.utsname.machine;
183 + }
184 + } catch (_) {}
185 + }
186 +
187 + /// Record a crash. Returns the captured `CrashReport` for the caller to use
188 + /// (e.g. `ErrorWidget.builder` rendering).
189 + CrashReport capture(
190 + Object error,
191 + StackTrace stack, {
192 + String? errorWidgetPayload,
193 + }) {
194 + final report = CrashReport(
195 + error: error,
196 + errorType: error.runtimeType.toString(),
197 + stackTrace: stack,
198 + timestamp: DateTime.now(),
199 + appVersion: _appVersion,
200 + buildNumber: _buildNumber,
201 + platform: _platformLabel,
202 + osVersion: _osVersion,
203 + deviceManufacturer: _deviceManufacturer,
204 + deviceModel: _deviceModel,
205 + locale: PlatformDispatcher.instance.locale.toString(),
206 + errorWidgetPayload: errorWidgetPayload,
207 + );
208 +
209 + _recentCrashes.add(report);
210 + if (_recentCrashes.length > 20) {
211 + _recentCrashes.removeAt(0);
212 + }
213 +
214 + if (!_isDuplicate(report)) {
215 + _currentCrash.value = report;
216 + }
217 + return report;
218 + }
219 +
220 + /// Clear the current crash so the app can re-render normally.
221 + void clearCrash() {
222 + _currentCrash.value = null;
223 + }
224 +
225 + /// Construct a prefilled "new issue" URL pointing at the GitHub repo.
226 + /// `Uri.https` percent-encodes everything — never manually encode.
227 + Uri buildGitHubIssueUrl(CrashReport report) {
228 + final title = 'Crash: ${report.shortError}';
229 + final params = <String, String>{
230 + 'title': title,
231 + 'body': report.markdownBody,
232 + 'labels': 'crash,bug',
233 + 'template': issueTemplate,
234 + };
235 + return Uri.https('github.com', '/$repoOwner/$repoName/issues/new', params);
236 + }
237 +
238 + /// Plain feedback URL used by the "Report a problem" Settings tile —
239 + /// distinct from the crash flow; no diagnostics are included.
240 + Uri buildFeedbackIssueUrl() {
241 + return Uri.https(
242 + 'github.com',
243 + '/$repoOwner/$repoName/issues/new',
244 + <String, String>{
245 + 'title': 'Feedback',
246 + 'labels': 'feedback',
247 + 'template': issueTemplate,
248 + },
249 + );
250 + }
251 +
252 + bool _isDuplicate(CrashReport report) {
253 + final cutoff = DateTime.now().subtract(_dedupeWindow);
254 + for (final prior in _recentCrashes.reversed) {
255 + if (prior.timestamp.isBefore(cutoff)) return false;
256 + if (prior.errorType == report.errorType &&
257 + prior.error.toString() == report.error.toString()) {
258 + return true;
259 + }
260 + }
261 + return false;
262 + }
263 +
264 + static String _platformFromDart() {
265 + try {
266 + if (Platform.isAndroid) return 'Android';
267 + if (Platform.isIOS) return 'iOS';
268 + return Platform.operatingSystem;
269 + } catch (_) {
270 + return 'unknown';
271 + }
272 + }
273 +}
lib/core/widgets/crash_error_widget.dart new
+204
@@ -0,0 +1,204 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:url_launcher/url_launcher.dart';
3 +
4 +import '../services/crash_report_service.dart';
5 +
6 +/// User-facing fallback shown when a crash is captured. Used both as
7 +/// `ErrorWidget.builder` and from `main.dart`'s `ValueListenableBuilder`
8 +/// that wraps `BootstrapHost`.
9 +class CrashErrorWidget extends StatelessWidget {
10 + const CrashErrorWidget({super.key, required this.crash});
11 +
12 + final CrashReport crash;
13 +
14 + @override
15 + Widget build(BuildContext context) {
16 + final theme = Theme.of(context);
17 + final stackLines = crash.stackTrace.toString().split('\n');
18 + final previewLines = stackLines.take(40).join('\n');
19 +
20 + return Material(
21 + color: theme.colorScheme.surface,
22 + child: SafeArea(
23 + child: Padding(
24 + padding: const EdgeInsets.all(20),
25 + child: Center(
26 + child: ConstrainedBox(
27 + constraints: const BoxConstraints(maxWidth: 560),
28 + child: Column(
29 + mainAxisAlignment: MainAxisAlignment.center,
30 + crossAxisAlignment: CrossAxisAlignment.stretch,
31 + children: [
32 + Container(
33 + width: 72,
34 + height: 72,
35 + alignment: Alignment.center,
36 + decoration: BoxDecoration(
37 + color: theme.colorScheme.errorContainer,
38 + borderRadius: BorderRadius.circular(20),
39 + ),
40 + child: Icon(
41 + Icons.error_outline,
42 + size: 36,
43 + color: theme.colorScheme.onErrorContainer,
44 + ),
45 + ),
46 + const SizedBox(height: 16),
47 + Text(
48 + 'Something went wrong',
49 + textAlign: TextAlign.center,
50 + style: theme.textTheme.headlineSmall?.copyWith(
51 + fontWeight: FontWeight.w700,
52 + ),
53 + ),
54 + const SizedBox(height: 6),
55 + Text(
56 + 'v${crash.appVersion} (${crash.buildNumber}) · ${crash.platform} ${crash.deviceModel}',
57 + textAlign: TextAlign.center,
58 + style: theme.textTheme.bodySmall?.copyWith(
59 + color: theme.colorScheme.onSurfaceVariant,
60 + ),
61 + ),
62 + const SizedBox(height: 18),
63 + Container(
64 + decoration: BoxDecoration(
65 + color: theme.colorScheme.surfaceContainerHighest
66 + .withValues(alpha: 0.4),
67 + borderRadius: BorderRadius.circular(14),
68 + border: Border.all(
69 + color: theme.colorScheme.outlineVariant,
70 + ),
71 + ),
72 + padding: const EdgeInsets.symmetric(
73 + horizontal: 14,
74 + vertical: 10,
75 + ),
76 + child: Column(
77 + crossAxisAlignment: CrossAxisAlignment.start,
78 + children: [
79 + Row(
80 + children: [
81 + Icon(
82 + Icons.bug_report_outlined,
83 + size: 18,
84 + color: theme.colorScheme.onSurfaceVariant,
85 + ),
86 + const SizedBox(width: 8),
87 + Expanded(
88 + child: Text(
89 + crash.errorType,
90 + style: theme.textTheme.labelLarge?.copyWith(
91 + fontWeight: FontWeight.w600,
92 + ),
93 + ),
94 + ),
95 + ],
96 + ),
97 + const SizedBox(height: 8),
98 + Text(
99 + crash.shortError,
100 + style: theme.textTheme.bodyMedium,
101 + ),
102 + ],
103 + ),
104 + ),
105 + const SizedBox(height: 14),
106 + Theme(
107 + data: theme.copyWith(dividerColor: Colors.transparent),
108 + child: ExpansionTile(
109 + tilePadding: EdgeInsets.zero,
110 + childrenPadding: const EdgeInsets.only(bottom: 8),
111 + title: Text(
112 + 'Stack trace',
113 + style: theme.textTheme.titleSmall?.copyWith(
114 + fontWeight: FontWeight.w600,
115 + ),
116 + ),
117 + subtitle: Text(
118 + 'Tap to expand',
119 + style: theme.textTheme.bodySmall?.copyWith(
120 + color: theme.colorScheme.onSurfaceVariant,
121 + ),
122 + ),
123 + children: [
124 + Container(
125 + width: double.infinity,
126 + padding: const EdgeInsets.all(12),
127 + decoration: BoxDecoration(
128 + color: theme.colorScheme.surfaceContainerHighest
129 + .withValues(alpha: 0.6),
130 + borderRadius: BorderRadius.circular(10),
131 + border: Border.all(
132 + color: theme.colorScheme.outlineVariant,
133 + ),
134 + ),
135 + child: SelectableText(
136 + previewLines.isEmpty ? '<empty>' : previewLines,
137 + style: const TextStyle(
138 + fontFamily: 'monospace',
139 + fontSize: 11.5,
140 + height: 1.35,
141 + ),
142 + ),
143 + ),
144 + ],
145 + ),
146 + ),
147 + const SizedBox(height: 20),
148 + FilledButton.icon(
149 + onPressed: () => _openGitHub(context),
150 + icon: const Icon(Icons.open_in_new),
151 + label: const Text('Report this crash'),
152 + ),
153 + const SizedBox(height: 8),
154 + TextButton(
155 + onPressed: () => _tryAgain(context),
156 + child: const Text('Try again'),
157 + ),
158 + const SizedBox(height: 12),
159 + Text(
160 + 'Reporting opens GitHub with diagnostics prefilled. '
161 + 'You stay in control — nothing is submitted automatically. '
162 + 'Please review and remove any sensitive content before submitting.',
163 + textAlign: TextAlign.center,
164 + style: theme.textTheme.bodySmall?.copyWith(
165 + color: theme.colorScheme.onSurfaceVariant,
166 + height: 1.4,
167 + ),
168 + ),
169 + ],
170 + ),
171 + ),
172 + ),
173 + ),
174 + ),
175 + );
176 + }
177 +
178 + Future<void> _openGitHub(BuildContext context) async {
179 + final uri = CrashReportService.instance.buildGitHubIssueUrl(crash);
180 + final messenger = ScaffoldMessenger.maybeOf(context);
181 + try {
182 + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
183 + if (!ok && messenger != null) {
184 + messenger.showSnackBar(
185 + const SnackBar(
186 + content: Text(
187 + 'Could not open GitHub. Please copy the URL manually.',
188 + ),
189 + ),
190 + );
191 + }
192 + } catch (e) {
193 + if (messenger != null) {
194 + messenger.showSnackBar(
195 + SnackBar(content: Text('Failed to open URL: $e')),
196 + );
197 + }
198 + }
199 + }
200 +
201 + void _tryAgain(BuildContext context) {
202 + CrashReportService.instance.clearCrash();
203 + }
204 +}
lib/features/settings/views/settings_screen.dart
+25
@@ -10,6 +10,7 @@ import 'package:localmind/l10n/app_localizations.dart';
10 import '../../../core/models/enums.dart';
11 import '../../../core/providers/app_providers.dart';
12 import '../../../core/routes/app_routes.dart';
13 +import '../../../core/services/crash_report_service.dart';
14 import '../../../core/theme/app_theme.dart';
15 import '../../../core/utils/system_insets.dart';
16 import '../../chat/utils/image_upload_utils.dart';
@@ -474,6 +475,11 @@ class SettingsViews extends ConsumerWidget {
475 stack: const ['Flutter', 'Riverpod', 'shadcn_ui'],
476 openSource: l10n.open_source_desc,
477 ),
478 + _SectionActionButton(
479 + icon: HugeIcons.strokeRoundedAlertCircle,
480 + label: 'Report a problem',
481 + onPressed: () => _openFeedbackIssue(context),
482 + ),
483 ],
484 );
485
@@ -2390,3 +2396,22 @@ String _languageLabel(String? localeCode, AppLocalizations l10n) {
2396
2397 return localeCode.toUpperCase();
2398 }
2399 +
2400 +Future<void> _openFeedbackIssue(BuildContext context) async {
2401 + final uri = CrashReportService.instance.buildFeedbackIssueUrl();
2402 + final messenger = ScaffoldMessenger.maybeOf(context);
2403 + try {
2404 + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
2405 + if (!ok && messenger != null) {
2406 + messenger.showSnackBar(
2407 + const SnackBar(
2408 + content: Text('Could not open GitHub. Please try again later.'),
2409 + ),
2410 + );
2411 + }
2412 + } catch (e) {
2413 + if (messenger != null) {
2414 + messenger.showSnackBar(SnackBar(content: Text('Failed to open URL: $e')));
2415 + }
2416 + }
2417 +}
lib/main.dart
+65 -6
@@ -1,14 +1,73 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter/foundation.dart';
4 import 'package:flutter/material.dart';
5 import 'package:just_audio_background/just_audio_background.dart';
6
7 import 'bootstrap/bootstrap_host.dart';
8 +import 'core/services/crash_report_service.dart';
9 +import 'core/widgets/crash_error_widget.dart';
10
11 Future<void> main() async {
7 - WidgetsFlutterBinding.ensureInitialized();
8 - await JustAudioBackground.init(
9 - androidNotificationChannelId: 'com.abdulmominsakib.localmind.channel.audio',
10 - androidNotificationChannelName: 'LocalMind Audio TTS Playback',
11 - androidNotificationOngoing: true,
12 + await runZonedGuarded<Future<void>>(
13 + () async {
14 + WidgetsFlutterBinding.ensureInitialized();
15 + final crashReports = CrashReportService.instance;
16 + await crashReports.initialize();
17 +
18 + FlutterError.onError = (details) {
19 + FlutterError.presentError(details);
20 + crashReports.capture(
21 + details.exception,
22 + details.stack ?? StackTrace.current,
23 + errorWidgetPayload: details.toString(),
24 + );
25 + };
26 +
27 + PlatformDispatcher.instance.onError = (error, stack) {
28 + crashReports.capture(error, stack);
29 + return true;
30 + };
31 +
32 + ErrorWidget.builder = (details) {
33 + final captured = crashReports.capture(
34 + details.exception,
35 + details.stack ?? StackTrace.current,
36 + errorWidgetPayload: details.toString(),
37 + );
38 + return CrashErrorWidget(crash: captured);
39 + };
40 +
41 + await JustAudioBackground.init(
42 + androidNotificationChannelId:
43 + 'com.abdulmominsakib.localmind.channel.audio',
44 + androidNotificationChannelName: 'LocalMind Audio TTS Playback',
45 + androidNotificationOngoing: true,
46 + );
47 + runApp(const CrashFallbackApp());
48 + },
49 + (error, stack) {
50 + CrashReportService.instance.capture(error, stack);
51 + },
52 );
13 - runApp(const BootstrapHost());
53 +}
54 +
55 +/// Root widget that swaps the entire app body for `CrashErrorWidget`
56 +/// when an async/unhandled crash is captured outside `ErrorWidget.builder`'s
57 +/// reach. `ErrorWidget.builder` still handles in-frame errors.
58 +class CrashFallbackApp extends StatelessWidget {
59 + const CrashFallbackApp({super.key});
60 +
61 + @override
62 + Widget build(BuildContext context) {
63 + return ValueListenableBuilder<CrashReport?>(
64 + valueListenable: CrashReportService.instance.currentCrash,
65 + builder: (context, crash, _) {
66 + if (crash != null) {
67 + return CrashErrorWidget(crash: crash);
68 + }
69 + return const BootstrapHost();
70 + },
71 + );
72 + }
73 }