diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index a1060e27..d0814ae4 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -7,6 +7,7 @@ import 'package:spotiflac_android/screens/track_history_snapshot.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index b35a563d..678865b2 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -65,8 +65,9 @@ extension _HomeTabSearchResultsUI on _HomeTabState { } if (isUrlNotRecognized) { - return Card( + return AppContentCard( elevation: 0, + mornyeSurface: false, color: colorScheme.errorContainer.withValues(alpha: 0.5), shape: RoundedRectangleBorder( borderRadius: context.tokens.borderRadiusCard, diff --git a/lib/screens/listening_statistics_screen.dart b/lib/screens/listening_statistics_screen.dart index d753adf0..5be27da1 100644 --- a/lib/screens/listening_statistics_screen.dart +++ b/lib/screens/listening_statistics_screen.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/widgets/animation_utils.dart' show SkeletonBox; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; import 'package:spotiflac_android/widgets/app_refresh_indicator.dart'; import 'package:spotiflac_android/widgets/app_shape.dart'; @@ -225,7 +226,8 @@ class _ListeningStatisticsScreenState children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Card( + child: AppContentCard( + mornyeSurface: false, color: mornye ? null : Theme.of(context).colorScheme.primaryContainer, @@ -333,7 +335,8 @@ class _ListeningStatisticsScreenState SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Card( + child: AppContentCard( + mornyeSurface: false, // Material leads with the total as an emphasized hero figure. color: mornye ? null : colors.primaryContainer, child: Padding( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index dfec2b78..60c21740 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/services/local_track_batch_actions.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/error_card.dart'; diff --git a/lib/screens/queue_tab_collection_items.dart b/lib/screens/queue_tab_collection_items.dart index de0fe7be..090e0d70 100644 --- a/lib/screens/queue_tab_collection_items.dart +++ b/lib/screens/queue_tab_collection_items.dart @@ -466,49 +466,47 @@ extension _QueueTabCollectionItemWidgets on _QueueTabState { ), ); - return Card( + return AppContentCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: InkWell( - onTap: onTap, - onLongPress: onLongPress, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - SizedBox(width: 56, height: 56, child: cover), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), + mornyeSurface: false, + onTap: onTap, + onLongPress: onLongPress, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + SizedBox(width: 56, height: 56, child: cover), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), - const SizedBox(height: 2), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - ], - ), + ), + ], ), - AppIcon( - Icons.chevron_right, - color: colorScheme.onSurfaceVariant, - size: 20, - ), - ], - ), + ), + AppIcon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: 20, + ), + ], ), ), ); diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 1d7b286f..628e2baa 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/widgets/donate_icons.dart'; @@ -125,8 +126,9 @@ class _MonthlyDonationGoalCard extends StatelessWidget { colorScheme.surface, ); - return Card( + return AppContentCard( elevation: 0, + mornyeSurface: false, color: cardColor, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Padding( @@ -260,8 +262,9 @@ class _DonateLinksCard extends StatelessWidget { colorScheme.surface, ); - return Card( + return AppContentCard( elevation: 0, + mornyeSurface: false, color: cardColor, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), clipBehavior: Clip.antiAlias, @@ -325,8 +328,9 @@ class _RecentSupportersCard extends StatelessWidget { final colorScheme = theme.colorScheme; final isDark = theme.brightness == Brightness.dark; - return Card( + return AppContentCard( elevation: 0, + mornyeSurface: false, color: Color.alphaBlend( (isDark ? Colors.white : Colors.black).withValues( alpha: isDark ? 0.08 : 0.04, @@ -423,8 +427,9 @@ class _DonateNoticeCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( + return AppContentCard( elevation: 0, + mornyeSurface: false, color: colorScheme.secondaryContainer.withValues(alpha: 0.3), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Padding( diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 8e63d4a6..a245006b 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; import 'package:flutter/services.dart'; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; @@ -732,8 +733,9 @@ class _LogSummaryCard extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Card( + child: AppContentCard( elevation: 0, + mornyeSurface: false, color: analysis.hasISPBlocking ? colorScheme.errorContainer.withValues(alpha: 0.5) : colorScheme.tertiaryContainer.withValues(alpha: 0.5), diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index baad9885..72a0cc13 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -7,6 +7,7 @@ import 'package:material_3_expressive/material_3_expressive.dart'; import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/material_expressive.dart'; import 'package:spotiflac_android/widgets/app_alert_dialog.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; @@ -1145,8 +1146,9 @@ class _SetupScreenState extends ConsumerState child: Column( children: [ if (_selectedDirectory != null) - Card( + AppContentCard( color: colorScheme.secondaryContainer, + mornyeSurface: false, child: ListTile( leading: Icon( Icons.folder, diff --git a/lib/screens/track_metadata_edit_sheet.dart b/lib/screens/track_metadata_edit_sheet.dart index 7018c645..364539ba 100644 --- a/lib/screens/track_metadata_edit_sheet.dart +++ b/lib/screens/track_metadata_edit_sheet.dart @@ -24,6 +24,7 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/app_action_button.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_progress_indicator.dart'; import 'package:spotiflac_android/widgets/app_icon_button.dart'; import 'package:spotiflac_android/widgets/app_loading_indicator.dart'; @@ -2019,7 +2020,7 @@ class _EditMetadataSheetState extends State { child: content, ), ) - : Card( + : AppContentCard( elevation: 0, margin: EdgeInsets.zero, color: settingsGroupColor(context), diff --git a/lib/widgets/app_content_card.dart b/lib/widgets/app_content_card.dart index 7ec886c0..d256a7bf 100644 --- a/lib/widgets/app_content_card.dart +++ b/lib/widgets/app_content_card.dart @@ -13,6 +13,10 @@ class AppContentCard extends StatelessWidget { this.elevation, this.clipBehavior = Clip.none, this.preserveColor = false, + this.margin, + this.onTap, + this.onLongPress, + this.mornyeSurface = true, }); final Widget child; @@ -22,51 +26,81 @@ class AppContentCard extends StatelessWidget { final Clip clipBehavior; final bool preserveColor; + /// Outer margin; Material defaults to its card theme, Mornye to zero. + final EdgeInsetsGeometry? margin; + + /// Makes the whole card pressable, with the expressive press feedback. + final VoidCallback? onTap; + final VoidCallback? onLongPress; + + /// False keeps a plain [Card] under Mornye for surfaces that carry their + /// own tinted color (errors, notices) rather than the Mornye control fill. + final bool mornyeSurface; + @override Widget build(BuildContext context) { - if (!context.isMornye) { - if (materialExpressiveEnabled(context) && + if (!context.isMornye || !mornyeSurface) { + if (!context.isMornye && + materialExpressiveEnabled(context) && (shape == null || shape is RoundedRectangleBorder)) { + final radius = shape is RoundedRectangleBorder + ? (shape as RoundedRectangleBorder).borderRadius.resolve( + Directionality.of(context), + ) + : BorderRadius.circular(20); return Padding( - padding: CardTheme.of(context).margin ?? const EdgeInsets.all(4), + padding: + margin ?? CardTheme.of(context).margin ?? const EdgeInsets.all(4), child: MaterialExpressiveScope( child: M3ECard( variant: M3ECardVariant.filled, padding: EdgeInsets.zero, color: color, elevation: elevation ?? 0, - border: shape is RoundedRectangleBorder + // BorderSide.none would still paint as a hairline. + border: + shape is RoundedRectangleBorder && + (shape as RoundedRectangleBorder).side.style != + BorderStyle.none ? (shape as RoundedRectangleBorder).side : null, - borderRadius: shape is RoundedRectangleBorder - ? (shape as RoundedRectangleBorder).borderRadius.resolve( - Directionality.of(context), - ) - : BorderRadius.circular(20), - clipBehavior: clipBehavior, + borderRadius: radius, + clipBehavior: onTap == null && onLongPress == null + ? clipBehavior + : Clip.antiAlias, + onPressed: onTap, + onLongPress: onLongPress, child: Material(color: Colors.transparent, child: child), ), ), ); } + final interactive = onTap != null || onLongPress != null; return Card( color: color, shape: shape, elevation: elevation, - clipBehavior: clipBehavior, - child: child, + margin: margin, + clipBehavior: interactive ? Clip.antiAlias : clipBehavior, + child: interactive + ? InkWell(onTap: onTap, onLongPress: onLongPress, child: child) + : child, ); } - return Material( + final content = DividerTheme( + data: DividerTheme.of( + context, + ).copyWith(color: MornyeTheme.metadataDividerColor(context)), + child: child, + ); + final surface = Material( color: preserveColor ? color : MornyeTheme.controlFill(context), borderRadius: BorderRadius.circular(28), clipBehavior: Clip.antiAlias, - child: DividerTheme( - data: DividerTheme.of( - context, - ).copyWith(color: MornyeTheme.metadataDividerColor(context)), - child: child, - ), + child: onTap != null || onLongPress != null + ? InkWell(onTap: onTap, onLongPress: onLongPress, child: content) + : content, ); + return margin == null ? surface : Padding(padding: margin!, child: surface); } } diff --git a/lib/widgets/error_card.dart b/lib/widgets/error_card.dart index d88cc15c..cc766bc8 100644 --- a/lib/widgets/error_card.dart +++ b/lib/widgets/error_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/app_shape.dart'; import 'package:spotiflac_android/widgets/expressive_button.dart'; @@ -47,9 +48,10 @@ class ErrorCard extends StatelessWidget { ? colorScheme.onErrorContainer : colorScheme.error; - return Card( + return AppContentCard( elevation: 0, color: background, + mornyeSurface: false, shape: RoundedRectangleBorder(borderRadius: tokens.borderRadiusCard), child: Padding( padding: EdgeInsets.all(tokens.gapLg), diff --git a/lib/widgets/extension_repo_card.dart b/lib/widgets/extension_repo_card.dart index 2f1a373d..d3df554d 100644 --- a/lib/widgets/extension_repo_card.dart +++ b/lib/widgets/extension_repo_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; /// Repo cards share the Mornye glass material, including their detail pages. @@ -22,7 +23,7 @@ class ExtensionRepoCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { if (!context.isMornye) { - return Card( + return AppContentCard( elevation: 0, color: color, margin: margin, diff --git a/lib/widgets/player_details_sheet.dart b/lib/widgets/player_details_sheet.dart index 2b5e6bf5..78fcfdcb 100644 --- a/lib/widgets/player_details_sheet.dart +++ b/lib/widgets/player_details_sheet.dart @@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_motion.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/isrc_utils.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/app_icon.dart'; import 'package:spotiflac_android/widgets/mornye_player_details_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; @@ -108,7 +109,7 @@ class _MetadataList extends StatelessWidget { controller: scrollController, padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), children: [ - Card( + AppContentCard( elevation: 0, color: settingsGroupColor(context), shape: RoundedRectangleBorder( diff --git a/lib/widgets/track_card.dart b/lib/widgets/track_card.dart index 07c895d3..3af95949 100644 --- a/lib/widgets/track_card.dart +++ b/lib/widgets/track_card.dart @@ -3,6 +3,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; /// How a [TrackCard] paints its container. @@ -145,11 +146,13 @@ class TrackCard extends StatelessWidget { } else if (style == TrackCardStyle.flat || context.isMornye) { cardColor = Colors.transparent; } else { - cardColor = null; // Inherits cardTheme.color. + // The card theme's tone, which the expressive card does not read. + cardColor = theme.cardTheme.color; } - final card = Card( + final card = AppContentCard( elevation: 0, + mornyeSurface: false, // The two styles carry their own outer margin so no call site has to // repeat it: flat rows sit tight inside a collection list, filled cards // get the Material list margin. @@ -165,63 +168,60 @@ class TrackCard extends StatelessWidget { color: cardColor, clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder(borderRadius: borderRadius), - child: InkWell( - onTap: onTap, - onLongPress: onLongPress, - borderRadius: borderRadius, - child: Stack( - children: [ - if (background != null) Positioned.fill(child: background!), - Padding( - padding: contentPadding, - child: Row( - children: [ - if (isSelectionMode) ...[ - Semantics( - checked: isSelected, - label: isSelected - ? context.l10n.a11yDeselectTrack - : context.l10n.a11ySelectTrack, - child: AnimatedSelectionCheckbox( - visible: true, - selected: isSelected, - colorScheme: colorScheme, - size: 24, - ), - ), - SizedBox(width: tokens.gapMd), - ], - leading, - SizedBox(width: tokens.gapMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - if (subtitle != null) ...[ - const SizedBox(height: 2), - subtitle!, - ], - ], + onTap: onTap, + onLongPress: onLongPress, + child: Stack( + children: [ + if (background != null) Positioned.fill(child: background!), + Padding( + padding: contentPadding, + child: Row( + children: [ + if (isSelectionMode) ...[ + Semantics( + checked: isSelected, + label: isSelected + ? context.l10n.a11yDeselectTrack + : context.l10n.a11ySelectTrack, + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), ), - if (trailing != null && !isSelectionMode) ...[ - SizedBox(width: tokens.gapSm), - trailing!, - ], + SizedBox(width: tokens.gapMd), ], - ), + leading, + SizedBox(width: tokens.gapMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + subtitle!, + ], + ], + ), + ), + if (trailing != null && !isSelectionMode) ...[ + SizedBox(width: tokens.gapSm), + trailing!, + ], + ], ), - ], - ), + ), + ], ), ); if (!context.isMornye) return card; diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 3b4fed33..f913fa1f 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; +import 'package:spotiflac_android/widgets/app_content_card.dart'; import 'package:spotiflac_android/widgets/album_detail_header.dart'; import 'package:spotiflac_android/widgets/app_search_field.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; @@ -717,7 +718,7 @@ void main() { ), ); - final card = tester.widget(find.byType(Card)); + final card = tester.widget(find.byType(AppContentCard)); expect(card.color, Colors.transparent); }); @@ -739,9 +740,9 @@ void main() { ), ); - final cardRect = tester.getRect(find.byType(Card)); + final cardRect = tester.getRect(find.byType(AppContentCard)); final trailingRect = tester.getRect(find.byKey(trailingKey)); - // Card's render box includes its 8dp flat-row margin; content itself is + // The card's render box includes its 8dp flat-row margin; content is // inset another 6dp from the painted card edge. expect(cardRect.right - trailingRect.right, 14); });