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
+}