diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 6c908b45..239f5277 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -1,8 +1,14 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/utils/user_facing_error.dart'; export 'package:spotiflac_android/l10n/app_localizations.dart'; extension AppLocalizationsX on BuildContext { AppLocalizations get l10n => AppLocalizations.of(this); + + String friendlyError( + Object? error, { + String fallback = defaultUserFacingErrorMessage, + }) => userFacingErrorMessage(error, fallback: fallback); } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index e878aa17..004f87ae 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -183,7 +183,7 @@ class _AlbumScreenState extends ConsumerState } catch (e) { if (mounted) { setState(() { - _error = e.toString(); + _error = context.friendlyError(e); _isLoading = false; }); } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index d6f1bcc8..7a6ac1b6 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -366,7 +366,7 @@ class _ArtistScreenState extends ConsumerState } catch (e) { if (mounted) { setState(() { - _error = e.toString(); + _error = context.friendlyError(e); _isLoadingDiscography = false; }); } diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 13a38ead..1c4db523 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -185,7 +185,9 @@ class _DownloadedAlbumScreenState extends ConsumerState if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + content: Text( + context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), + ), ), ); } diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 812ff401..e2edf4a7 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -685,7 +685,7 @@ class _LoadingOrErrorScaffold extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - error!, + context.friendlyError(error), style: TextStyle(color: Theme.of(context).colorScheme.error), ), const SizedBox(height: 16), @@ -969,7 +969,7 @@ class _ExtensionAlbumScreenState extends ConsumerState { } catch (e) { if (!mounted) return; setState(() { - _error = context.l10n.snackbarError(e.toString()); + _error = context.l10n.snackbarError(context.friendlyError(e)); _isLoading = false; }); } @@ -1115,7 +1115,7 @@ class _ExtensionPlaylistScreenState } catch (e) { if (!mounted) return; setState(() { - _error = context.l10n.snackbarError(e.toString()); + _error = context.l10n.snackbarError(context.friendlyError(e)); _isLoading = false; }); } @@ -1267,7 +1267,7 @@ class _ExtensionArtistScreenState extends ConsumerState } catch (e) { if (!mounted) return; setState(() { - _error = context.l10n.snackbarError(e.toString()); + _error = context.l10n.snackbarError(context.friendlyError(e)); _isLoading = false; }); } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 6a6c6de2..5bd97406 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -160,7 +160,9 @@ class _LocalAlbumScreenState extends ConsumerState if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + content: Text( + context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), + ), ), ); } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 57a9b374..b9aa866e 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -221,7 +221,7 @@ class _MainShellState extends ConsumerState SnackBar( content: Text( context.l10n.snackbarCannotOpenFile( - e.toString(), + context.friendlyError(e), ), ), ), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 627b3e7c..d69f378f 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -687,7 +687,9 @@ class _NowPlayingScreenState extends ConsumerState { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + content: Text( + context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), + ), ), ); } @@ -717,7 +719,9 @@ class _NowPlayingScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.nowPlayingShuffleLibraryFailed(e.toString()), + context.l10n.nowPlayingShuffleLibraryFailed( + context.friendlyError(e), + ), ), ), ); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index b3df2d5d..24dc6655 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -163,7 +163,7 @@ class _PlaylistScreenState extends ConsumerState } catch (e) { if (!mounted) return; setState(() { - _error = e.toString(); + _error = context.friendlyError(e); _isLoading = false; }); } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4dcf8296..7944923b 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1863,7 +1863,9 @@ class _QueueTabState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.snackbarFolderPickerFailed(e.toString()), + context.l10n.snackbarFolderPickerFailed( + context.friendlyError(e), + ), ), ), ); diff --git a/lib/screens/queue_tab_item_widgets.dart b/lib/screens/queue_tab_item_widgets.dart index 1ce4d9b0..8588795d 100644 --- a/lib/screens/queue_tab_item_widgets.dart +++ b/lib/screens/queue_tab_item_widgets.dart @@ -332,7 +332,10 @@ extension _QueueTabItemWidgets on _QueueTabState { if (raw == downloadFolderAccessLostErrorMessage) { return context.l10n.downloadErrorFolderAccessLost; } - return raw; + return context.friendlyError( + raw, + fallback: context.l10n.updateDownloadFailed, + ); } Widget _buildDownloadFailureMessage( diff --git a/lib/screens/queue_tab_navigation.dart b/lib/screens/queue_tab_navigation.dart index a0b782dc..e93e0d2b 100644 --- a/lib/screens/queue_tab_navigation.dart +++ b/lib/screens/queue_tab_navigation.dart @@ -24,7 +24,9 @@ extension _QueueTabNavigation on _QueueTabState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + content: Text( + context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), + ), ), ); } @@ -60,7 +62,9 @@ extension _QueueTabNavigation on _QueueTabState { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + content: Text( + context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), + ), ), ); } diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index a58c0276..04d2aa9d 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -397,7 +397,7 @@ class _RepoTabState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - error, + context.friendlyError(error), style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.onErrorContainer), ), @@ -539,7 +539,7 @@ class _RepoTabState extends ConsumerState { ), const SizedBox(height: 8), Text( - error, + context.friendlyError(error), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), diff --git a/lib/screens/settings/app_settings_page.dart b/lib/screens/settings/app_settings_page.dart index 3b37ca0d..15181d0e 100644 --- a/lib/screens/settings/app_settings_page.dart +++ b/lib/screens/settings/app_settings_page.dart @@ -246,7 +246,9 @@ class AppSettingsPage extends ConsumerWidget { if (context.mounted) { Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + SnackBar( + content: Text(context.l10n.snackbarError(context.friendlyError(e))), + ), ); } } diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index 98a0992b..ea69a604 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -55,9 +55,11 @@ class _CacheManagementPageState extends ConsumerState { } catch (e) { if (!mounted) return; setState(() => _isLoading = false); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarError(context.friendlyError(e))), + ), + ); } } @@ -293,9 +295,11 @@ class _CacheManagementPageState extends ConsumerState { } } catch (e) { if (!mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.snackbarError(context.friendlyError(e))), + ), + ); } finally { if (mounted) { setState(() => _busyAction = null); diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 58176e05..7d69b524 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -204,7 +204,10 @@ class _ExtensionDetailPageState extends ConsumerState { if (hasError && extension.errorMessage != null) _InfoRow( label: context.l10n.extensionError, - value: extension.errorMessage!, + value: context.friendlyError( + extension.errorMessage, + fallback: context.l10n.extensionsErrorLoading, + ), isError: true, ), ], @@ -805,7 +808,10 @@ class _HealthCheckItem extends StatelessWidget { if (check.required) context.l10n.extensionHealthRequired, ]; final message = check.error?.trim().isNotEmpty == true - ? check.error! + ? context.friendlyError( + check.error, + fallback: context.l10n.extensionHealthServiceUnknown, + ) : check.message; return Column( @@ -1115,9 +1121,16 @@ class _SettingItemState extends State<_SettingItem> { payload['error'] as String? ?? result['error'] as String? ?? context.l10n.extensionActionFailed; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(error))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.friendlyError( + error, + fallback: context.l10n.extensionActionFailed, + ), + ), + ), + ); } else { if (widget.onActionPayload != null) { await widget.onActionPayload!(payload); @@ -1150,7 +1163,9 @@ class _SettingItemState extends State<_SettingItem> { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + SnackBar( + content: Text(context.l10n.snackbarError(context.friendlyError(e))), + ), ); } } finally { diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 718a2943..2e02d0d4 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -23,17 +23,6 @@ class ExtensionsPage extends ConsumerStatefulWidget { } class _ExtensionsPageState extends ConsumerState { - static final RegExp _platformExceptionPattern = RegExp( - r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),', - ); - static final RegExp _platformExceptionSimplePattern = RegExp( - r'PlatformException\([^,]+,\s*(.+?),\s*null', - ); - static final RegExp _trailingNullsPattern = RegExp( - r',\s*null\s*,\s*null\)?$', - ); - static final RegExp _leadingCommaPattern = RegExp(r'^\s*,\s*'); - @override void initState() { super.initState(); @@ -94,7 +83,7 @@ class _ExtensionsPageState extends ConsumerState { const SizedBox(width: 12), Expanded( child: Text( - extState.error!, + context.friendlyError(extState.error), style: TextStyle( color: colorScheme.onErrorContainer, ), @@ -308,26 +297,10 @@ class _ExtensionsPageState extends ConsumerState { } String _getFriendlyErrorMessage(String? error) { - if (error == null) return context.l10n.snackbarFailedToInstall; - - String message = error; - - if (message.contains('PlatformException')) { - final match = _platformExceptionPattern.firstMatch(message); - if (match != null) { - message = match.group(1)?.trim() ?? message; - } else { - final simpleMatch = _platformExceptionSimplePattern.firstMatch(message); - if (simpleMatch != null) { - message = simpleMatch.group(1)?.trim() ?? message; - } - } - } - - message = message.replaceAll(_trailingNullsPattern, ''); - message = message.replaceAll(_leadingCommaPattern, ''); - - return message; + return context.friendlyError( + error, + fallback: context.l10n.snackbarFailedToInstall, + ); } } @@ -414,8 +387,10 @@ class _ExtensionItem extends StatelessWidget { const SizedBox(height: 2), Text( hasError - ? extension.errorMessage ?? - context.l10n.extensionsErrorLoading + ? context.friendlyError( + extension.errorMessage, + fallback: context.l10n.extensionsErrorLoading, + ) : serviceHealthStatus == null ? 'v${extension.version}' : 'v${extension.version} · ${_extensionHealthLabel(context, serviceHealthStatus)}', diff --git a/lib/screens/settings/files_settings_page.dart b/lib/screens/settings/files_settings_page.dart index 754326a5..190fda36 100644 --- a/lib/screens/settings/files_settings_page.dart +++ b/lib/screens/settings/files_settings_page.dart @@ -639,7 +639,9 @@ class _FilesSettingsPageState extends ConsumerState { ScaffoldMessenger.of(ctx).showSnackBar( SnackBar( content: Text( - ctx.l10n.snackbarFolderPickerFailed(e.toString()), + ctx.l10n.snackbarFolderPickerFailed( + ctx.friendlyError(e), + ), ), backgroundColor: Theme.of(ctx).colorScheme.error, duration: const Duration(seconds: 4), @@ -822,8 +824,9 @@ class _FilesSettingsPageState extends ConsumerState { leading: Icon(option.$4), title: Text(option.$2), subtitle: Text(option.$3), - trailing: - current == option.$1 ? const Icon(Icons.check) : null, + trailing: current == option.$1 + ? const Icon(Icons.check) + : null, onTap: () { ref .read(settingsProvider.notifier) @@ -974,7 +977,6 @@ class _FolderOption extends StatelessWidget { } } - /// Bottom sheet for editing a filename format. Owns its controller and disposes /// it in [dispose] to avoid use-after-dispose during the close animation. class _FilenameFormatEditorSheet extends StatefulWidget { @@ -1101,9 +1103,9 @@ class _FilenameFormatEditorSheetState ), Text( widget.title ?? context.l10n.filenameFormat, - style: Theme.of( - context, - ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), textAlign: TextAlign.center, ), const SizedBox(height: 8), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 1148c6eb..3135d06e 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -133,7 +133,9 @@ class _LibrarySettingsPageState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.snackbarFolderPickerFailed(e.toString()), + context.l10n.snackbarFolderPickerFailed( + context.friendlyError(e), + ), ), ), ); diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 0eeaab8c..0c85a195 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -258,7 +258,7 @@ class _SetupScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.snackbarCannotOpenFile(e.toString()), + context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), ), ), ); @@ -371,7 +371,9 @@ class _SetupScreenState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.snackbarFolderPickerFailed(e.toString()), + context.l10n.snackbarFolderPickerFailed( + context.friendlyError(e), + ), ), backgroundColor: Theme.of(context).colorScheme.error, duration: const Duration(seconds: 4), @@ -479,7 +481,9 @@ class _SetupScreenState extends ConsumerState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + SnackBar( + content: Text(context.l10n.snackbarError(context.friendlyError(e))), + ), ); } } finally { diff --git a/lib/screens/track_metadata_actions.dart b/lib/screens/track_metadata_actions.dart index 0398fd91..d4b3bcb5 100644 --- a/lib/screens/track_metadata_actions.dart +++ b/lib/screens/track_metadata_actions.dart @@ -211,7 +211,9 @@ extension _TrackMetadataFileActions on _TrackMetadataScreenState { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + content: Text( + context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), + ), ), ); } diff --git a/lib/screens/track_metadata_convert.dart b/lib/screens/track_metadata_convert.dart index fc2b14e9..7ea7512d 100644 --- a/lib/screens/track_metadata_convert.dart +++ b/lib/screens/track_metadata_convert.dart @@ -1124,7 +1124,11 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState { if (mounted) { _setState(() => _isConverting = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed(context.friendlyError(e)), + ), + ), ); } } finally { diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index d5d71462..ce8dbbe1 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -434,7 +434,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } catch (e) { if (!mounted) return; - _showSheetSnackBar(context.l10n.snackbarError(e.toString())); + _showSheetSnackBar(context.l10n.snackbarError(context.friendlyError(e))); } } @@ -1191,7 +1191,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { }); } catch (e) { if (mounted) { - _showSheetSnackBar(context.l10n.snackbarError(e.toString())); + _showSheetSnackBar( + context.l10n.snackbarError(context.friendlyError(e)), + ); } } finally { if (mounted) setState(() => _fetching = false); @@ -1261,7 +1263,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ); } catch (e) { if (mounted) { - _showSheetSnackBar(context.l10n.snackbarError(e.toString())); + _showSheetSnackBar( + context.l10n.snackbarError(context.friendlyError(e)), + ); } } finally { if (mounted) setState(() => _fetching = false); @@ -1346,7 +1350,12 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (result['error'] != null) { if (mounted) { - _showSheetSnackBar('${result['error']}'); + _showSheetSnackBar( + context.friendlyError( + result['error'], + fallback: context.l10n.metadataSaveFailedFfmpeg, + ), + ); } setState(() => _saving = false); return; @@ -1509,7 +1518,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } catch (e) { if (mounted) { - _showSheetSnackBar(context.l10n.snackbarError(e.toString())); + _showSheetSnackBar( + context.l10n.snackbarError(context.friendlyError(e)), + ); } } finally { if (mounted) setState(() => _saving = false); diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index 75553a5c..23863600 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -486,7 +486,10 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { success = true; } } else { - error = result['error']?.toString() ?? l10nFailedToEmbedLyrics; + error = userFacingErrorMessage( + result['error'], + fallback: l10nFailedToEmbedLyrics, + ); } } else if (isMp3 || isOpus || isM4A) { final metadata = _buildFallbackMetadata(); @@ -579,7 +582,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { if (mounted) { _setState(() => _isEmbedding = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + SnackBar( + content: Text(context.l10n.snackbarError(context.friendlyError(e))), + ), ); } } finally { @@ -677,7 +682,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), + context.l10n.trackSaveFailed( + context.friendlyError(result['error']), + ), ), ), ); @@ -771,7 +778,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), + context.l10n.trackSaveFailed( + context.friendlyError(result['error']), + ), ), ), ); @@ -784,7 +793,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed(context.friendlyError(e)), + ), + ), ); } } @@ -823,7 +836,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { ..showSnackBar( SnackBar( content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), + context.l10n.trackSaveFailed( + context.friendlyError(result['error']), + ), ), ), ); @@ -908,7 +923,9 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { ..showSnackBar( SnackBar( content: Text( - context.l10n.trackSaveFailed(result['error'].toString()), + context.l10n.trackSaveFailed( + context.friendlyError(result['error']), + ), ), ), ); @@ -925,7 +942,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed(context.friendlyError(e)), + ), + ), ); } } @@ -1131,7 +1152,10 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { } } else { if (mounted) { - final error = result['error']?.toString() ?? 'Unknown error'; + final error = context.friendlyError( + result['error'], + fallback: context.l10n.metadataSaveFailedFfmpeg, + ); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.trackSaveFailed(error))), ); @@ -1140,7 +1164,11 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState { } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed(context.friendlyError(e)), + ), + ), ); } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 8564e7cb..f205548b 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -31,6 +31,7 @@ import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/utils/user_facing_error.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/nav_bar_inset.dart'; import 'package:spotiflac_android/utils/re_enrich_release_policy.dart'; diff --git a/lib/utils/extension_auth_launcher.dart b/lib/utils/extension_auth_launcher.dart index 06662fb3..8583ba85 100644 --- a/lib/utils/extension_auth_launcher.dart +++ b/lib/utils/extension_auth_launcher.dart @@ -329,7 +329,11 @@ Future _completeSessionGrantFromClipboard( } catch (e) { if (!context.mounted) return; messenger?.showSnackBar( - SnackBar(content: Text('Verification callback failed: $e')), + SnackBar( + content: Text( + 'Verification callback failed: ${context.friendlyError(e)}', + ), + ), ); } } diff --git a/lib/utils/local_playback.dart b/lib/utils/local_playback.dart index 2ecd4528..8c3c57ea 100644 --- a/lib/utils/local_playback.dart +++ b/lib/utils/local_playback.dart @@ -22,9 +22,8 @@ Future playLocalIfAvailable( final historyNotifier = ref.read(downloadHistoryProvider.notifier); try { - DownloadHistoryItem? historyItem = await historyNotifier.getBySpotifyIdAsync( - track.id, - ); + DownloadHistoryItem? historyItem = await historyNotifier + .getBySpotifyIdAsync(track.id); final isrc = track.isrc?.trim(); historyItem ??= (isrc != null && isrc.isNotEmpty) ? await historyNotifier.getByIsrcAsync(isrc) @@ -74,7 +73,11 @@ Future playLocalIfAvailable( } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$e'))), + SnackBar( + content: Text( + context.l10n.snackbarCannotOpenFile(context.friendlyError(e)), + ), + ), ); } return true; diff --git a/lib/utils/user_facing_error.dart b/lib/utils/user_facing_error.dart new file mode 100644 index 00000000..8648778b --- /dev/null +++ b/lib/utils/user_facing_error.dart @@ -0,0 +1,188 @@ +import 'package:flutter/services.dart'; + +const defaultUserFacingErrorMessage = + 'The operation could not be completed. Please try again.'; + +/// Converts an internal exception or backend error into text that is safe to +/// render in the UI. The original error should still be sent to the app logger +/// before this function is called when diagnostic detail is needed. +String userFacingErrorMessage( + Object? error, { + String fallback = defaultUserFacingErrorMessage, +}) { + final safeFallback = fallback.trim().isEmpty + ? defaultUserFacingErrorMessage + : fallback.trim(); + + if (error == null) return safeFallback; + + if (error is PlatformException) { + final message = error.message?.trim(); + if (_isMeaningful(message)) { + return _sanitizeText(message!, fallback: safeFallback); + } + return _messageFromCode(error.code, fallback: safeFallback); + } + + return _sanitizeText(error.toString(), fallback: safeFallback); +} + +String _sanitizeText(String raw, {required String fallback}) { + var message = raw.trim(); + if (!_isMeaningful(message)) return fallback; + + message = _stripLeadingWrappers(message); + + final platformMatch = RegExp( + r'^PlatformException\(\s*([^,]+)\s*,\s*(.*)\)$', + caseSensitive: false, + dotAll: true, + ).firstMatch(message); + if (platformMatch != null) { + final code = platformMatch.group(1)?.trim() ?? ''; + final body = platformMatch.group(2)?.trim() ?? ''; + final parsedMessage = _platformMessage(body); + if (_isMeaningful(parsedMessage)) { + return _sanitizeText(parsedMessage!, fallback: fallback); + } + return _messageFromCode(code, fallback: fallback); + } + + final lower = message.toLowerCase(); + if (lower.contains('socketexception') || + lower.contains('failed host lookup') || + lower.contains('connection refused') || + lower.contains('network is unreachable')) { + return 'Unable to connect. Check your internet connection.'; + } + if (lower.contains('timeoutexception') || + lower.contains('timed out') || + lower.contains('timeout')) { + return 'The request timed out. Please try again.'; + } + if (lower.contains('missingpluginexception') || + lower.contains('no implementation found for method') || + lower.contains('channel-error') || + lower.contains('null check operator used on a null value') || + lower.contains('lateinitializationerror') || + lower.contains('nosuchmethoderror') || + lower.contains('is not a subtype of') || + lower.startsWith('instance of ')) { + return fallback; + } + + final stackStart = message.indexOf( + RegExp(r'\n\s*(?:#\d+|at\s+\S+|)'), + ); + if (stackStart >= 0) message = message.substring(0, stackStart); + + message = message + .replaceFirst(RegExp(r'\s*\(OS Error:.*$', dotAll: true), '') + .replaceFirst( + RegExp( + r',\s*(?:path|source|target)\s*=\s*.*$', + caseSensitive: false, + dotAll: true, + ), + '', + ) + .replaceFirst( + RegExp(r',\s*errno\s*=\s*\d+.*$', caseSensitive: false), + '', + ); + + message = _stripLeadingWrappers( + message, + ).replaceAll(RegExp(r'\s+'), ' ').trim(); + message = message.replaceFirst(RegExp(r'^[,;:\s]+'), '').trim(); + + if (!_isMeaningful(message) || + message.toLowerCase().contains('platformexception')) { + return fallback; + } + + const maxLength = 240; + if (message.length > maxLength) { + final shortened = message.substring(0, maxLength); + final lastSpace = shortened.lastIndexOf(' '); + message = + '${shortened.substring(0, lastSpace > 160 ? lastSpace : maxLength)}…'; + } + return message; +} + +String? _platformMessage(String body) { + final withDetailsAndStack = RegExp( + r'^(.*),\s*(?:null|\{.*\}|\[.*\]),\s*(?:null|.*)$', + caseSensitive: false, + dotAll: true, + ).firstMatch(body); + var candidate = withDetailsAndStack?.group(1)?.trim(); + + candidate ??= RegExp( + r'^(.*),\s*null\s*,\s*null$', + caseSensitive: false, + dotAll: true, + ).firstMatch(body)?.group(1)?.trim(); + candidate ??= RegExp( + r'^(.*),\s*null$', + caseSensitive: false, + dotAll: true, + ).firstMatch(body)?.group(1)?.trim(); + candidate ??= body.trim(); + + return _isMeaningful(candidate) ? candidate : null; +} + +String _messageFromCode(String code, {required String fallback}) { + var normalized = code.trim().replaceAll(RegExp(r'''^["']|["']$'''), ''); + final lower = normalized.toLowerCase().replaceAll('-', '_'); + const genericCodes = { + '', + 'error', + 'err', + 'failed', + 'failure', + 'unknown', + 'exception', + 'internal', + 'internal_error', + 'platform_error', + 'channel_error', + 'unavailable', + '-1', + }; + if (genericCodes.contains(lower) || RegExp(r'^\d+$').hasMatch(lower)) { + return fallback; + } + if (lower.contains('cancel')) return 'The operation was cancelled.'; + + normalized = lower + .replaceFirst(RegExp(r'^(?:error|err)_'), '') + .replaceAll(RegExp(r'[_-]+'), ' ') + .trim(); + if (normalized.isEmpty) return fallback; + return '${normalized[0].toUpperCase()}${normalized.substring(1)}'; +} + +String _stripLeadingWrappers(String value) { + var message = value.trim(); + final wrapper = RegExp( + r'^(?:Exception|Error|StateError|ArgumentError|FormatException|FileSystemException|HttpException|HandshakeException|Bad state|Invalid argument\(s\))\s*:\s*', + caseSensitive: false, + ); + while (wrapper.hasMatch(message)) { + message = message.replaceFirst(wrapper, '').trim(); + } + return message; +} + +bool _isMeaningful(String? value) { + if (value == null) return false; + final normalized = value.trim().toLowerCase(); + return normalized.isNotEmpty && + normalized != 'null' && + normalized != 'none' && + normalized != 'unknown' && + normalized != 'error'; +} diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 14fde45d..d2a1c8b3 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -584,7 +584,7 @@ class _AudioAnalysisCardState extends State { } catch (e) { if (mounted) { setState(() { - _error = e.toString(); + _error = context.friendlyError(e); _analyzing = false; }); } diff --git a/lib/widgets/error_card.dart b/lib/widgets/error_card.dart index 9a9d66d5..f5a46cad 100644 --- a/lib/widgets/error_card.dart +++ b/lib/widgets/error_card.dart @@ -11,6 +11,7 @@ class ErrorCard extends StatelessWidget { @override Widget build(BuildContext context) { + final displayError = context.friendlyError(error); final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || @@ -66,7 +67,10 @@ class ErrorCard extends StatelessWidget { Icon(Icons.error_outline, color: colorScheme.error), const SizedBox(width: 12), Expanded( - child: Text(error, style: TextStyle(color: colorScheme.error)), + child: Text( + displayError, + style: TextStyle(color: colorScheme.error), + ), ), ], ), diff --git a/test/user_facing_error_test.dart b/test/user_facing_error_test.dart new file mode 100644 index 00000000..c2e1eedb --- /dev/null +++ b/test/user_facing_error_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/utils/user_facing_error.dart'; + +void main() { + group('userFacingErrorMessage', () { + test('hides an empty generic PlatformException', () { + expect( + userFacingErrorMessage( + PlatformException(code: 'ERROR'), + fallback: 'Could not save metadata', + ), + 'Could not save metadata', + ); + expect( + userFacingErrorMessage( + 'PlatformException(ERROR, null, null, null)', + fallback: 'Could not save metadata', + ), + 'Could not save metadata', + ); + }); + + test('keeps a meaningful PlatformException message', () { + expect( + userFacingErrorMessage( + PlatformException( + code: 'write_failed', + message: 'The selected file is not writable', + ), + ), + 'The selected file is not writable', + ); + expect( + userFacingErrorMessage( + 'PlatformException(ERROR, The selected file is not writable, null, null)', + ), + 'The selected file is not writable', + ); + }); + + test('turns a meaningful platform code into readable text', () { + expect( + userFacingErrorMessage(PlatformException(code: 'permission_denied')), + 'Permission denied', + ); + }); + + test('removes exception wrappers, paths, and stack traces', () { + expect( + userFacingErrorMessage( + "Exception: FileSystemException: Cannot open file, path = '/secret/song.flac'\n#0 internalCall", + ), + 'Cannot open file', + ); + }); + + test('maps connectivity and internal implementation errors', () { + expect( + userFacingErrorMessage('SocketException: Failed host lookup: api.test'), + 'Unable to connect. Check your internet connection.', + ); + expect( + userFacingErrorMessage( + 'MissingPluginException(No implementation found for method save)', + fallback: 'Could not save metadata', + ), + 'Could not save metadata', + ); + }); + + test('preserves an ordinary backend error', () { + expect( + userFacingErrorMessage('Song not found on any service'), + 'Song not found on any service', + ); + }); + }); +}