diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index f52f48c..9634694 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -28,18 +28,22 @@ extension ContextX on BuildContext { IconData? icon, List<(String, Future 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 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, diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index f96ddd4..c98f87f 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -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 { diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index f01fc21..e3b7aa3 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -242,7 +242,7 @@ abstract class PackageManager extends StateNotifier { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { : 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 { OperationFailed( target: fileMetadata, type: FailureType.downloadFailed, - message: 'Failed to resume download: $e', + message: 'Failed to resume download. Please start again.', + description: '$e', ), ); } diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 8d2e3bd..a70f0aa 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -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, + ); } } }