feat(ui): unify content cards and preserve press behavior

This commit is contained in:
zarzet committed 2026-10-15 15:59:38 +07:00
1 parent 8ff1490d9e
commit 33dca89f28
15 files changed
+182 -129

No files matched your search

+1
View File
@@ -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';
+2 -1
View File
@@ -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,
+5 -2
View File
@@ -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(
+1
View File
@@ -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';
+37 -39
View File
@@ -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,
),
],
),
),
);
+9 -4
View File
@@ -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(
+3 -1
View File
@@ -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),
+3 -1
View File
@@ -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<SetupScreen>
child: Column(
children: [
if (_selectedDirectory != null)
Card(
AppContentCard(
color: colorScheme.secondaryContainer,
mornyeSurface: false,
child: ListTile(
leading: Icon(
Icons.folder,
+2 -1
View File
@@ -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<EditMetadataSheet> {
child: content,
),
)
: Card(
: AppContentCard(
elevation: 0,
margin: EdgeInsets.zero,
color: settingsGroupColor(context),
+53 -19
View File
@@ -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);
}
}
+3 -1
View File
@@ -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),
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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(
+55 -55
View File
@@ -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;
+4 -3
View File
@@ -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<Card>(find.byType(Card));
final card = tester.widget<AppContentCard>(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);
});