diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart index 647472ec..b07493a2 100644 --- a/lib/screens/home_tab_recent.dart +++ b/lib/screens/home_tab_recent.dart @@ -24,7 +24,30 @@ extension _HomeTabRecentUI on _HomeTabState { ), if (uniqueItems.isNotEmpty) TextButton( - onPressed: () { + onPressed: () async { + // Clearing history is not undoable, so ask first. + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(dialogContext.l10n.dialogClearAll), + content: Text( + dialogContext.l10n.dialogClearHistoryMessage, + ), + actions: [ + TextButton( + onPressed: () => + Navigator.of(dialogContext).pop(false), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => + Navigator.of(dialogContext).pop(true), + child: Text(dialogContext.l10n.dialogClearAll), + ), + ], + ), + ); + if (confirmed != true) return; for (final id in downloadIds) { ref .read(recentAccessProvider.notifier) @@ -34,7 +57,9 @@ extension _HomeTabRecentUI on _HomeTabState { }, child: Text( context.l10n.dialogClearAll, - style: TextStyle(color: colorScheme.primary, fontSize: 12), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colorScheme.primary, + ), ), ), ], diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index bc08ccef..3c9d583f 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -9,16 +9,23 @@ extension _HomeTabSearchResultsUI on _HomeTabState { error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('too many requests'); final isUrlNotRecognized = error == 'url_not_recognized'; + // Re-runs the current query. Skipped for an unrecognized URL, where the + // outcome is deterministic and retrying would just fail again. + final retry = _urlController.text.trim().isEmpty + ? null + : () => _performSearch(_urlController.text.trim()); if (isRateLimit) { - return ErrorCard(error: error, colorScheme: colorScheme); + return ErrorCard(error: error, colorScheme: colorScheme, onRetry: retry); } if (isUrlNotRecognized) { return Card( elevation: 0, color: colorScheme.errorContainer.withValues(alpha: 0.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder( + borderRadius: context.tokens.borderRadiusCard, + ), child: Padding( padding: const EdgeInsets.all(16), child: Row( @@ -39,7 +46,9 @@ extension _HomeTabSearchResultsUI on _HomeTabState { const SizedBox(height: 4), Text( l10n.errorUrlNotRecognizedMessage, - style: TextStyle(color: colorScheme.error, fontSize: 12), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.error), ), ], ), @@ -50,7 +59,11 @@ extension _HomeTabSearchResultsUI on _HomeTabState { ); } - return ErrorCard(error: l10n.errorUrlFetchFailed, colorScheme: colorScheme); + return ErrorCard( + error: l10n.errorUrlFetchFailed, + colorScheme: colorScheme, + onRetry: retry, + ); } Widget _buildEmptySearchResultWidget(ColorScheme colorScheme) { @@ -124,9 +137,6 @@ extension _HomeTabSearchResultsUI on _HomeTabState { useRootNavigator: true, isScrollControlled: true, backgroundColor: colorScheme.surfaceContainerLow, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), builder: (ctx) => StatefulBuilder( builder: (ctx, setSheetState) { return SafeArea( @@ -136,17 +146,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), - ), + const AppSheetHandle(), Row( children: [ Text( diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index f83cd98d..c4d4dedf 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -839,26 +839,16 @@ extension _QueueTabFilterWidgets on _QueueTabState { icon = Icons.history; } - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 64, color: colorScheme.onSurfaceVariant), - const SizedBox(height: 16), - Text( - message, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - Text( - subtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), - ), - ], + return EmptyState( + icon: icon, + title: message, + message: subtitle, + // Every empty branch here means "you have not downloaded anything of + // this kind yet", so the way forward is always search. + action: FilledButton.icon( + onPressed: () => ShellNavigationService.requestTab(ShellTab.home), + icon: const Icon(Icons.search), + label: Text(context.l10n.navHome), ), ); } diff --git a/lib/widgets/error_card.dart b/lib/widgets/error_card.dart index f5a46cad..1eb91875 100644 --- a/lib/widgets/error_card.dart +++ b/lib/widgets/error_card.dart @@ -1,75 +1,89 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/theme/app_tokens.dart'; /// Error card shown by detail screens: a dedicated rate-limit presentation /// when the message looks like an HTTP 429, otherwise a generic error row. +/// +/// Pass [onRetry] so the card offers a way out; a message with no action leaves +/// the user stuck on a dead screen. class ErrorCard extends StatelessWidget { - const ErrorCard({super.key, required this.error, required this.colorScheme}); + const ErrorCard({ + super.key, + required this.error, + required this.colorScheme, + this.onRetry, + }); final String error; final ColorScheme colorScheme; + /// Re-runs whatever failed. Omit only when the failure is deterministic + /// (e.g. an unrecognized URL), where retrying cannot help. + final VoidCallback? onRetry; + @override Widget build(BuildContext context) { + final tokens = context.tokens; final displayError = context.friendlyError(error); final isRateLimit = error.contains('429') || error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('too many requests'); - if (isRateLimit) { - return Card( - elevation: 0, - color: colorScheme.errorContainer, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.timer_off, color: colorScheme.onErrorContainer), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.l10n.errorRateLimited, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - context.l10n.errorRateLimitedMessage, - style: TextStyle( - color: colorScheme.onErrorContainer, - fontSize: 12, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } + final title = isRateLimit ? context.l10n.errorRateLimited : null; + final message = isRateLimit + ? context.l10n.errorRateLimitedMessage + : displayError; + final icon = isRateLimit ? Icons.timer_off : Icons.error_outline; + final background = isRateLimit + ? colorScheme.errorContainer + : colorScheme.errorContainer.withValues(alpha: 0.5); + final foreground = isRateLimit + ? colorScheme.onErrorContainer + : colorScheme.error; return Card( elevation: 0, - color: colorScheme.errorContainer.withValues(alpha: 0.5), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: background, + shape: RoundedRectangleBorder(borderRadius: tokens.borderRadiusCard), child: Padding( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.all(tokens.gapLg), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.error_outline, color: colorScheme.error), - const SizedBox(width: 12), + Icon(icon, color: foreground), + SizedBox(width: tokens.gapMd), Expanded( - child: Text( - displayError, - style: TextStyle(color: colorScheme.error), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) ...[ + Text( + title, + style: TextStyle( + color: foreground, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: tokens.gapXs), + ], + Text( + message, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: foreground), + ), + if (onRetry != null) ...[ + SizedBox(height: tokens.gapMd), + FilledButton.tonalIcon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 18), + label: Text(context.l10n.dialogRetry), + ), + ], + ], ), ), ], @@ -78,3 +92,62 @@ class ErrorCard extends StatelessWidget { ); } } + +/// Empty-state block with an optional call to action. +/// +/// Screens used to render a bare icon plus a sentence, leaving the user with +/// nothing to tap. [action] is the way forward (search, pick a folder, install +/// an extension, ...). +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + this.message, + this.action, + }); + + final IconData icon; + final String title; + final String? message; + final Widget? action; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Center( + child: Padding( + padding: EdgeInsets.all(tokens.gapXl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 64, color: colorScheme.onSurfaceVariant), + SizedBox(height: tokens.gapLg), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (message != null) ...[ + SizedBox(height: tokens.gapSm), + Text( + message!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + if (action != null) ...[SizedBox(height: tokens.gapXl), action!], + ], + ), + ), + ); + } +}