mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
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:
co-authored by
Claude Opus 4.5
parent
85d5c2309c
commit
6075110e69
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user