Improve user-facing error messages (#307)

Replace technical error messages with user-friendly alternatives.
Technical details are preserved in expandable "Technical details"
section for bug reports.

Changes:
- Replace raw exceptions ($e) with clear messages
- Add collapsible technical details to error toasts
- Add "Copy for bug report" button formatting
- Transform Kotlin error messages (e.g., "Hash verification failed"
  becomes "File verification failed. Please try downloading again.")

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
alltheseas
2026-02-05 13:57:42 -06:00
co-authored by Claude Opus 4.5
parent 85d5c2309c
commit 6075110e69
4 changed files with 172 additions and 67 deletions
+84 -31
View File
@@ -28,18 +28,22 @@ extension ContextX on BuildContext {
IconData? icon,
List<(String, Future<void> Function())> actions = const [],
}) {
// Always add Copy action for errors with description
// Always add Copy action for errors - formats nicely for bug reports
final allActions = <(String, Future<void> Function())>[
...actions,
if (description != null)
(
'Copy',
() async {
await Clipboard.setData(
ClipboardData(text: '$title\n\n$description'),
);
},
),
(
'Copy for bug report',
() async {
final buffer = StringBuffer();
buffer.writeln('Error: $title');
if (description != null) {
buffer.writeln();
buffer.writeln('Technical details:');
buffer.writeln(description);
}
await Clipboard.setData(ClipboardData(text: buffer.toString()));
},
),
];
_showCustomToast(
@@ -285,7 +289,7 @@ class _ToastOverlayState extends State<_ToastOverlay>
}
}
class _ToastContent extends StatelessWidget {
class _ToastContent extends StatefulWidget {
final IconData icon;
final Color iconBgColor;
final Color accentColor;
@@ -304,9 +308,18 @@ class _ToastContent extends StatelessWidget {
required this.onDismiss,
});
@override
State<_ToastContent> createState() => _ToastContentState();
}
class _ToastContentState extends State<_ToastContent> {
bool _detailsExpanded = false;
@override
Widget build(BuildContext context) {
final hasExtraContent = description != null || actions.isNotEmpty;
final hasDescription = widget.description != null;
final hasActions = widget.actions.isNotEmpty;
final hasExtraContent = hasDescription || hasActions;
// Use Stack to position X at top-right always
return Stack(
@@ -325,10 +338,10 @@ class _ToastContent extends StatelessWidget {
width: 38,
height: 38,
decoration: BoxDecoration(
color: iconBgColor,
color: widget.iconBgColor,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: accentColor, size: 20),
child: Icon(widget.icon, color: widget.accentColor, size: 20),
),
const SizedBox(width: 14),
// Text content
@@ -339,7 +352,7 @@ class _ToastContent extends StatelessWidget {
children: [
// Title
Text(
title,
widget.title,
style: const TextStyle(
fontFamily: kFontFamily,
fontSize: 16,
@@ -348,35 +361,75 @@ class _ToastContent extends StatelessWidget {
height: 1.35,
),
),
// Description
if (description != null) ...[
const SizedBox(height: 6),
Text(
description!,
style: TextStyle(
fontFamily: kFontFamily,
fontSize: 13,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.7),
height: 1.45,
// Expandable technical details
if (hasDescription) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: () {
setState(() {
_detailsExpanded = !_detailsExpanded;
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_detailsExpanded
? Icons.expand_less
: Icons.expand_more,
size: 16,
color: Colors.white.withValues(alpha: 0.7),
),
const SizedBox(width: 4),
Text(
'Technical details',
style: TextStyle(
fontFamily: kFontFamily,
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
),
// Expanded description
if (_detailsExpanded) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: SelectableText(
widget.description!,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.8),
height: 1.4,
),
),
),
],
],
// Actions
if (actions.isNotEmpty) ...[
if (hasActions) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: actions
children: widget.actions
.map(
(action) => _ToastActionButton(
label: action.$1,
onPressed: () {
onDismiss();
widget.onDismiss();
action.$2();
},
accentColor: accentColor,
accentColor: widget.accentColor,
),
)
.toList(),
@@ -393,7 +446,7 @@ class _ToastContent extends StatelessWidget {
top: 0,
right: 0,
child: GestureDetector(
onTap: onDismiss,
onTap: widget.onDismiss,
child: Container(
width: 28,
height: 28,
@@ -271,13 +271,21 @@ final class AndroidPackageManager extends PackageManager {
break;
case InstallStatus.failed:
final failureType = _errorCodeToFailureType(errorCode, message);
final userMessage = _getUserFriendlyMessage(failureType, message);
// Preserve technical details: combine original message and description
final technicalDetails = [
if (message != null) message,
if (description != null) description,
].join('\n\n');
setOperation(
appId,
OperationFailed(
target: target,
type: _errorCodeToFailureType(errorCode, message),
message: message ?? 'Installation failed',
description: description,
type: failureType,
message: userMessage,
description: technicalDetails.isNotEmpty ? technicalDetails : null,
filePath: filePath,
),
);
@@ -363,6 +371,27 @@ final class AndroidPackageManager extends PackageManager {
return FailureType.installFailed;
}
/// Convert failure type to user-friendly message.
/// Technical details are preserved in the description field.
String _getUserFriendlyMessage(FailureType type, String? originalMessage) {
return switch (type) {
FailureType.downloadFailed => 'Download failed. Please try again.',
FailureType.hashMismatch =>
'File verification failed. Please try downloading again.',
FailureType.invalidFile =>
'Invalid app file. Please try downloading again.',
FailureType.certMismatch =>
'Update signed by different developer. Uninstall current version to update.',
FailureType.permissionDenied =>
'Permission required. Please grant install permission and try again.',
FailureType.insufficientStorage =>
'Not enough storage space. Please free up space and try again.',
FailureType.incompatible =>
'This app is not compatible with your device.',
FailureType.installFailed => 'Installation failed. Please try again.',
};
}
void _deleteFile(String? path) {
if (path == null) return;
try {
@@ -242,7 +242,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download timed out (no response from server)',
message: 'Download timed out. Please check your internet connection and try again.',
),
);
needsQueueProcessing = true;
@@ -252,7 +252,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: op.target,
type: FailureType.installFailed,
message: 'Operation timed out (no response from system)',
message: 'Installation timed out. Please try again.',
filePath: op.filePath,
),
);
@@ -339,7 +339,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'No download URL available',
message: 'Download link unavailable. Please try again later.',
),
);
return false;
@@ -379,7 +379,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: item.target,
type: FailureType.downloadFailed,
message: 'No download URL available',
message: 'Download link unavailable. Please try again later.',
),
);
} else {
@@ -421,7 +421,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download stalled. Please try again.',
message: 'Download stopped. Please try again.',
),
);
// Cancel the stuck task to clean up
@@ -441,7 +441,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download lost. Please try again.',
message: 'Download was interrupted. Please try again.',
),
);
scheduleProcessQueue();
@@ -454,7 +454,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download error: $e',
message: 'Download failed. Please try again.',
description: '$e',
),
);
scheduleProcessQueue();
@@ -486,7 +487,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Download task not found. Please try again.',
message: 'Download was interrupted. Please try again.',
),
);
}
@@ -497,7 +498,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'Failed to resume download: $e',
message: 'Failed to resume download. Please start again.',
description: '$e',
),
);
}
@@ -606,14 +608,15 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
await uninstall(appId);
await _performInstall(appId, op.target, filePath);
} catch (e) {
final message = e.toString();
if (!message.contains('cancelled')) {
final errorMessage = e.toString();
if (!errorMessage.contains('cancelled')) {
setOperation(
appId,
OperationFailed(
target: op.target,
type: FailureType.installFailed,
message: 'Force update failed: $message',
message: 'Update failed. Please try again.',
description: errorMessage,
filePath: filePath,
),
);
@@ -624,7 +627,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
target: op.target,
type: FailureType.certMismatch,
message:
'Certificate mismatch. Uninstall current version to update.',
'Update signed by different developer. Uninstall current version to update.',
filePath: filePath,
),
);
@@ -733,7 +736,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'Failed to start download: $e',
message: 'Failed to start download. Please try again.',
description: '$e',
),
);
}
@@ -843,18 +847,20 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'File not found (404)',
message: 'File no longer available (404). Please check for a newer version.',
),
);
scheduleProcessQueue();
break;
case TaskStatus.failed:
String error = 'Download failed';
String? errorDetails;
final exception = update.exception;
if (exception != null) {
error = exception.toString();
if (error.length > 200) error = '${error.substring(0, 197)}...';
errorDetails = exception.toString();
if (errorDetails.length > 500) {
errorDetails = '${errorDetails.substring(0, 497)}...';
}
}
activeDownloads.remove(appId);
setOperation(
@@ -862,7 +868,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: error,
message: 'Download failed. Please try again.',
description: errorDetails,
),
);
scheduleProcessQueue();
@@ -920,7 +927,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: target,
type: FailureType.downloadFailed,
message: 'Failed to access downloaded file: $e',
message: 'Failed to access downloaded file. Please try again.',
description: '$e',
),
);
}
@@ -956,7 +964,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: op.target,
type: FailureType.downloadFailed,
message: 'No download URL available',
message: 'Download link unavailable. Please try again later.',
),
);
continue;
@@ -1093,6 +1101,14 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
final isHashMismatch = message.contains('Hash verification failed');
final isInvalidFile = message.contains('Invalid APK file');
final userMessage = isCertMismatch
? 'Update signed by different developer. Uninstall current version to update.'
: isHashMismatch
? 'File verification failed. Please try downloading again.'
: isInvalidFile
? 'Invalid app file. Please try downloading again.'
: 'Installation failed. Please try again.';
setOperation(
appId,
OperationFailed(
@@ -1104,9 +1120,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
: isCertMismatch
? FailureType.certMismatch
: FailureType.installFailed,
message: isCertMismatch
? 'Certificate mismatch. Uninstall current version to update.'
: message,
message: userMessage,
description: message,
filePath: filePath,
),
);
@@ -1203,7 +1218,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
OperationFailed(
target: fileMetadata,
type: FailureType.downloadFailed,
message: 'Failed to resume download: $e',
message: 'Failed to resume download. Please start again.',
description: '$e',
),
);
}
+15 -8
View File
@@ -608,9 +608,12 @@ class InstallButton extends ConsumerWidget {
await pm.uninstall(app.identifier);
} catch (e) {
if (context.mounted) {
final message = e.toString();
if (!message.contains('cancelled')) {
context.showError('Uninstall failed', description: '$e');
final errorMessage = e.toString();
if (!errorMessage.contains('cancelled')) {
context.showError(
'Uninstall failed. Please try again.',
description: errorMessage,
);
}
}
}
@@ -634,8 +637,9 @@ class InstallButton extends ConsumerWidget {
void _showErrorToast(BuildContext context, OperationFailed operation) {
if (operation.type == FailureType.certMismatch) {
context.showError(
'Certificate mismatch',
description: 'The app signature does not match. Force update required.',
'Update signed by different developer',
description:
'To install this update, you\'ll need to uninstall the current version first. This will remove app data.',
);
} else if (operation.type == FailureType.incompatible) {
context.showError(
@@ -740,9 +744,12 @@ class InstallButton extends ConsumerWidget {
final pm = ref.read(packageManagerProvider.notifier);
await pm.forceUpdate(app.identifier);
} catch (e) {
final message = e.toString();
if (context.mounted && !message.contains('cancelled')) {
context.showError('Force update failed', description: '$e');
final errorMessage = e.toString();
if (context.mounted && !errorMessage.contains('cancelled')) {
context.showError(
'Update failed. Please try again.',
description: errorMessage,
);
}
}
}