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
}