@tduncan / Cortana / commits / 4d383ce

fix: localize crash reporting UI, add copy button, and sanitize paths

abdulmominsakib committed Jul 20, 2026 at 21:45 UTC 4d383ce11044715e2dfa6a8b05eef7eb62d2d0a3
30 files changed +963 -124
.gitignore
+3 -1
@@ -51,4 +51,6 @@ private/
51 plans/
52 skills-lock.json
53
54 -notes.txt
\ No newline at end of file
54 +notes.txt
55 +docs/raw_ss/
56 +docs/marketing/
lib/app.dart
+58 -31
@@ -285,34 +285,44 @@ class AppShell extends ConsumerWidget {
285 final isHome = location == AppRoutes.home;
286 final hasActiveChat = ref.watch(conv.activeConversationProvider) != null;
287
288 - return PopScope(
289 - canPop: isHome && !hasActiveChat,
290 - onPopInvokedWithResult: (didPop, result) {
291 - if (didPop) return;
292 - if (isHome && hasActiveChat) {
293 - final origin = ref.read(chatOriginProvider);
294 - ref.read(chatOriginProvider.notifier).clear();
295 - switch (origin) {
296 - case ChatOrigin.history:
297 - context.go(AppRoutes.chatHistory);
298 - case ChatOrigin.savedMessages:
299 - context.go(AppRoutes.savedMessages);
300 - case ChatOrigin.none:
301 - ref.read(chatProvider.notifier).startNewConversation();
302 - }
303 - return;
304 - }
305 - if (!isHome) {
306 - context.go(AppRoutes.home);
288 + void handleBack() {
289 + if (!isHome) {
290 + context.go(AppRoutes.home);
291 + return;
292 + }
293 + if (hasActiveChat) {
294 + final origin = ref.read(chatOriginProvider);
295 + ref.read(chatOriginProvider.notifier).clear();
296 + switch (origin) {
297 + case ChatOrigin.history:
298 + context.go(AppRoutes.chatHistory);
299 + case ChatOrigin.savedMessages:
300 + context.go(AppRoutes.savedMessages);
301 + case ChatOrigin.none:
302 + ref.read(chatProvider.notifier).startNewConversation();
303 }
308 - },
309 - child: ShadResponsiveBuilder(
310 - builder: (context, breakpoint) {
311 - final isDesktop = breakpoint >= ShadTheme.of(context).breakpoints.md;
304 + }
305 + // On the empty home screen on mobile, the back press is handled
306 + // by the inner PopScope below (which opens the drawer instead of
307 + // exiting the app).
308 + }
309 +
310 + return ShadResponsiveBuilder(
311 + builder: (context, breakpoint) {
312 + final isDesktop = breakpoint >= ShadTheme.of(context).breakpoints.md;
313
313 - if (isDesktop) {
314 - return Scaffold(
315 - body: Row(
314 + if (isDesktop) {
315 + return Scaffold(
316 + body: PopScope(
317 + // Always intercept back — never let it silently exit the
318 + // app from inside the shell. The handler decides what back
319 + // means for the current screen.
320 + canPop: false,
321 + onPopInvokedWithResult: (didPop, _) {
322 + if (didPop) return;
323 + handleBack();
324 + },
325 + child: Row(
326 children: [
327 const SidebarWidget(),
328 VerticalDivider(
@@ -325,12 +335,29 @@ class AppShell extends ConsumerWidget {
335 Expanded(child: child),
336 ],
337 ),
328 - );
329 - }
338 + ),
339 + );
340 + }
341
331 - return Scaffold(body: child, drawer: const ConversationDrawer());
332 - },
333 - ),
342 + return Scaffold(
343 + body: PopScope(
344 + canPop: false,
345 + onPopInvokedWithResult: (didPop, _) {
346 + if (didPop) return;
347 + if (isHome && !hasActiveChat) {
348 + // On the empty home screen, open the side drawer instead
349 + // of exiting the app — matches the menu icon on the
350 + // top-left.
351 + Scaffold.of(context).openDrawer();
352 + return;
353 + }
354 + handleBack();
355 + },
356 + child: child,
357 + ),
358 + drawer: const ConversationDrawer(),
359 + );
360 + },
361 );
362 }
363 }
lib/core/services/crash_report_service.dart
+60 -8
@@ -1,6 +1,7 @@
1 import 'dart:async';
2 import 'dart:io' show Platform;
3
4 +import 'package:characters/characters.dart';
5 import 'package:device_info_plus/device_info_plus.dart';
6 import 'package:flutter/foundation.dart';
7 import 'package:package_info_plus/package_info_plus.dart';
@@ -37,17 +38,21 @@ class CrashReport {
38
39 /// First non-empty line of the error, truncated. Used for the issue title.
40 String get shortError {
40 - final raw = error.toString().trim();
41 + final raw = _sanitizePaths(error.toString().trim());
42 if (raw.isEmpty) return errorType;
43 final firstLine = raw.split('\n').first.trim();
43 - if (firstLine.length <= 80) return firstLine;
44 - return '${firstLine.substring(0, 77)}...';
44 + final chars = firstLine.characters;
45 + if (chars.length <= 80) return firstLine;
46 + return '${chars.take(77)}...';
47 }
48
47 - /// Issue body markdown for GitHub. Pre-built at capture time so URL
48 - /// generation is synchronous and side-effect-free.
49 + /// Issue body markdown for GitHub. Built lazily when accessed so URL
50 + /// generation remains synchronous and side-effect-free.
51 String get markdownBody {
50 - final stack = _truncate(stackTrace.toString(), 6000);
52 + final stack = _truncate(
53 + _sanitizePaths(stackTrace.toString()),
54 + 3000,
55 + );
56 final cleanStack = _stripBackticks(stack);
57 final ts = timestamp.toUtc().toIso8601String();
58
@@ -65,7 +70,7 @@ class CrashReport {
70 ..writeln('## Crash Details')
71 ..writeln()
72 ..writeln('```text')
68 - ..writeln(_stripBackticks(error.toString()))
73 + ..writeln(_stripBackticks(_sanitizePaths(error.toString())))
74 ..writeln('```');
75
76 if (errorWidgetPayload != null && errorWidgetPayload!.isNotEmpty) {
@@ -74,7 +79,11 @@ class CrashReport {
79 ..writeln('**FlutterErrorDetails:**')
80 ..writeln()
81 ..writeln('```text')
77 - ..writeln(_stripBackticks(_truncate(errorWidgetPayload!, 2000)))
82 + ..writeln(
83 + _stripBackticks(
84 + _truncate(_sanitizePaths(errorWidgetPayload!), 2000),
85 + ),
86 + )
87 ..writeln('```');
88 }
89
@@ -113,6 +122,29 @@ class CrashReport {
122 }
123
124 String _stripBackticks(String input) => input.replaceAll('```', "'''");
125 +
126 + /// Replaces user-specific path segments (home directories, Android package
127 + /// paths) with placeholders so that crash reports do not leak usernames or
128 + /// absolute paths while preserving the structure of the stack trace.
129 + String _sanitizePaths(String input) {
130 + return input
131 + .replaceAllMapped(
132 + RegExp(r'/Users/[^/\s]+'),
133 + (_) => '/Users/<user>',
134 + )
135 + .replaceAllMapped(
136 + RegExp(r'/home/[^/\s]+'),
137 + (_) => '/home/<user>',
138 + )
139 + .replaceAllMapped(
140 + RegExp(r'/data/user/[^/\s]+'),
141 + (_) => '/data/user/<user>',
142 + )
143 + .replaceAllMapped(
144 + RegExp(r'C:\\Users\\[^\\\s]+'),
145 + (_) => r'C:\Users\<user>',
146 + );
147 + }
148 }
149
150 /// Singleton that captures, dedupes, and exposes crashes for the UI layer.
@@ -186,6 +218,11 @@ class CrashReportService {
218
219 /// Record a crash. Returns the captured `CrashReport` for the caller to use
220 /// (e.g. `ErrorWidget.builder` rendering).
221 + ///
222 + /// Setting `_currentCrash.value` from inside `ErrorWidget.builder` is safe:
223 + /// `ValueNotifier` schedules a rebuild via `markNeedsBuild` rather than
224 + /// building synchronously, so it will not trigger a "dirty during build"
225 + /// assertion.
226 CrashReport capture(
227 Object error,
228 StackTrace stack, {
@@ -249,6 +286,21 @@ class CrashReportService {
286 );
287 }
288
289 + /// Resets the singleton state. Intended for tests only.
290 + @visibleForTesting
291 + void resetForTesting() {
292 + _initialized = false;
293 + _packageInfo = null;
294 + _appVersion = 'unknown';
295 + _buildNumber = 'unknown';
296 + _platformLabel = _platformFromDart();
297 + _osVersion = 'unknown';
298 + _deviceManufacturer = 'unknown';
299 + _deviceModel = 'unknown';
300 + _recentCrashes.clear();
301 + _currentCrash.value = null;
302 + }
303 +
304 bool _isDuplicate(CrashReport report) {
305 final cutoff = DateTime.now().subtract(_dedupeWindow);
306 for (final prior in _recentCrashes.reversed) {
lib/core/widgets/crash_error_widget.dart
+63 -16
@@ -1,6 +1,9 @@
1 import 'package:flutter/material.dart';
2 +import 'package:flutter/services.dart';
3 +import 'package:hugeicons/hugeicons.dart';
4 import 'package:url_launcher/url_launcher.dart';
5
6 +import '../../l10n/app_localizations.dart';
7 import '../services/crash_report_service.dart';
8
9 /// User-facing fallback shown when a crash is captured. Used both as
@@ -14,6 +17,7 @@ class CrashErrorWidget extends StatelessWidget {
17 @override
18 Widget build(BuildContext context) {
19 final theme = Theme.of(context);
20 + final l10n = AppLocalizations.of(context);
21 final stackLines = crash.stackTrace.toString().split('\n');
22 final previewLines = stackLines.take(40).join('\n');
23
@@ -37,15 +41,15 @@ class CrashErrorWidget extends StatelessWidget {
41 color: theme.colorScheme.errorContainer,
42 borderRadius: BorderRadius.circular(20),
43 ),
40 - child: Icon(
41 - Icons.error_outline,
44 + child: HugeIcon(
45 + icon: HugeIcons.strokeRoundedAlertCircle,
46 size: 36,
47 color: theme.colorScheme.onErrorContainer,
48 ),
49 ),
50 const SizedBox(height: 16),
51 Text(
48 - 'Something went wrong',
52 + l10n?.crash_report_title ?? 'Something went wrong',
53 textAlign: TextAlign.center,
54 style: theme.textTheme.headlineSmall?.copyWith(
55 fontWeight: FontWeight.w700,
@@ -78,8 +82,8 @@ class CrashErrorWidget extends StatelessWidget {
82 children: [
83 Row(
84 children: [
81 - Icon(
82 - Icons.bug_report_outlined,
85 + HugeIcon(
86 + icon: HugeIcons.strokeRoundedBug01,
87 size: 18,
88 color: theme.colorScheme.onSurfaceVariant,
89 ),
@@ -109,13 +113,13 @@ class CrashErrorWidget extends StatelessWidget {
113 tilePadding: EdgeInsets.zero,
114 childrenPadding: const EdgeInsets.only(bottom: 8),
115 title: Text(
112 - 'Stack trace',
116 + l10n?.crash_report_stack_trace ?? 'Stack trace',
117 style: theme.textTheme.titleSmall?.copyWith(
118 fontWeight: FontWeight.w600,
119 ),
120 ),
121 subtitle: Text(
118 - 'Tap to expand',
122 + l10n?.crash_report_tap_to_expand ?? 'Tap to expand',
123 style: theme.textTheme.bodySmall?.copyWith(
124 color: theme.colorScheme.onSurfaceVariant,
125 ),
@@ -133,7 +137,10 @@ class CrashErrorWidget extends StatelessWidget {
137 ),
138 ),
139 child: SelectableText(
136 - previewLines.isEmpty ? '<empty>' : previewLines,
140 + previewLines.isEmpty
141 + ? (l10n?.crash_report_empty_stack ??
142 + '<empty>')
143 + : previewLines,
144 style: const TextStyle(
145 fontFamily: 'monospace',
146 fontSize: 11.5,
@@ -147,19 +154,34 @@ class CrashErrorWidget extends StatelessWidget {
154 const SizedBox(height: 20),
155 FilledButton.icon(
156 onPressed: () => _openGitHub(context),
150 - icon: const Icon(Icons.open_in_new),
151 - label: const Text('Report this crash'),
157 + icon: const HugeIcon(
158 + icon: HugeIcons.strokeRoundedArrowUpRight01,
159 + size: 18,
160 + ),
161 + label: Text(
162 + l10n?.crash_report_button ?? 'Report this crash',
163 + ),
164 + ),
165 + const SizedBox(height: 8),
166 + OutlinedButton.icon(
167 + onPressed: () => _copyReport(context),
168 + icon: const HugeIcon(
169 + icon: HugeIcons.strokeRoundedCopy01,
170 + size: 18,
171 + ),
172 + label: Text(l10n?.copy ?? 'Copy report'),
173 ),
174 const SizedBox(height: 8),
175 TextButton(
176 onPressed: () => _tryAgain(context),
156 - child: const Text('Try again'),
177 + child: Text(l10n?.crash_try_again ?? 'Try again'),
178 ),
179 const SizedBox(height: 12),
180 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.',
181 + l10n?.crash_report_disclaimer ??
182 + 'Reporting opens GitHub with diagnostics prefilled. '
183 + 'You stay in control — nothing is submitted automatically. '
184 + 'Please review and remove any sensitive content before submitting.',
185 textAlign: TextAlign.center,
186 style: theme.textTheme.bodySmall?.copyWith(
187 color: theme.colorScheme.onSurfaceVariant,
@@ -178,13 +200,15 @@ class CrashErrorWidget extends StatelessWidget {
200 Future<void> _openGitHub(BuildContext context) async {
201 final uri = CrashReportService.instance.buildGitHubIssueUrl(crash);
202 final messenger = ScaffoldMessenger.maybeOf(context);
203 + final l10n = AppLocalizations.of(context);
204 try {
205 final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
206 if (!ok && messenger != null) {
207 messenger.showSnackBar(
185 - const SnackBar(
208 + SnackBar(
209 content: Text(
187 - 'Could not open GitHub. Please copy the URL manually.',
210 + l10n?.could_not_open_github ??
211 + 'Could not open GitHub. Please copy the URL manually.',
212 ),
213 ),
214 );
@@ -198,6 +222,29 @@ class CrashErrorWidget extends StatelessWidget {
222 }
223 }
224
225 + Future<void> _copyReport(BuildContext context) async {
226 + final messenger = ScaffoldMessenger.maybeOf(context);
227 + final l10n = AppLocalizations.of(context);
228 + try {
229 + await Clipboard.setData(ClipboardData(text: crash.markdownBody));
230 + if (messenger != null) {
231 + messenger.showSnackBar(
232 + SnackBar(
233 + content: Text(
234 + l10n?.crash_report_copied ?? 'Copied to clipboard',
235 + ),
236 + ),
237 + );
238 + }
239 + } catch (e) {
240 + if (messenger != null) {
241 + messenger.showSnackBar(
242 + SnackBar(content: Text('Failed to copy: $e')),
243 + );
244 + }
245 + }
246 + }
247 +
248 void _tryAgain(BuildContext context) {
249 CrashReportService.instance.clearCrash();
250 }
lib/features/chat/providers/chat_notifier.dart
+122 -54
@@ -124,6 +124,7 @@ class ChatNotifier extends Notifier<ChatState> {
124 DateTime? _streamStartTime;
125 DateTime? _firstTokenTime;
126 bool _useFreshConversationSystemPrompt = false;
127 + bool _isRegenerating = false;
128 String? _freshConversationSystemPrompt;
129
130 String? get _activeConversationId =>
@@ -141,33 +142,39 @@ class ChatNotifier extends Notifier<ChatState> {
142 _firstTokenTime ??= DateTime.now();
143 }
144
144 - Message _finalizeStreamMessage(Message msg, {String? stopReason}) {
145 - final stats = _streamStats;
146 - final start = _streamStartTime;
147 - final firstToken = _firstTokenTime;
145 + Message _finalizeStreamMessage(
146 + Message msg, {
147 + String? stopReason,
148 + ChatStats? stats,
149 + DateTime? startTime,
150 + DateTime? firstTokenTime,
151 + }) {
152 + final effectiveStats = stats ?? _streamStats;
153 + final effectiveStart = startTime ?? _streamStartTime;
154 + final effectiveFirstToken = firstTokenTime ?? _firstTokenTime;
155 final now = DateTime.now();
156
157 int? ttftMs;
151 - if (firstToken != null && start != null) {
152 - ttftMs = firstToken.difference(start).inMilliseconds;
153 - } else if (stats?.timeToFirstTokenSeconds != null) {
154 - ttftMs = (stats!.timeToFirstTokenSeconds! * 1000).round();
158 + if (effectiveFirstToken != null && effectiveStart != null) {
159 + ttftMs = effectiveFirstToken.difference(effectiveStart).inMilliseconds;
160 + } else if (effectiveStats?.timeToFirstTokenSeconds != null) {
161 + ttftMs = (effectiveStats!.timeToFirstTokenSeconds! * 1000).round();
162 }
163
164 int? genMs;
158 - if (start != null) {
159 - genMs = now.difference(start).inMilliseconds;
165 + if (effectiveStart != null) {
166 + genMs = now.difference(effectiveStart).inMilliseconds;
167 }
168
162 - double? tps = stats?.tokensPerSecond;
163 - final outputTokens = stats?.totalOutputTokens;
169 + double? tps = effectiveStats?.tokensPerSecond;
170 + final outputTokens = effectiveStats?.totalOutputTokens;
171 if (tps == null && outputTokens != null && genMs != null && genMs > 0) {
172 tps = outputTokens / (genMs / 1000);
173 }
174
175 return msg.copyWith(
176 tokenCount: outputTokens ?? msg.tokenCount,
170 - inputTokenCount: stats?.inputTokens ?? msg.inputTokenCount,
177 + inputTokenCount: effectiveStats?.inputTokens ?? msg.inputTokenCount,
178 tokensPerSecond: tps ?? msg.tokensPerSecond,
179 generationTimeMs: genMs ?? msg.generationTimeMs,
180 ttftMs: ttftMs ?? msg.ttftMs,
@@ -520,14 +527,45 @@ class ChatNotifier extends Notifier<ChatState> {
527 _latestStreamingMessage = null;
528
529 if (streamingMessage != null && convId != null) {
523 - unawaited(_persistCancelledMessageInBackground(streamingMessage, convId));
530 + // Capture the old stream's metrics before anything resets them for the
531 + // next generation.
532 + final stats = _streamStats;
533 + final startTime = _streamStartTime;
534 + final firstTokenTime = _firstTokenTime;
535 +
536 + // Mark the in-memory copy as cancelled and inactive so it does not stay
537 + // visible in the active timeline while the next stream starts.
538 + final cancelled = streamingMessage.copyWith(
539 + conversationId: convId,
540 + status: MessageStatus.cancelled,
541 + isProcessing: false,
542 + isActiveVariant: false,
543 + );
544 + final updatedAll = state.allMessages.map((m) {
545 + return m.id == cancelled.id ? cancelled : m;
546 + }).toList();
547 + state = state.copyWith(
548 + allMessages: updatedAll,
549 + messages: MessageVariants.resolveActiveTimeline(updatedAll),
550 + );
551 +
552 + unawaited(_persistCancelledMessageInBackground(
553 + streamingMessage,
554 + convId,
555 + stats: stats,
556 + startTime: startTime,
557 + firstTokenTime: firstTokenTime,
558 + ));
559 }
560 }
561
562 Future<void> _persistCancelledMessageInBackground(
563 Message streamingMessage,
529 - String conversationId,
530 - ) async {
564 + String conversationId, {
565 + ChatStats? stats,
566 + DateTime? startTime,
567 + DateTime? firstTokenTime,
568 + }) async {
569 try {
570 final finalMessage = _finalizeStreamMessage(
571 streamingMessage.copyWith(
@@ -536,6 +574,9 @@ class ChatNotifier extends Notifier<ChatState> {
574 isProcessing: false,
575 ),
576 stopReason: 'cancelled',
577 + stats: stats,
578 + startTime: startTime,
579 + firstTokenTime: firstTokenTime,
580 );
581 await _saveService?.flush();
582 await _saveMessage(finalMessage);
@@ -800,6 +841,9 @@ class ChatNotifier extends Notifier<ChatState> {
841
842 ref.read(chatBackgroundServiceProvider).start();
843
844 + await _abortStreamImmediately();
845 + ref.read(isStreamingProvider.notifier).setStreaming(true);
846 +
847 _resetStreamMetrics();
848 _resetCheckpointMetrics();
849 _updateSavedMetrics(assistantMessage);
@@ -807,7 +851,6 @@ class ChatNotifier extends Notifier<ChatState> {
851 final messagesForApi = _buildMessagesForApi(selectedModel);
852
853 try {
810 - await _abortStreamImmediately();
854 _uiUpdateTimer?.cancel();
855
856 String reasoningContent = '';
@@ -1678,34 +1721,40 @@ class ChatNotifier extends Notifier<ChatState> {
1721 }
1722
1723 Future<void> retryMessage(String messageId) async {
1681 - final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1682 - if (messageIndex == -1) return;
1683 -
1684 - final message = state.messages[messageIndex];
1685 - if (message.role != MessageRole.assistant) return;
1686 -
1687 - if (messageIndex > 0 &&
1688 - state.messages[messageIndex - 1].role == MessageRole.user) {
1689 - final userMessage = state.messages[messageIndex - 1];
1690 - final groupId = MessageVariants.groupId(message);
1724 + if (state.isStreaming || _isRegenerating) return;
1725 + _isRegenerating = true;
1726 + try {
1727 + final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1728 + if (messageIndex == -1) return;
1729 +
1730 + final message = state.messages[messageIndex];
1731 + if (message.role != MessageRole.assistant) return;
1732 +
1733 + if (messageIndex > 0 &&
1734 + state.messages[messageIndex - 1].role == MessageRole.user) {
1735 + final userMessage = state.messages[messageIndex - 1];
1736 + final groupId = MessageVariants.groupId(message);
1737 +
1738 + final deactivated = state.allMessages.map((m) {
1739 + if (MessageVariants.groupId(m) != groupId) return m;
1740 + return m.copyWith(isActiveVariant: false);
1741 + }).toList();
1742 + for (final m in deactivated.where(
1743 + (x) => MessageVariants.groupId(x) == groupId,
1744 + )) {
1745 + await _saveMessage(m);
1746 + }
1747 + state = state.copyWith(allMessages: deactivated);
1748
1692 - final deactivated = state.allMessages.map((m) {
1693 - if (MessageVariants.groupId(m) != groupId) return m;
1694 - return m.copyWith(isActiveVariant: false);
1695 - }).toList();
1696 - for (final m in deactivated.where(
1697 - (x) => MessageVariants.groupId(x) == groupId,
1698 - )) {
1699 - await _saveMessage(m);
1749 + await _regenerateAssistant(
1750 + userMessage,
1751 + variantGroupId: groupId,
1752 + threadOrder: message.threadOrder,
1753 + variantIndex: _nextVariantIndex(groupId),
1754 + );
1755 }
1701 - state = state.copyWith(allMessages: deactivated);
1702 -
1703 - await _regenerateAssistant(
1704 - userMessage,
1705 - variantGroupId: groupId,
1706 - threadOrder: message.threadOrder,
1707 - variantIndex: _nextVariantIndex(groupId),
1708 - );
1756 + } finally {
1757 + _isRegenerating = false;
1758 }
1759 }
1760
@@ -1719,6 +1768,8 @@ class ChatNotifier extends Notifier<ChatState> {
1768 final server = ref.read(activeServerProvider);
1769 if (server == null || _activeConversationId == null) return;
1770
1771 + await _abortStreamImmediately();
1772 +
1773 final assistantMessage = Message(
1774 id: generateUuid(),
1775 conversationId: _activeConversationId!,
@@ -1760,7 +1811,19 @@ class ChatNotifier extends Notifier<ChatState> {
1811 final server = ref.read(activeServerProvider);
1812 final chatParams = ref.read(chatParamsProvider);
1813 final chatService = ref.read(chatServiceProvider);
1763 - if (server == null || chatService == null) return;
1814 + if (server == null || chatService == null) {
1815 + final errorMessage = assistantMessage.copyWith(
1816 + status: MessageStatus.error,
1817 + errorMessage: 'No chat service available',
1818 + isProcessing: false,
1819 + );
1820 + await _saveMessage(errorMessage);
1821 + _replaceMessageInAll(errorMessage, clearStreaming: true);
1822 + state = state.copyWith(isStreaming: false, clearStreaming: true);
1823 + ref.read(isStreamingProvider.notifier).setStreaming(false);
1824 + ref.read(chatBackgroundServiceProvider).stop();
1825 + return;
1826 + }
1827
1828 final messagesForApi = continueGeneration
1829 ? _buildMessagesForContinue(selectedModel, assistantMessage)
@@ -2111,17 +2174,22 @@ class ChatNotifier extends Notifier<ChatState> {
2174 }
2175
2176 Future<void> generateResponseForLastUser() async {
2114 - if (state.isStreaming) return;
2115 - final messages = state.messages;
2116 - if (messages.isEmpty || messages.last.role != MessageRole.user) return;
2177 + if (state.isStreaming || _isRegenerating) return;
2178 + _isRegenerating = true;
2179 + try {
2180 + final messages = state.messages;
2181 + if (messages.isEmpty || messages.last.role != MessageRole.user) return;
2182
2118 - final userMessage = messages.last;
2119 - await _regenerateAssistant(
2120 - userMessage,
2121 - variantGroupId: generateUuid(),
2122 - threadOrder: userMessage.threadOrder + 1,
2123 - variantIndex: 0,
2124 - );
2183 + final userMessage = messages.last;
2184 + await _regenerateAssistant(
2185 + userMessage,
2186 + variantGroupId: generateUuid(),
2187 + threadOrder: userMessage.threadOrder + 1,
2188 + variantIndex: 0,
2189 + );
2190 + } finally {
2191 + _isRegenerating = false;
2192 + }
2193 }
2194
2195 Future<void> generateAiUserMessage() async {
lib/features/settings/views/settings_screen.dart
+7 -3
@@ -477,7 +477,7 @@ class SettingsViews extends ConsumerWidget {
477 ),
478 _SectionActionButton(
479 icon: HugeIcons.strokeRoundedAlertCircle,
480 - label: 'Report a problem',
480 + label: l10n.report_a_problem,
481 onPressed: () => _openFeedbackIssue(context),
482 ),
483 ],
@@ -2400,12 +2400,16 @@ String _languageLabel(String? localeCode, AppLocalizations l10n) {
2400 Future<void> _openFeedbackIssue(BuildContext context) async {
2401 final uri = CrashReportService.instance.buildFeedbackIssueUrl();
2402 final messenger = ScaffoldMessenger.maybeOf(context);
2403 + final l10n = AppLocalizations.of(context);
2404 try {
2405 final ok = await launchUrl(uri, mode: LaunchMode.externalApplication);
2406 if (!ok && messenger != null) {
2407 messenger.showSnackBar(
2407 - const SnackBar(
2408 - content: Text('Could not open GitHub. Please try again later.'),
2408 + SnackBar(
2409 + content: Text(
2410 + l10n?.could_not_open_github ??
2411 + 'Could not open GitHub. Please try again later.',
2412 + ),
2413 ),
2414 );
2415 }
lib/l10n/app_ar.arb
+10 -1
@@ -846,5 +846,14 @@
846 "cloud_sync_last_synced": "آخر مزامنة",
847 "cloud_sync_never": "أبدًا",
848 "cloud_sync_conflicts": "التعارضات المحفوظة",
849 - "cloud_sync_passphrase_mismatch": "عبارتا المرور غير متطابقتين"
849 + "cloud_sync_passphrase_mismatch": "عبارتا المرور غير متطابقتين",
850 + "crash_report_title": "حدث خطأ ما",
851 + "crash_report_stack_trace": "تتبع المكدس",
852 + "crash_report_tap_to_expand": "اضغط للتوسيع",
853 + "crash_report_button": "الإبلاغ عن هذا العطل",
854 + "crash_try_again": "المحاولة مرة أخرى",
855 + "crash_report_empty_stack": "<فارغ>",
856 + "crash_report_disclaimer": "يفتح الإبلاغ GitHub مع بيانات التشخيص مملوءة مسبقًا. أنت المسؤول — لا يتم إرسال أي شيء تلقائيًا. يرجى المراجعة وإزالة أي محتوى حساس قبل الإرسال.",
857 + "crash_report_copied": "تم النسخ إلى الحافظة",
858 + "report_a_problem": "الإبلاغ عن مشكلة"
859 }
lib/l10n/app_bn.arb
+10 -1
@@ -846,5 +846,14 @@
846 "cloud_sync_last_synced": "সর্বশেষ সিঙ্ক",
847 "cloud_sync_never": "কখনও নয়",
848 "cloud_sync_conflicts": "সংরক্ষিত দ্বন্দ্ব",
849 - "cloud_sync_passphrase_mismatch": "পাসফ্রেজ মিলছে না"
849 + "cloud_sync_passphrase_mismatch": "পাসফ্রেজ মিলছে না",
850 + "crash_report_title": "কিছু ভুল হয়েছে",
851 + "crash_report_stack_trace": "স্ট্যাক ট্রেস",
852 + "crash_report_tap_to_expand": "সম্প্রসারণ করতে ট্যাপ করুন",
853 + "crash_report_button": "এই ক্র্যাশ রিপোর্ট করুন",
854 + "crash_try_again": "আবার চেষ্টা করুন",
855 + "crash_report_empty_stack": "<খালি>",
856 + "crash_report_disclaimer": "রিপোর্টিং GitHub খুলবে যেখানে ডায়াগনস্টিক্স পূর্বে পূরণ করা থাকবে। নিয়ন্ত্রণ আপনার হাতে — কিছুই স্বয়ংক্রিয়ভাবে জমা দেওয়া হয় না। জমা দেওয়ার আগে দয়া করে সংবেদনশীল তথ্য সরিয়ে দিন।",
857 + "crash_report_copied": "ক্লিপবোর্ডে কপি করা হয়েছে",
858 + "report_a_problem": "একটি সমস্যা রিপোর্ট করুন"
859 }
lib/l10n/app_en.arb
+37 -1
@@ -3031,5 +3031,41 @@
3031 "cloud_sync_last_synced": "Last synced",
3032 "cloud_sync_never": "Never",
3033 "cloud_sync_conflicts": "Conflicts preserved",
3034 - "cloud_sync_passphrase_mismatch": "Passphrases do not match"
3034 + "cloud_sync_passphrase_mismatch": "Passphrases do not match",
3035 + "crash_report_title": "Something went wrong",
3036 + "@crash_report_title": {
3037 + "description": "Title shown on the crash error screen"
3038 + },
3039 + "crash_report_stack_trace": "Stack trace",
3040 + "@crash_report_stack_trace": {
3041 + "description": "Label for the expandable stack trace section on the crash screen"
3042 + },
3043 + "crash_report_tap_to_expand": "Tap to expand",
3044 + "@crash_report_tap_to_expand": {
3045 + "description": "Hint telling the user they can expand the stack trace section"
3046 + },
3047 + "crash_report_button": "Report this crash",
3048 + "@crash_report_button": {
3049 + "description": "Button that opens GitHub to report the captured crash"
3050 + },
3051 + "crash_try_again": "Try again",
3052 + "@crash_try_again": {
3053 + "description": "Button that clears the crash and attempts to reload the app"
3054 + },
3055 + "crash_report_empty_stack": "<empty>",
3056 + "@crash_report_empty_stack": {
3057 + "description": "Placeholder shown when a crash stack trace is empty"
3058 + },
3059 + "crash_report_disclaimer": "Reporting opens GitHub with diagnostics prefilled. You stay in control — nothing is submitted automatically. Please review and remove any sensitive content before submitting.",
3060 + "@crash_report_disclaimer": {
3061 + "description": "Disclaimer below the crash report buttons explaining that the issue is not submitted automatically"
3062 + },
3063 + "crash_report_copied": "Copied to clipboard",
3064 + "@crash_report_copied": {
3065 + "description": "Snackbar message shown after the user copies the crash report to the clipboard"
3066 + },
3067 + "report_a_problem": "Report a problem",
3068 + "@report_a_problem": {
3069 + "description": "Settings tile label that opens the generic feedback issue form"
3070 + }
3071 }
lib/l10n/app_es.arb
+10 -1
@@ -846,5 +846,14 @@
846 "cloud_sync_last_synced": "Última sincronización",
847 "cloud_sync_never": "Nunca",
848 "cloud_sync_conflicts": "Conflictos conservados",
849 - "cloud_sync_passphrase_mismatch": "Las frases no coinciden"
849 + "cloud_sync_passphrase_mismatch": "Las frases no coinciden",
850 + "crash_report_title": "Algo salió mal",
851 + "crash_report_stack_trace": "Seguimiento de pila",
852 + "crash_report_tap_to_expand": "Toca para expandir",
853 + "crash_report_button": "Informar de este error",
854 + "crash_try_again": "Intentar de nuevo",
855 + "crash_report_empty_stack": "<vacío>",
856 + "crash_report_disclaimer": "Informar abre GitHub con el diagnóstico rellenado de antemano. Tú tienes el control: no se envía nada automáticamente. Revisa y elimina cualquier contenido sensible antes de enviar.",
857 + "crash_report_copied": "Copiado al portapapeles",
858 + "report_a_problem": "Informar de un problema"
859 }
lib/l10n/app_hi.arb
+10 -1
@@ -846,5 +846,14 @@
846 "cloud_sync_last_synced": "अंतिम सिंक",
847 "cloud_sync_never": "कभी नहीं",
848 "cloud_sync_conflicts": "सहेजे गए विरोध",
849 - "cloud_sync_passphrase_mismatch": "पासफ़्रेज़ मेल नहीं खाते"
849 + "cloud_sync_passphrase_mismatch": "पासफ़्रेज़ मेल नहीं खाते",
850 + "crash_report_title": "कुछ गलत हो गया",
851 + "crash_report_stack_trace": "स्टैक ट्रेस",
852 + "crash_report_tap_to_expand": "विस्तार करने के लिए टैप करें",
853 + "crash_report_button": "इस क्रैश की रिपोर्ट करें",
854 + "crash_try_again": "फिर से प्रयास करें",
855 + "crash_report_empty_stack": "<खाली>",
856 + "crash_report_disclaimer": "रिपोर्टिंग GitHub खोलेगी जिसमें निदान पहले से भरा होगा। नियंत्रण आपके पास है — कुछ भी स्वचालित रूप से सबमिट नहीं किया जाता। कृपया सबमिट करने से पहले संवेदनशील सामग्री की समीक्षा करें और हटा दें।",
857 + "crash_report_copied": "क्लिपबोर्ड पर कॉपी किया गया",
858 + "report_a_problem": "समस्या की रिपोर्ट करें"
859 }
lib/l10n/app_it.arb
+10 -1
@@ -846,5 +846,14 @@
846 "cloud_sync_last_synced": "Ultima sincronizzazione",
847 "cloud_sync_never": "Mai",
848 "cloud_sync_conflicts": "Conflitti conservati",
849 - "cloud_sync_passphrase_mismatch": "Le passphrase non corrispondono"
849 + "cloud_sync_passphrase_mismatch": "Le passphrase non corrispondono",
850 + "crash_report_title": "Qualcosa è andato storto",
851 + "crash_report_stack_trace": "Stack trace",
852 + "crash_report_tap_to_expand": "Tocca per espandere",
853 + "crash_report_button": "Segnala questo crash",
854 + "crash_try_again": "Riprova",
855 + "crash_report_empty_stack": "<vuoto>",
856 + "crash_report_disclaimer": "Segnalare apre GitHub con le diagnostiche già compilate. Hai il controllo: non viene inviato nulla automaticamente. Rivedi e rimuovi eventuali contenuti sensibili prima di inviare.",
857 + "crash_report_copied": "Copiato negli appunti",
858 + "report_a_problem": "Segnala un problema"
859 }
lib/l10n/app_ja.arb
+10 -1
@@ -846,5 +846,14 @@
846 "cloud_sync_last_synced": "最終同期",
847 "cloud_sync_never": "未実行",
848 "cloud_sync_conflicts": "保持された競合",
849 - "cloud_sync_passphrase_mismatch": "パスフレーズが一致しません"
849 + "cloud_sync_passphrase_mismatch": "パスフレーズが一致しません",
850 + "crash_report_title": "問題が発生しました",
851 + "crash_report_stack_trace": "スタックトレース",
852 + "crash_report_tap_to_expand": "タップして展開",
853 + "crash_report_button": "このクラッシュを報告",
854 + "crash_try_again": "再試行",
855 + "crash_report_empty_stack": "<空>",
856 + "crash_report_disclaimer": "報告すると、診断情報があらかじめ入力された状態で GitHub が開きます。自動送信はされません。送信前に機密性の高い内容を削除してください。",
857 + "crash_report_copied": "クリップボードにコピーしました",
858 + "report_a_problem": "問題を報告"
859 }
lib/l10n/app_localizations.dart
+54
@@ -5176,6 +5176,60 @@ abstract class AppLocalizations {
5176 /// In en, this message translates to:
5177 /// **'Passphrases do not match'**
5178 String get cloud_sync_passphrase_mismatch;
5179 +
5180 + /// Title shown on the crash error screen
5181 + ///
5182 + /// In en, this message translates to:
5183 + /// **'Something went wrong'**
5184 + String get crash_report_title;
5185 +
5186 + /// Label for the expandable stack trace section on the crash screen
5187 + ///
5188 + /// In en, this message translates to:
5189 + /// **'Stack trace'**
5190 + String get crash_report_stack_trace;
5191 +
5192 + /// Hint telling the user they can expand the stack trace section
5193 + ///
5194 + /// In en, this message translates to:
5195 + /// **'Tap to expand'**
5196 + String get crash_report_tap_to_expand;
5197 +
5198 + /// Button that opens GitHub to report the captured crash
5199 + ///
5200 + /// In en, this message translates to:
5201 + /// **'Report this crash'**
5202 + String get crash_report_button;
5203 +
5204 + /// Button that clears the crash and attempts to reload the app
5205 + ///
5206 + /// In en, this message translates to:
5207 + /// **'Try again'**
5208 + String get crash_try_again;
5209 +
5210 + /// Placeholder shown when a crash stack trace is empty
5211 + ///
5212 + /// In en, this message translates to:
5213 + /// **'<empty>'**
5214 + String get crash_report_empty_stack;
5215 +
5216 + /// Disclaimer below the crash report buttons explaining that the issue is not submitted automatically
5217 + ///
5218 + /// In en, this message translates to:
5219 + /// **'Reporting opens GitHub with diagnostics prefilled. You stay in control — nothing is submitted automatically. Please review and remove any sensitive content before submitting.'**
5220 + String get crash_report_disclaimer;
5221 +
5222 + /// Snackbar message shown after the user copies the crash report to the clipboard
5223 + ///
5224 + /// In en, this message translates to:
5225 + /// **'Copied to clipboard'**
5226 + String get crash_report_copied;
5227 +
5228 + /// Settings tile label that opens the generic feedback issue form
5229 + ///
5230 + /// In en, this message translates to:
5231 + /// **'Report a problem'**
5232 + String get report_a_problem;
5233 }
5234
5235 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+28
@@ -2751,4 +2751,32 @@ class AppLocalizationsAr extends AppLocalizations {
2751
2752 @override
2753 String get cloud_sync_passphrase_mismatch => 'عبارتا المرور غير متطابقتين';
2754 +
2755 + @override
2756 + String get crash_report_title => 'حدث خطأ ما';
2757 +
2758 + @override
2759 + String get crash_report_stack_trace => 'تتبع المكدس';
2760 +
2761 + @override
2762 + String get crash_report_tap_to_expand => 'اضغط للتوسيع';
2763 +
2764 + @override
2765 + String get crash_report_button => 'الإبلاغ عن هذا العطل';
2766 +
2767 + @override
2768 + String get crash_try_again => 'المحاولة مرة أخرى';
2769 +
2770 + @override
2771 + String get crash_report_empty_stack => '<فارغ>';
2772 +
2773 + @override
2774 + String get crash_report_disclaimer =>
2775 + 'يفتح الإبلاغ GitHub مع بيانات التشخيص مملوءة مسبقًا. أنت المسؤول — لا يتم إرسال أي شيء تلقائيًا. يرجى المراجعة وإزالة أي محتوى حساس قبل الإرسال.';
2776 +
2777 + @override
2778 + String get crash_report_copied => 'تم النسخ إلى الحافظة';
2779 +
2780 + @override
2781 + String get report_a_problem => 'الإبلاغ عن مشكلة';
2782 }
lib/l10n/app_localizations_bn.dart
+28
@@ -2771,4 +2771,32 @@ class AppLocalizationsBn extends AppLocalizations {
2771
2772 @override
2773 String get cloud_sync_passphrase_mismatch => 'পাসফ্রেজ মিলছে না';
2774 +
2775 + @override
2776 + String get crash_report_title => 'কিছু ভুল হয়েছে';
2777 +
2778 + @override
2779 + String get crash_report_stack_trace => 'স্ট্যাক ট্রেস';
2780 +
2781 + @override
2782 + String get crash_report_tap_to_expand => 'সম্প্রসারণ করতে ট্যাপ করুন';
2783 +
2784 + @override
2785 + String get crash_report_button => 'এই ক্র্যাশ রিপোর্ট করুন';
2786 +
2787 + @override
2788 + String get crash_try_again => 'আবার চেষ্টা করুন';
2789 +
2790 + @override
2791 + String get crash_report_empty_stack => '<খালি>';
2792 +
2793 + @override
2794 + String get crash_report_disclaimer =>
2795 + 'রিপোর্টিং GitHub খুলবে যেখানে ডায়াগনস্টিক্স পূর্বে পূরণ করা থাকবে। নিয়ন্ত্রণ আপনার হাতে — কিছুই স্বয়ংক্রিয়ভাবে জমা দেওয়া হয় না। জমা দেওয়ার আগে দয়া করে সংবেদনশীল তথ্য সরিয়ে দিন।';
2796 +
2797 + @override
2798 + String get crash_report_copied => 'ক্লিপবোর্ডে কপি করা হয়েছে';
2799 +
2800 + @override
2801 + String get report_a_problem => 'একটি সমস্যা রিপোর্ট করুন';
2802 }
lib/l10n/app_localizations_en.dart
+28
@@ -2759,4 +2759,32 @@ class AppLocalizationsEn extends AppLocalizations {
2759
2760 @override
2761 String get cloud_sync_passphrase_mismatch => 'Passphrases do not match';
2762 +
2763 + @override
2764 + String get crash_report_title => 'Something went wrong';
2765 +
2766 + @override
2767 + String get crash_report_stack_trace => 'Stack trace';
2768 +
2769 + @override
2770 + String get crash_report_tap_to_expand => 'Tap to expand';
2771 +
2772 + @override
2773 + String get crash_report_button => 'Report this crash';
2774 +
2775 + @override
2776 + String get crash_try_again => 'Try again';
2777 +
2778 + @override
2779 + String get crash_report_empty_stack => '<empty>';
2780 +
2781 + @override
2782 + String get crash_report_disclaimer =>
2783 + 'Reporting opens GitHub with diagnostics prefilled. You stay in control — nothing is submitted automatically. Please review and remove any sensitive content before submitting.';
2784 +
2785 + @override
2786 + String get crash_report_copied => 'Copied to clipboard';
2787 +
2788 + @override
2789 + String get report_a_problem => 'Report a problem';
2790 }
lib/l10n/app_localizations_es.dart
+28
@@ -2775,4 +2775,32 @@ class AppLocalizationsEs extends AppLocalizations {
2775
2776 @override
2777 String get cloud_sync_passphrase_mismatch => 'Las frases no coinciden';
2778 +
2779 + @override
2780 + String get crash_report_title => 'Algo salió mal';
2781 +
2782 + @override
2783 + String get crash_report_stack_trace => 'Seguimiento de pila';
2784 +
2785 + @override
2786 + String get crash_report_tap_to_expand => 'Toca para expandir';
2787 +
2788 + @override
2789 + String get crash_report_button => 'Informar de este error';
2790 +
2791 + @override
2792 + String get crash_try_again => 'Intentar de nuevo';
2793 +
2794 + @override
2795 + String get crash_report_empty_stack => '<vacío>';
2796 +
2797 + @override
2798 + String get crash_report_disclaimer =>
2799 + 'Informar abre GitHub con el diagnóstico rellenado de antemano. Tú tienes el control: no se envía nada automáticamente. Revisa y elimina cualquier contenido sensible antes de enviar.';
2800 +
2801 + @override
2802 + String get crash_report_copied => 'Copiado al portapapeles';
2803 +
2804 + @override
2805 + String get report_a_problem => 'Informar de un problema';
2806 }
lib/l10n/app_localizations_hi.dart
+28
@@ -2764,4 +2764,32 @@ class AppLocalizationsHi extends AppLocalizations {
2764
2765 @override
2766 String get cloud_sync_passphrase_mismatch => 'पासफ़्रेज़ मेल नहीं खाते';
2767 +
2768 + @override
2769 + String get crash_report_title => 'कुछ गलत हो गया';
2770 +
2771 + @override
2772 + String get crash_report_stack_trace => 'स्टैक ट्रेस';
2773 +
2774 + @override
2775 + String get crash_report_tap_to_expand => 'विस्तार करने के लिए टैप करें';
2776 +
2777 + @override
2778 + String get crash_report_button => 'इस क्रैश की रिपोर्ट करें';
2779 +
2780 + @override
2781 + String get crash_try_again => 'फिर से प्रयास करें';
2782 +
2783 + @override
2784 + String get crash_report_empty_stack => '<खाली>';
2785 +
2786 + @override
2787 + String get crash_report_disclaimer =>
2788 + 'रिपोर्टिंग GitHub खोलेगी जिसमें निदान पहले से भरा होगा। नियंत्रण आपके पास है — कुछ भी स्वचालित रूप से सबमिट नहीं किया जाता। कृपया सबमिट करने से पहले संवेदनशील सामग्री की समीक्षा करें और हटा दें।';
2789 +
2790 + @override
2791 + String get crash_report_copied => 'क्लिपबोर्ड पर कॉपी किया गया';
2792 +
2793 + @override
2794 + String get report_a_problem => 'समस्या की रिपोर्ट करें';
2795 }
lib/l10n/app_localizations_it.dart
+28
@@ -2778,4 +2778,32 @@ class AppLocalizationsIt extends AppLocalizations {
2778 @override
2779 String get cloud_sync_passphrase_mismatch =>
2780 'Le passphrase non corrispondono';
2781 +
2782 + @override
2783 + String get crash_report_title => 'Qualcosa è andato storto';
2784 +
2785 + @override
2786 + String get crash_report_stack_trace => 'Stack trace';
2787 +
2788 + @override
2789 + String get crash_report_tap_to_expand => 'Tocca per espandere';
2790 +
2791 + @override
2792 + String get crash_report_button => 'Segnala questo crash';
2793 +
2794 + @override
2795 + String get crash_try_again => 'Riprova';
2796 +
2797 + @override
2798 + String get crash_report_empty_stack => '<vuoto>';
2799 +
2800 + @override
2801 + String get crash_report_disclaimer =>
2802 + 'Segnalare apre GitHub con le diagnostiche già compilate. Hai il controllo: non viene inviato nulla automaticamente. Rivedi e rimuovi eventuali contenuti sensibili prima di inviare.';
2803 +
2804 + @override
2805 + String get crash_report_copied => 'Copiato negli appunti';
2806 +
2807 + @override
2808 + String get report_a_problem => 'Segnala un problema';
2809 }
lib/l10n/app_localizations_ja.dart
+28
@@ -2711,4 +2711,32 @@ class AppLocalizationsJa extends AppLocalizations {
2711
2712 @override
2713 String get cloud_sync_passphrase_mismatch => 'パスフレーズが一致しません';
2714 +
2715 + @override
2716 + String get crash_report_title => '問題が発生しました';
2717 +
2718 + @override
2719 + String get crash_report_stack_trace => 'スタックトレース';
2720 +
2721 + @override
2722 + String get crash_report_tap_to_expand => 'タップして展開';
2723 +
2724 + @override
2725 + String get crash_report_button => 'このクラッシュを報告';
2726 +
2727 + @override
2728 + String get crash_try_again => '再試行';
2729 +
2730 + @override
2731 + String get crash_report_empty_stack => '<空>';
2732 +
2733 + @override
2734 + String get crash_report_disclaimer =>
2735 + '報告すると、診断情報があらかじめ入力された状態で GitHub が開きます。自動送信はされません。送信前に機密性の高い内容を削除してください。';
2736 +
2737 + @override
2738 + String get crash_report_copied => 'クリップボードにコピーしました';
2739 +
2740 + @override
2741 + String get report_a_problem => '問題を報告';
2742 }
lib/l10n/app_localizations_ru.dart
+28
@@ -2778,4 +2778,32 @@ class AppLocalizationsRu extends AppLocalizations {
2778
2779 @override
2780 String get cloud_sync_passphrase_mismatch => 'Пароли не совпадают';
2781 +
2782 + @override
2783 + String get crash_report_title => 'Что-то пошло не так';
2784 +
2785 + @override
2786 + String get crash_report_stack_trace => 'Трассировка стека';
2787 +
2788 + @override
2789 + String get crash_report_tap_to_expand => 'Нажмите, чтобы развернуть';
2790 +
2791 + @override
2792 + String get crash_report_button => 'Сообщить об этом сбое';
2793 +
2794 + @override
2795 + String get crash_try_again => 'Попробовать снова';
2796 +
2797 + @override
2798 + String get crash_report_empty_stack => '<пусто>';
2799 +
2800 + @override
2801 + String get crash_report_disclaimer =>
2802 + 'Отчёт откроет GitHub с уже заполненной диагностикой. Контроль остаётся за вами — ничего не отправляется автоматически. Перед отправкой проверьте и удалите конфиденциальные данные.';
2803 +
2804 + @override
2805 + String get crash_report_copied => 'Скопировано в буфер обмена';
2806 +
2807 + @override
2808 + String get report_a_problem => 'Сообщить о проблеме';
2809 }
lib/l10n/app_localizations_zh.dart
+56
@@ -2691,6 +2691,34 @@ class AppLocalizationsZh extends AppLocalizations {
2691
2692 @override
2693 String get cloud_sync_passphrase_mismatch => '密码短语不匹配';
2694 +
2695 + @override
2696 + String get crash_report_title => '出了点问题';
2697 +
2698 + @override
2699 + String get crash_report_stack_trace => '堆栈跟踪';
2700 +
2701 + @override
2702 + String get crash_report_tap_to_expand => '点击展开';
2703 +
2704 + @override
2705 + String get crash_report_button => '报告此崩溃';
2706 +
2707 + @override
2708 + String get crash_try_again => '重试';
2709 +
2710 + @override
2711 + String get crash_report_empty_stack => '<空>';
2712 +
2713 + @override
2714 + String get crash_report_disclaimer =>
2715 + '报告将打开已预填诊断信息的 GitHub。由您掌控 — 不会自动提交任何内容。请在提交前检查并删除敏感内容。';
2716 +
2717 + @override
2718 + String get crash_report_copied => '已复制到剪贴板';
2719 +
2720 + @override
2721 + String get report_a_problem => '报告问题';
2722 }
2723
2724 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
@@ -5381,4 +5409,32 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
5409
5410 @override
5411 String get cloud_sync_passphrase_mismatch => '密碼片語不相符';
5412 +
5413 + @override
5414 + String get crash_report_title => '出了點問題';
5415 +
5416 + @override
5417 + String get crash_report_stack_trace => '堆疊追蹤';
5418 +
5419 + @override
5420 + String get crash_report_tap_to_expand => '點擊展開';
5421 +
5422 + @override
5423 + String get crash_report_button => '回報此當機';
5424 +
5425 + @override
5426 + String get crash_try_again => '重試';
5427 +
5428 + @override
5429 + String get crash_report_empty_stack => '<空>';
5430 +
5431 + @override
5432 + String get crash_report_disclaimer =>
5433 + '回報將開啟已預先填入診斷資訊的 GitHub。由您掌控 — 不會自動提交任何內容。請在提交前檢查並移除敏感內容。';
5434 +
5435 + @override
5436 + String get crash_report_copied => '已複製到剪貼簿';
5437 +
5438 + @override
5439 + String get report_a_problem => '回報問題';
5440 }
lib/l10n/app_ru.arb
+10 -1
@@ -843,5 +843,14 @@
843 "cloud_sync_last_synced": "Последняя синхронизация",
844 "cloud_sync_never": "Никогда",
845 "cloud_sync_conflicts": "Сохранённые конфликты",
846 - "cloud_sync_passphrase_mismatch": "Пароли не совпадают"
846 + "cloud_sync_passphrase_mismatch": "Пароли не совпадают",
847 + "crash_report_title": "Что-то пошло не так",
848 + "crash_report_stack_trace": "Трассировка стека",
849 + "crash_report_tap_to_expand": "Нажмите, чтобы развернуть",
850 + "crash_report_button": "Сообщить об этом сбое",
851 + "crash_try_again": "Попробовать снова",
852 + "crash_report_empty_stack": "<пусто>",
853 + "crash_report_disclaimer": "Отчёт откроет GitHub с уже заполненной диагностикой. Контроль остаётся за вами — ничего не отправляется автоматически. Перед отправкой проверьте и удалите конфиденциальные данные.",
854 + "crash_report_copied": "Скопировано в буфер обмена",
855 + "report_a_problem": "Сообщить о проблеме"
856 }
lib/l10n/app_zh.arb
+10 -1
@@ -846,5 +846,14 @@
846 "cloud_sync_last_synced": "上次同步",
847 "cloud_sync_never": "从未",
848 "cloud_sync_conflicts": "已保留冲突",
849 - "cloud_sync_passphrase_mismatch": "密码短语不匹配"
849 + "cloud_sync_passphrase_mismatch": "密码短语不匹配",
850 + "crash_report_title": "出了点问题",
851 + "crash_report_stack_trace": "堆栈跟踪",
852 + "crash_report_tap_to_expand": "点击展开",
853 + "crash_report_button": "报告此崩溃",
854 + "crash_try_again": "重试",
855 + "crash_report_empty_stack": "<空>",
856 + "crash_report_disclaimer": "报告将打开已预填诊断信息的 GitHub。由您掌控 — 不会自动提交任何内容。请在提交前检查并删除敏感内容。",
857 + "crash_report_copied": "已复制到剪贴板",
858 + "report_a_problem": "报告问题"
859 }
lib/l10n/app_zh_TW.arb
+10 -1
@@ -846,5 +846,14 @@
846 "cloud_sync_last_synced": "上次同步",
847 "cloud_sync_never": "從未",
848 "cloud_sync_conflicts": "已保留衝突",
849 - "cloud_sync_passphrase_mismatch": "密碼片語不相符"
849 + "cloud_sync_passphrase_mismatch": "密碼片語不相符",
850 + "crash_report_title": "出了點問題",
851 + "crash_report_stack_trace": "堆疊追蹤",
852 + "crash_report_tap_to_expand": "點擊展開",
853 + "crash_report_button": "回報此當機",
854 + "crash_try_again": "重試",
855 + "crash_report_empty_stack": "<空>",
856 + "crash_report_disclaimer": "回報將開啟已預先填入診斷資訊的 GitHub。由您掌控 — 不會自動提交任何內容。請在提交前檢查並移除敏感內容。",
857 + "crash_report_copied": "已複製到剪貼簿",
858 + "report_a_problem": "回報問題"
859 }
lib/main.dart
+4
@@ -15,6 +15,10 @@ Future<void> main() async {
15 final crashReports = CrashReportService.instance;
16 await crashReports.initialize();
17
18 + // Framework errors are reported both here and via ErrorWidget.builder
19 + // (because FlutterError.presentError eventually builds an ErrorWidget).
20 + // CrashReportService deduplicates identical errors within a 2-second
21 + // window, so the user sees only one crash screen per incident.
22 FlutterError.onError = (details) {
23 FlutterError.presentError(details);
24 crashReports.capture(
pubspec.lock
+1 -1
@@ -242,7 +242,7 @@ packages:
242 source: hosted
243 version: "1.5.0"
244 characters:
245 - dependency: transitive
245 + dependency: "direct main"
246 description:
247 name: characters
248 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
pubspec.yaml
+1
@@ -42,6 +42,7 @@ dependencies:
42 go_router: ^17.1.0
43 shadcn_ui: ^0.54.0
44 url_launcher: ^6.3.1
45 + characters: ^1.4.0
46 dio: ^5.9.2
47 flutter_secure_storage: ^10.3.1
48 aws_common: ^0.7.7
test/core/services/crash_report_service_test.dart new
+183
@@ -0,0 +1,183 @@
1 +import 'package:characters/characters.dart';
2 +import 'package:flutter_test/flutter_test.dart';
3 +import 'package:localmind/core/services/crash_report_service.dart';
4 +
5 +CrashReport _makeReport({
6 + required Object error,
7 + required StackTrace stack,
8 + String? errorWidgetPayload,
9 +}) {
10 + return CrashReport(
11 + error: error,
12 + errorType: error.runtimeType.toString(),
13 + stackTrace: stack,
14 + timestamp: DateTime(2026, 7, 20, 12, 0, 0),
15 + appVersion: '1.5.2',
16 + buildNumber: '57',
17 + platform: 'Android',
18 + osVersion: 'Android 14 (SDK 34)',
19 + deviceManufacturer: 'TestManufacturer',
20 + deviceModel: 'TestModel',
21 + locale: 'en_US',
22 + errorWidgetPayload: errorWidgetPayload,
23 + );
24 +}
25 +
26 +void main() {
27 + setUp(() {
28 + CrashReportService.instance.resetForTesting();
29 + });
30 +
31 + group('CrashReport', () {
32 + test('shortError returns first line when under limit', () {
33 + final report = _makeReport(
34 + error: Exception('Something went wrong'),
35 + stack: StackTrace.current,
36 + );
37 + expect(report.shortError, 'Something went wrong');
38 + });
39 +
40 + test('shortError truncates long first lines to 80 chars', () {
41 + final longMessage = 'A' * 200;
42 + final report = _makeReport(
43 + error: Exception(longMessage),
44 + stack: StackTrace.current,
45 + );
46 + expect(report.shortError.length, 80);
47 + expect(report.shortError, '${'A' * 77}...');
48 + });
49 +
50 + test('shortError sanitizes user-specific paths', () {
51 + final report = _makeReport(
52 + error: Exception(
53 + 'File not found: /Users/janedoe/Documents/secret.txt',
54 + ),
55 + stack: StackTrace.current,
56 + );
57 + expect(report.shortError, isNot(contains('/Users/janedoe')));
58 + expect(report.shortError, contains('/Users/<user>'));
59 + });
60 +
61 + test('shortError does not split emoji grapheme clusters', () {
62 + final report = _makeReport(
63 + error: Exception('🚀' * 100),
64 + stack: StackTrace.current,
65 + );
66 + final value = report.shortError;
67 + expect(value.characters.length, lessThanOrEqualTo(80));
68 + // The trailing code unit must not be a lone high surrogate.
69 + final lastCodeUnit = value.codeUnits.last;
70 + expect(
71 + lastCodeUnit < 0xD800 || lastCodeUnit > 0xDBFF,
72 + isTrue,
73 + reason: 'shortError should not end with a high surrogate',
74 + );
75 + });
76 +
77 + test('markdownBody contains expected sections', () {
78 + final report = _makeReport(
79 + error: Exception('Oops'),
80 + stack: StackTrace.fromString('#0 somewhere (file.dart:1:2)'),
81 + );
82 + final body = report.markdownBody;
83 + expect(body, contains('## App Info'));
84 + expect(body, contains('## Device Info'));
85 + expect(body, contains('## Crash Details'));
86 + expect(body, contains('## Stack Trace'));
87 + expect(body, contains('## Steps to Reproduce'));
88 + expect(body, contains('## Expected Behavior'));
89 + expect(body, contains('## Actual Behavior'));
90 + expect(body, contains('## Additional Context'));
91 + });
92 +
93 + test('markdownBody escapes triple backticks', () {
94 + final report = _makeReport(
95 + error: Exception('```code```'),
96 + stack: StackTrace.fromString('#0 frame (file.dart:1:2)'),
97 + );
98 + final body = report.markdownBody;
99 + expect(body, isNot(contains('```code```')));
100 + expect(body, contains("'''code'''"));
101 + });
102 +
103 + test('markdownBody sanitizes user-specific paths', () {
104 + final report = _makeReport(
105 + error: Exception(
106 + 'File not found: /Users/janedoe/Documents/secret.txt',
107 + ),
108 + stack: StackTrace.fromString(
109 + '#0 frame (/home/johndoe/app/main.dart:1:2)',
110 + ),
111 + );
112 + final body = report.markdownBody;
113 + expect(body, isNot(contains('/Users/janedoe')));
114 + expect(body, isNot(contains('/home/johndoe')));
115 + expect(body, contains('/Users/<user>'));
116 + expect(body, contains('/home/<user>'));
117 + });
118 +
119 + test('generated GitHub URL stays under a safe length', () {
120 + final longStack = '#0 frame (file.dart:1:2)\n' * 500;
121 + final report = _makeReport(
122 + error: Exception('Oops'),
123 + stack: StackTrace.fromString(longStack),
124 + );
125 + final service = CrashReportService.instance;
126 + final url = service.buildGitHubIssueUrl(report).toString();
127 + expect(url.length, lessThan(8192));
128 + });
129 + });
130 +
131 + group('CrashReportService', () {
132 + test('buildGitHubIssueUrl points to the configured repo and template', () {
133 + final report = _makeReport(
134 + error: Exception('Boom'),
135 + stack: StackTrace.current,
136 + );
137 + final uri = CrashReportService.instance.buildGitHubIssueUrl(report);
138 + expect(uri.host, 'github.com');
139 + expect(uri.path, '/abdulmominsakib/localmind/issues/new');
140 + expect(uri.queryParameters['template'], 'crash_report.md');
141 + expect(uri.queryParameters['labels'], 'crash,bug');
142 + expect(uri.queryParameters['title'], 'Crash: Boom');
143 + expect(uri.queryParameters['body'], isNotNull);
144 + });
145 +
146 + test('buildFeedbackIssueUrl points to the feedback form', () {
147 + final uri = CrashReportService.instance.buildFeedbackIssueUrl();
148 + expect(uri.path, '/abdulmominsakib/localmind/issues/new');
149 + expect(uri.queryParameters['template'], 'crash_report.md');
150 + expect(uri.queryParameters['labels'], 'feedback');
151 + expect(uri.queryParameters['title'], 'Feedback');
152 + });
153 +
154 + test('capture deduplicates identical errors within the window', () {
155 + final service = CrashReportService.instance;
156 + final error = Exception('Same error');
157 + final stack = StackTrace.current;
158 +
159 + service.capture(error, stack);
160 + expect(service.currentCrash.value, isNotNull);
161 +
162 + service.clearCrash();
163 + service.capture(error, stack);
164 + expect(service.currentCrash.value, isNull);
165 + });
166 +
167 + test('capture does not deduplicate different errors', () {
168 + final service = CrashReportService.instance;
169 + service.capture(Exception('First'), StackTrace.current);
170 + service.clearCrash();
171 + service.capture(Exception('Second'), StackTrace.current);
172 + expect(service.currentCrash.value, isNotNull);
173 + });
174 +
175 + test('clearCrash resets the current crash', () {
176 + final service = CrashReportService.instance;
177 + service.capture(Exception('Oops'), StackTrace.current);
178 + expect(service.currentCrash.value, isNotNull);
179 + service.clearCrash();
180 + expect(service.currentCrash.value, isNull);
181 + });
182 + });
183 +}