diff --git a/app/README.md b/app/README.md
index 82dc0b497..50faee854 100644
--- a/app/README.md
+++ b/app/README.md
@@ -146,3 +146,13 @@ Routes include onboarding, home, learn, briefing, settings, debug, explore, libr
- [`:core:catalog` README](../core/catalog/README.md)
- [`:core:downloads` README](../core/downloads/README.md)
- [`:core:playback` README](../core/playback/README.md)
+
+- Launch branding uses the white B/microphone vector on electric purple (#573DF5) for the system splash and adaptive launcher icon. The foreground is inset for launcher masks and also supplies the themed monochrome icon.
+
+### Upright brand wordmarks
+
+- Notification small icons use the monochrome italic B-and-microphone app mark; launcher and splash branding remain italic.
+
+### Debug notification preview
+
+Debug builds include an ADB-only `TestNotificationReceiver`, protected by `android.permission.DUMP`. With notification permission enabled, run `adb shell am broadcast -n cx.aswin.boxlore/.debug.TestNotificationReceiver -a cx.aswin.boxlore.DEBUG_TEST_NOTIFICATION` to preview the app notification mark. The receiver and its channel strings are excluded from release builds.
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 000000000..9763b5c1a
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/debug/java/cx/aswin/boxlore/debug/TestNotificationReceiver.kt b/app/src/debug/java/cx/aswin/boxlore/debug/TestNotificationReceiver.kt
new file mode 100644
index 000000000..b0c7bb62e
--- /dev/null
+++ b/app/src/debug/java/cx/aswin/boxlore/debug/TestNotificationReceiver.kt
@@ -0,0 +1,49 @@
+package cx.aswin.boxlore.debug
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import cx.aswin.boxlore.R
+
+/** ADB-only notification preview; omitted from release builds. */
+class TestNotificationReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION) return
+ val manager = context.getSystemService(NotificationManager::class.java)
+ if (!manager.areNotificationsEnabled()) return
+ manager.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL,
+ context.getString(R.string.debug_notification_channel),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ ),
+ )
+ val notification =
+ NotificationCompat.Builder(context, CHANNEL)
+ .setSmallIcon(R.drawable.ic_notification_custom)
+ .setContentTitle(context.getString(R.string.debug_notification_title))
+ .setContentText(context.getString(R.string.debug_notification_body))
+ .setAutoCancel(true)
+ context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
+ notification.setContentIntent(
+ PendingIntent.getActivity(
+ context,
+ 0,
+ launch,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ ),
+ )
+ }
+ manager.notify(NOTIFICATION_ID, notification.build())
+ }
+
+ private companion object {
+ const val ACTION = "cx.aswin.boxlore.DEBUG_TEST_NOTIFICATION"
+ const val CHANNEL = "debug_notification_preview"
+ const val NOTIFICATION_ID = 90001
+ }
+}
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 000000000..4309c2be4
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+
+ Notification tests
+ Test notification
+ Testing the updated boxlore app icon.
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
index 862d05cf4..e5504d637 100644
--- a/app/src/main/res/drawable/ic_launcher_background.xml
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -1,21 +1,4 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
index 1c02944e8..b364e15cd 100644
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -1,5 +1,8 @@
-
-
-
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
index a067752bb..fb3831034 100644
--- a/app/src/main/res/drawable/ic_notification.xml
+++ b/app/src/main/res/drawable/ic_notification.xml
@@ -1,10 +1,2 @@
-
-
-
+
+
diff --git a/app/src/main/res/drawable/ic_notification_custom.png b/app/src/main/res/drawable/ic_notification_custom.png
deleted file mode 100644
index 9a185f51e..000000000
Binary files a/app/src/main/res/drawable/ic_notification_custom.png and /dev/null differ
diff --git a/app/src/main/res/drawable/ic_notification_custom.xml b/app/src/main/res/drawable/ic_notification_custom.xml
new file mode 100644
index 000000000..fb3831034
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification_custom.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/app/src/main/res/drawable/ic_splash_logo.xml b/app/src/main/res/drawable/ic_splash_logo.xml
index 93cb4b889..ea0bfece0 100644
--- a/app/src/main/res/drawable/ic_splash_logo.xml
+++ b/app/src/main/res/drawable/ic_splash_logo.xml
@@ -1,11 +1,8 @@
-
- -
-
-
-
+
+
+
+
+
diff --git a/app/src/main/res/values/brand_notifications.xml b/app/src/main/res/values/brand_notifications.xml
new file mode 100644
index 000000000..6c6ee25a1
--- /dev/null
+++ b/app/src/main/res/values/brand_notifications.xml
@@ -0,0 +1,4 @@
+
+
+ - @drawable/ic_notification_custom
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index d8ac26eff..46d5a16fd 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,6 +1,6 @@
- #5B5BD6
- #5B5BD6
+ #573DF5
+ #573DF5
#FFFFFF
diff --git a/app/src/main/res/values/ic_launcher_colors.xml b/app/src/main/res/values/ic_launcher_colors.xml
index a474ebe4e..41b091985 100644
--- a/app/src/main/res/values/ic_launcher_colors.xml
+++ b/app/src/main/res/values/ic_launcher_colors.xml
@@ -2,5 +2,5 @@
#FFFFFF
- #5B5BD6
+ #573DF5
diff --git a/core/designsystem/README.md b/core/designsystem/README.md
index 80decbf00..091ed3f5a 100644
--- a/core/designsystem/README.md
+++ b/core/designsystem/README.md
@@ -15,11 +15,16 @@ Owns shared Compose visual primitives: theme, typography, shapes, motion, loader
- `icon.GenreSuggestions`: Real-time keyword-driven suggestions and fuzzy ranking helpers (`GenreSuggestion`, `ALL_GENRE_SUGGESTIONS`, `filterGenreSuggestions`, `findSuggestedIcons`, `buildFolderSuggestionsWithLibrary`, `buildGenreSuggestionsWithFolders`) for genre tagging and folder creation with library prioritization.
- `icon.GenreExactMatchResolver`: Exact case-insensitive matching (`findExactGenreIconKey`) that auto-switches folder icons in real time as the user types matching genre names or topic keywords without waiting for manual chip or grid taps.
- `PredictiveBackWrapper` peeks the NavHost (scale 1.0 → 0.9) during system Back. Progress always returns to rest after commit or cancel so a Back that replaces the start destination (cold-start Subscriptions → Home) does not leave Home scaled down.
-- Shared discovery poster cards: `FeedMediaCard`, `CuratedEpisodeCard`, `EqualHeightPosterGrid`, and `FeedPosterSpacing` (Home “Based on Your Taste” and Explore For You).
+- Shared discovery cards: `FeedMediaCard`, `CuratedEpisodeCard`, `EqualHeightPosterGrid`, and `FeedPosterSpacing`. `FeedMediaCardPresentation.Default` retains the existing outlined presentation. Explicit `ExpressivePoster` and `ExpressiveFeatured` variants use native clickable cards with a three-line title-only foot that vertically centers shorter titles and grows with font scale. Posters use solid surface-container cards with 24dp corners, 14sp/20sp text, and restored 16dp lower artwork corners. Featured cards use a title-first split composition on solid tertiary-container: a 16sp/24sp three-line title and standalone 24dp forward arrow at the start, with artwork occupying 44% of the width (capped at 176dp) and running flush to the end edge. A sweeping 72dp artwork corner and asymmetric 28dp/12dp outer corners define the silhouette; the composition mirrors in RTL. The arrow belongs to the same native card action, 12dp below the reserved title area. Featured artwork omits promotional overlays. Their normal-text height is 152dp; enlarged text grows the layout. `FeedMediaCardSkeleton` shares the loaded layout and a measured three-line paragraph, covering nonlinear font scaling and keeping placeholders equal to loaded cards. `DiscoveryExpressiveTheme` scopes expressive Material motion while inheriting the existing color scheme, typography, shapes and appearance preferences; it does not change the application theme.
- `ProgressiveSearchScrollLogic` decides when a progressive Find-a-show list should pin to the top (query change, new top hit, or Matches header). Used by onboarding search and Explore Find-a-show; Ask anything does not use it.
- `LazyListKeyPolicy` provides safe deduplication and collision-free key generation for LazyColumn, LazyRow, and LazyGrid lists, preventing `IllegalArgumentException: Key was already used` crashes from duplicate backend feeds or search hits.
- `share.ShareManager` for composite share cards and the system share sheet; emits glossary `share_content` via `:core:analytics`. `ShareBottomSheet` presents a clear content preview, a switch-style timestamp option, a primary artwork share action, and separate link/story actions.
-- `share.ShareCardRenderer` builds the share-card bitmaps used by `ShareManager`. Stories separate episode/show details from a reduced branding block and retain the “listen now” prompt; square message artwork uses smaller branding without that prompt.
+- `share.ShareCardRenderer` builds the share-card bitmaps used by `ShareManager`. Stories and square artwork separate episode/show details from a compact “Listen on” label and upright boxlore wordmark.
+
+## Brand identity
+
+- boxlore classic uses the authored iris, slate and seafoam light/dark palette. A rich iris primary in light mode and luminous iris in dark mode lead the quieter slate and seafoam supporting accents. Wallpaper colours and custom accents retain their selection, with the updated classic neutral surfaces.
+- The top-bar wordmark uses the full-size plain B vector for readability at chrome sizes. LORE page/navigation marks and the boxlore brief serif masthead are tintable vectors from the new brand kit; UI typography remains Google Sans Flex. Share artwork follows the wordmark’s updated aspect ratio.
## Internal structure
@@ -76,6 +81,7 @@ Classic navigation applies its system insets outside the minimum 80dp content he
- `FloatingNavigationChromeTest` covers presentation clearance, root/query selection, fixed floating pill / player-slot geometry, Lore's fourth-slot placement, non-overlapping 48dp targets throughout the morph at supported widths, and safe progress clamping.
- `NavigationAccentColorsTest` covers every preset's authored primary in both modes, exact accent overrides with different container colors, and wallpaper-style palettes with unrelated secondary roles. `NavigationChromePlacementTest` covers Classic's 48dp button area, gesture/zero/larger insets, both styles at multiple densities, player/selector gaps, unchanged already-inset content padding, and constrained windows.
- `AdaptivePlayerOverlayOffsetTest` covers selector/Play All clearance, eased contraction-before-descent timing with zero slope at phase boundaries, reverse motion, Classic/no-player behavior, and invalid progress. `NavigationIndicatorMotionTest` covers the production indicator route/target logic and motion spec: direct Lore-to-Library movement, retained positions while hidden, immediate direction changes from the current position when interrupted, and position-linked Lore aurora blending with bounded/invalid inputs. `FloatingNavigationChromeTest` checks the single indicator path, external/compact Lore geometry, and matching mirrored bounds throughout the player morph. These are JVM logic checks without screenshots or device automation.
+- Home JVM `HomeDiscoveryCardTest` checks the expressive card/skeleton geometry at phone widths, enlarged fonts and RTL, centered short titles, three-line ellipsis, curved artwork, compact/regular rail sizing, legacy default sizing, and actual light/dark/dynamic/pure-black surface rendering. `HomeDiscoveryInteractionTest` checks title-only episode semantics and independent native click targets. No device automation or saved screenshots are required.
- Screenshot goldens (optional local Roborazzi) live in feature modules (see `:feature:home`).
```bash
@@ -92,3 +98,10 @@ Classic navigation applies its system insets outside the minimum 80dp content he
- [`ARCHITECTURE.md`](../../ARCHITECTURE.md)
- [`docs/TESTING.md`](../../docs/TESTING.md)
- [`:app` README](../../app/README.md)
+
+- The shared top-bar wordmark uses the plain B without a microphone, retaining its dimensions and theme tint.
+
+
+### Upright brand wordmarks
+
+- Extended brand wordmarks and the LORE navigation brain are upright. Share cards use the plain upright wordmark at 280px wide. Stories stack “Listen on” above it in a centred signature below the content, clear of bottom story controls. Square message cards place the label and wordmark in one centred footer row with a 96px bottom inset. Labels retain a 12px size when the full export is displayed at 360px wide; artwork and title remain the main focus.
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/component/BoxLoreNavigationBar.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/component/BoxLoreNavigationBar.kt
index 4a5d734a2..623bea1d1 100644
--- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/component/BoxLoreNavigationBar.kt
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/component/BoxLoreNavigationBar.kt
@@ -213,8 +213,8 @@ private val loreNavDestination =
label = "Lore",
selectedIcon = Icons.Filled.Psychology,
unselectedIcon = Icons.Outlined.Psychology,
- selectedIconRes = cx.aswin.boxlore.core.designsystem.R.drawable.ic_neurology_filled,
- unselectedIconRes = cx.aswin.boxlore.core.designsystem.R.drawable.ic_neurology,
+ selectedIconRes = cx.aswin.boxlore.core.designsystem.R.drawable.ic_lore_brand,
+ unselectedIconRes = cx.aswin.boxlore.core.designsystem.R.drawable.ic_lore_brand,
)
private val classicNavDestinations = primaryNavDestinations + loreNavDestination
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/BoxLoreLogo.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/BoxLoreLogo.kt
index e1293f202..06d256c97 100644
--- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/BoxLoreLogo.kt
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/BoxLoreLogo.kt
@@ -11,6 +11,8 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+const val BoxLoreLogoAspectRatio = 7.576829f
+
/**
* Reusable boxlore wordmark from the vector drawable.
*
@@ -25,6 +27,6 @@ fun BoxLoreLogo(modifier: Modifier = Modifier, textColor: Color = MaterialTheme.
modifier =
modifier
.height(height)
- .aspectRatio(805f / 110f),
+ .aspectRatio(BoxLoreLogoAspectRatio),
)
}
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/CuratedEpisodeCard.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/CuratedEpisodeCard.kt
index 991d4584f..99db4370c 100644
--- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/CuratedEpisodeCard.kt
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/CuratedEpisodeCard.kt
@@ -9,8 +9,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import cx.aswin.boxlore.core.designsystem.R
import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight
import cx.aswin.boxlore.core.model.Episode
import cx.aswin.boxlore.core.model.Podcast
@@ -25,6 +27,7 @@ fun CuratedEpisodeCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
showSubtitle: Boolean = true,
+ presentation: FeedMediaCardPresentation = FeedMediaCardPresentation.Default,
) {
val isNew =
episode.publishedDate > 0L &&
@@ -37,22 +40,31 @@ fun CuratedEpisodeCard(
onClick = onClick,
modifier = modifier,
titleMaxLines = 3,
+ presentation = presentation,
imageChrome = {
if (isNew) {
Box(
modifier =
Modifier
- .padding(6.dp)
+ .padding(if (presentation == FeedMediaCardPresentation.Default) 6.dp else 8.dp)
.clip(MaterialTheme.shapes.extraSmall)
.background(MaterialTheme.colorScheme.primary)
.align(Alignment.TopStart),
) {
Text(
- text = "NEW",
- style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp, lineHeight = 8.sp),
+ text = if (presentation == FeedMediaCardPresentation.Default) "NEW" else stringResource(R.string.feed_media_new),
+ style = if (presentation == FeedMediaCardPresentation.Default) {
+ MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp, lineHeight = 8.sp)
+ } else {
+ MaterialTheme.typography.labelSmall
+ },
fontWeight = GoogleSansWeight.bold,
color = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
+ modifier = if (presentation == FeedMediaCardPresentation.Default) {
+ Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
+ } else {
+ Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+ },
)
}
}
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/ExpressiveFeedMediaCard.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/ExpressiveFeedMediaCard.kt
new file mode 100644
index 000000000..6fe857cbc
--- /dev/null
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/ExpressiveFeedMediaCard.kt
@@ -0,0 +1,216 @@
+package cx.aswin.boxlore.core.designsystem.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowForward
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialExpressiveTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MotionScheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.clearAndSetSemantics
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.PlatformTextStyle
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.text.style.LineHeightStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun DiscoveryExpressiveTheme(content: @Composable () -> Unit) {
+ MaterialExpressiveTheme(
+ colorScheme = MaterialTheme.colorScheme,
+ typography = MaterialTheme.typography,
+ shapes = MaterialTheme.shapes,
+ motionScheme = MotionScheme.expressive(),
+ content = content,
+ )
+}
+
+@Composable
+internal fun ExpressiveFeedMediaCard(
+ imageUrl: String,
+ title: String,
+ onClick: () -> Unit,
+ presentation: FeedMediaCardPresentation,
+ modifier: Modifier,
+ imageChrome: @Composable (BoxScope.() -> Unit)?,
+) {
+ DiscoveryExpressiveTheme {
+ Card(
+ onClick = onClick,
+ shape = expressiveCardShape(presentation),
+ colors = expressiveCardColors(presentation),
+ modifier = modifier.semantics { role = Role.Button },
+ ) {
+ ExpressiveMediaLayout(
+ presentation = presentation,
+ artwork = { artworkModifier ->
+ Box(artworkModifier) {
+ OptimizedImage(
+ url = imageUrl,
+ proxyWidth = 400,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.matchParentSize(),
+ )
+ if (presentation != FeedMediaCardPresentation.ExpressiveFeatured) imageChrome?.invoke(this)
+ }
+ },
+ title = { titleModifier -> ExpressiveMediaTitle(title, titleModifier, presentation) },
+ openIndicator = { FeaturedOpenIndicator() },
+ )
+ }
+ }
+}
+
+/** Shares the real card's measurement, including the font-scaled three-line title. */
+@Composable
+fun FeedMediaCardSkeleton(
+ modifier: Modifier = Modifier,
+ presentation: FeedMediaCardPresentation = FeedMediaCardPresentation.ExpressivePoster,
+ placeholder: @Composable (Modifier) -> Unit,
+) {
+ Card(
+ modifier = modifier.clearAndSetSemantics { },
+ shape = expressiveCardShape(presentation),
+ colors = expressiveCardColors(presentation),
+ ) {
+ ExpressiveMediaLayout(
+ presentation = presentation,
+ artwork = placeholder,
+ title = { titleModifier ->
+ Box(titleModifier) {
+ ExpressiveMediaTitle("", Modifier.fillMaxWidth(), presentation)
+ val markerHeight = with(LocalDensity.current) { 12.sp.toDp() }
+ Column(Modifier.matchParentSize()) {
+ listOf(0.9f, 0.75f, 0.55f).forEach { fraction ->
+ Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.CenterStart) {
+ placeholder(Modifier.fillMaxWidth(fraction).height(markerHeight))
+ }
+ }
+ }
+ }
+ },
+ openIndicator = { placeholder(Modifier.size(24.dp)) },
+ )
+ }
+}
+
+@Composable
+private fun ExpressiveMediaTitle(title: String, modifier: Modifier, presentation: FeedMediaCardPresentation) {
+ val featured = presentation == FeedMediaCardPresentation.ExpressiveFeatured
+ val style = LocalTextStyle.current.merge(
+ MaterialTheme.typography.titleSmall.copy(
+ fontSize = if (featured) 16.sp else 14.sp,
+ lineHeight = if (featured) 24.sp else 20.sp,
+ platformStyle = PlatformTextStyle(includeFontPadding = false),
+ lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None),
+ ),
+ )
+ // Measure actual paragraphs: nonlinear font scaling and empty-string metrics
+ // make a dp/sp multiplication or Text(minLines) insufficient for matching feet.
+ val measuredTitle = rememberTextMeasurer().measure(AnnotatedString("Hg\nHg\nHg"), style = style, softWrap = false)
+ val titleHeight = with(LocalDensity.current) { measuredTitle.size.height.toDp() }
+ Box(modifier.height(titleHeight), contentAlignment = Alignment.CenterStart) {
+ Text(
+ text = title,
+ style = style,
+ color = LocalContentColor.current,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
+
+@Composable
+private fun ExpressiveMediaLayout(
+ presentation: FeedMediaCardPresentation,
+ artwork: @Composable (Modifier) -> Unit,
+ title: @Composable (Modifier) -> Unit,
+ openIndicator: @Composable () -> Unit,
+) {
+ if (presentation == FeedMediaCardPresentation.ExpressiveFeatured) {
+ BoxWithConstraints(Modifier.fillMaxWidth()) {
+ val artworkWidth = (maxWidth * 0.44f).coerceAtMost(176.dp)
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min).heightIn(min = 152.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ title(Modifier.fillMaxWidth())
+ openIndicator()
+ }
+ artwork(
+ Modifier.width(artworkWidth).fillMaxHeight()
+ .clip(RoundedCornerShape(topStart = 72.dp, topEnd = 0.dp, bottomStart = 16.dp, bottomEnd = 0.dp)),
+ )
+ }
+ }
+ } else {
+ Column {
+ artwork(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp)))
+ title(Modifier.fillMaxWidth().padding(12.dp))
+ }
+ }
+}
+
+private fun expressiveCardShape(presentation: FeedMediaCardPresentation) =
+ if (presentation == FeedMediaCardPresentation.ExpressiveFeatured) {
+ RoundedCornerShape(topStart = 28.dp, topEnd = 12.dp, bottomStart = 12.dp, bottomEnd = 28.dp)
+ } else {
+ RoundedCornerShape(24.dp)
+ }
+
+@Composable
+private fun expressiveCardColors(presentation: FeedMediaCardPresentation) =
+ if (presentation == FeedMediaCardPresentation.ExpressiveFeatured) {
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer,
+ contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
+ )
+ } else {
+ CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+@Composable
+private fun FeaturedOpenIndicator() {
+ Icon(Icons.AutoMirrored.Rounded.ArrowForward, contentDescription = null, modifier = Modifier.size(24.dp))
+}
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/FeedMediaCard.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/FeedMediaCard.kt
index 9be30ed62..0400f500e 100644
--- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/FeedMediaCard.kt
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/FeedMediaCard.kt
@@ -34,7 +34,15 @@ enum class FeedMediaCardDensity {
Grid,
}
+/** Explicit opt-in keeps existing discovery screens visually unchanged. */
+enum class FeedMediaCardPresentation {
+ Default,
+ ExpressivePoster,
+ ExpressiveFeatured,
+}
+
@Composable
+@Suppress("LongParameterList") // Keep the opt-in presentation alongside the existing source-compatible parameters.
fun FeedMediaCard(
imageUrl: String,
title: String,
@@ -42,8 +50,13 @@ fun FeedMediaCard(
onClick: () -> Unit,
modifier: Modifier = Modifier,
titleMaxLines: Int = 2,
+ presentation: FeedMediaCardPresentation = FeedMediaCardPresentation.Default,
imageChrome: @Composable (BoxScope.() -> Unit)? = null,
) {
+ if (presentation != FeedMediaCardPresentation.Default) {
+ ExpressiveFeedMediaCard(imageUrl, title, onClick, presentation, modifier, imageChrome)
+ return
+ }
val lines = titleMaxLines.coerceAtLeast(1)
val showSubtitle = !subtitle.isNullOrBlank()
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/share/ShareCardRenderer.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/share/ShareCardRenderer.kt
index f68e0c64d..f157df93b 100644
--- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/share/ShareCardRenderer.kt
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/share/ShareCardRenderer.kt
@@ -9,31 +9,30 @@ internal data class ShareBrandingLayout(
val contentGap: Float,
val labelSize: Float,
val logoWidth: Int,
- val listenNowGap: Float,
- val listenNowSize: Float,
- val showListenNow: Boolean,
+ val inline: Boolean,
)
internal fun shareBrandingLayout(isStory: Boolean): ShareBrandingLayout = if (isStory) {
ShareBrandingLayout(
- contentGap = 112f,
- labelSize = 28f,
- logoWidth = 400,
- listenNowGap = 18f,
- listenNowSize = 28f,
- showListenNow = true,
+ contentGap = 80f,
+ labelSize = 36f,
+ logoWidth = 280,
+ inline = false,
)
} else {
ShareBrandingLayout(
- contentGap = 64f,
- labelSize = 24f,
- logoWidth = 340,
- listenNowGap = 0f,
- listenNowSize = 0f,
- showListenNow = false,
+ contentGap = 48f,
+ labelSize = 40f,
+ logoWidth = 280,
+ inline = true,
)
}
+internal fun shareBrandingTop(isStory: Boolean, contentBottom: Float, brandingHeight: Float): Float {
+ val preferredTop = if (isStory) 1_440f else 1_200f - 96f - brandingHeight
+ return maxOf(preferredTop, contentBottom + shareBrandingLayout(isStory).contentGap)
+}
+
/** Bitmap card composition for [ShareManager] share sheets / Instagram stories. */
internal object ShareCardRenderer {
fun createShareCard(
@@ -96,12 +95,9 @@ internal object ShareCardRenderer {
context = context,
canvas = canvas,
canvasWidth = width,
- brandingTop = textBottom + brandingLayout.contentGap,
- brandingLabelSize = brandingLayout.labelSize,
- logoWidth = brandingLayout.logoWidth,
- listenNowGap = brandingLayout.listenNowGap,
- listenNowSize = brandingLayout.listenNowSize,
- showListenNow = brandingLayout.showListenNow,
+ contentBottom = textBottom,
+ isStory = true,
+ layout = brandingLayout,
)
} else {
val textBottom =
@@ -121,12 +117,9 @@ internal object ShareCardRenderer {
context = context,
canvas = canvas,
canvasWidth = width,
- brandingTop = textBottom + brandingLayout.contentGap,
- brandingLabelSize = brandingLayout.labelSize,
- logoWidth = brandingLayout.logoWidth,
- listenNowGap = brandingLayout.listenNowGap,
- listenNowSize = brandingLayout.listenNowSize,
- showListenNow = brandingLayout.showListenNow,
+ contentBottom = textBottom,
+ isStory = false,
+ layout = brandingLayout,
)
}
@@ -206,61 +199,35 @@ internal object ShareCardRenderer {
context: Context,
canvas: android.graphics.Canvas,
canvasWidth: Int,
- brandingTop: Float,
- brandingLabelSize: Float,
- logoWidth: Int,
- listenNowGap: Float,
- listenNowSize: Float,
- showListenNow: Boolean,
+ contentBottom: Float,
+ isStory: Boolean,
+ layout: ShareBrandingLayout,
) {
- val brandingLeft = (canvasWidth - logoWidth) / 2f
- val brandingRight = brandingLeft + logoWidth
val textPaint =
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.WHITE
- textSize = brandingLabelSize
- typeface = googleSansTypeface(context, android.graphics.Typeface.BOLD)
+ textSize = layout.labelSize
+ typeface = googleSansTypeface(context, android.graphics.Typeface.NORMAL)
+ textAlign = if (layout.inline) android.graphics.Paint.Align.LEFT else android.graphics.Paint.Align.CENTER
}
- val label = "is better on"
- val labelBaseline = brandingTop - textPaint.fontMetrics.ascent
- canvas.drawText(label, brandingLeft, labelBaseline, textPaint)
+ val label = context.getString(cx.aswin.boxlore.core.designsystem.R.string.share_listen_on)
+ val labelHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent
+ val logoHeight = layout.logoWidth / cx.aswin.boxlore.core.designsystem.components.BoxLoreLogoAspectRatio
+ val brandingHeight = if (layout.inline) maxOf(labelHeight, logoHeight) else labelHeight + 12f + logoHeight
+ val brandingTop = shareBrandingTop(isStory, contentBottom, brandingHeight)
+ val labelWidth = textPaint.measureText(label)
+ val rowLeft = (canvasWidth - labelWidth - 18f - layout.logoWidth) / 2f
+ val labelX = if (layout.inline) rowLeft else canvasWidth / 2f
+ val labelTop = if (layout.inline) brandingTop + (brandingHeight - labelHeight) / 2f else brandingTop
+ canvas.drawText(label, labelX, labelTop - textPaint.fontMetrics.ascent, textPaint)
- val waveStart = brandingLeft + textPaint.measureText(label) + 20f
- val waveCenter =
- brandingTop + (
- textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent
- ) / 2f
- if (brandingRight > waveStart) {
- val path = android.graphics.Path()
- val amplitude = 9f
- val wavelength = 72f
- var x = waveStart
- path.moveTo(x, waveCenter)
- while (x < brandingRight) {
- x = (x + 3f).coerceAtMost(brandingRight)
- val y =
- waveCenter + amplitude *
- kotlin.math.sin(
- ((x - waveStart) / wavelength) * 2f * kotlin.math.PI.toFloat(),
- )
- path.lineTo(x, y)
+ val logoTop =
+ if (layout.inline) {
+ brandingTop + (brandingHeight - logoHeight) / 2f
+ } else {
+ brandingTop + labelHeight + 12f
}
- canvas.drawPath(
- path,
- android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
- color = android.graphics.Color.WHITE
- style = android.graphics.Paint.Style.STROKE
- strokeWidth = 4f
- strokeCap = android.graphics.Paint.Cap.ROUND
- strokeJoin = android.graphics.Paint.Join.ROUND
- },
- )
- }
-
- val labelHeight = textPaint.fontMetrics.descent - textPaint.fontMetrics.ascent
- val logoTop = (brandingTop + labelHeight + 18f).toInt()
- val logoHeight = (logoWidth * 110f / 805f).toInt()
- val logoLeft = (canvasWidth - logoWidth) / 2
+ val logoLeft = if (layout.inline) rowLeft + labelWidth + 18f else (canvasWidth - layout.logoWidth) / 2f
androidx.core.content.ContextCompat
.getDrawable(
context,
@@ -269,27 +236,13 @@ internal object ShareCardRenderer {
?.apply {
setTint(android.graphics.Color.WHITE)
setBounds(
- logoLeft,
- logoTop,
- logoLeft + logoWidth,
- logoTop + logoHeight,
+ logoLeft.toInt(),
+ logoTop.toInt(),
+ (logoLeft + layout.logoWidth).toInt(),
+ (logoTop + logoHeight).toInt(),
)
draw(canvas)
}
- if (!showListenNow) return
-
- val listenNowTop = logoTop + logoHeight + listenNowGap
- drawCenteredTextBlock(
- canvas = canvas,
- canvasWidth = canvasWidth,
- text = "listen now",
- top = listenNowTop,
- width = logoWidth,
- textSize = listenNowSize,
- maxLines = 1,
- color = android.graphics.Color.parseColor("#DCDCF8"),
- typeface = googleSansTypeface(context, android.graphics.Typeface.NORMAL),
- )
}
private fun drawShareText(
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/ClassicBrandColors.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/ClassicBrandColors.kt
new file mode 100644
index 000000000..0103e8436
--- /dev/null
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/ClassicBrandColors.kt
@@ -0,0 +1,85 @@
+package cx.aswin.boxlore.core.designsystem.theme
+
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.ui.graphics.Color
+
+/** Authored classic palette from the boxlore brand kit. */
+internal object ClassicBrandColors {
+ val light = lightColorScheme(
+ primary = Color(0xFF5145D8),
+ onPrimary = Color(0xFFFFFFFF),
+ primaryContainer = Color(0xFFE5E0FF),
+ onPrimaryContainer = Color(0xFF261D58),
+ secondary = Color(0xFF55647C),
+ onSecondary = Color(0xFFFFFFFF),
+ secondaryContainer = Color(0xFFDFE6F2),
+ onSecondaryContainer = Color(0xFF202D42),
+ tertiary = Color(0xFF397B6D),
+ onTertiary = Color(0xFFFFFFFF),
+ tertiaryContainer = Color(0xFFCDEDE1),
+ onTertiaryContainer = Color(0xFF123C32),
+ error = Color(0xFFB3261E),
+ onError = Color(0xFFFFFFFF),
+ errorContainer = Color(0xFFF9DEDC),
+ onErrorContainer = Color(0xFF410E0B),
+ background = Color(0xFFF8F8FB),
+ onBackground = Color(0xFF1A1B23),
+ surface = Color(0xFFF8F8FB),
+ onSurface = Color(0xFF1A1B23),
+ surfaceVariant = Color(0xFFE5E3EE),
+ onSurfaceVariant = Color(0xFF494854),
+ outline = Color(0xFF797783),
+ outlineVariant = Color(0xFFCBC7D4),
+ inverseSurface = Color(0xFF2E3038),
+ inverseOnSurface = Color(0xFFF1F0F6),
+ inversePrimary = Color(0xFF9C8CFF),
+ surfaceTint = Color(0xFF5145D8),
+ scrim = Color(0xFF000000),
+ surfaceDim = Color(0xFFDDDDE5),
+ surfaceBright = Color(0xFFFAFAFE),
+ surfaceContainerLowest = Color(0xFFFFFFFF),
+ surfaceContainerLow = Color(0xFFF2F2F7),
+ surfaceContainer = Color(0xFFEDEDF4),
+ surfaceContainerHigh = Color(0xFFE7E7F0),
+ surfaceContainerHighest = Color(0xFFE0E0EA),
+ )
+ val dark = darkColorScheme(
+ primary = Color(0xFF9C8CFF),
+ onPrimary = Color(0xFF21164B),
+ primaryContainer = Color(0xFF3F347B),
+ onPrimaryContainer = Color(0xFFECE7FF),
+ secondary = Color(0xFFA3B1CA),
+ onSecondary = Color(0xFF253248),
+ secondaryContainer = Color(0xFF354258),
+ onSecondaryContainer = Color(0xFFE2E8F3),
+ tertiary = Color(0xFF89C6B5),
+ onTertiary = Color(0xFF12392F),
+ tertiaryContainer = Color(0xFF274C41),
+ onTertiaryContainer = Color(0xFFD0EFE3),
+ error = Color(0xFFF2B8B5),
+ onError = Color(0xFF601410),
+ errorContainer = Color(0xFF8C1D18),
+ onErrorContainer = Color(0xFFF9DEDC),
+ background = Color(0xFF121318),
+ onBackground = Color(0xFFE6E6EE),
+ surface = Color(0xFF121318),
+ onSurface = Color(0xFFE6E6EE),
+ surfaceVariant = Color(0xFF33333F),
+ onSurfaceVariant = Color(0xFFC8C5D3),
+ outline = Color(0xFF938F9F),
+ outlineVariant = Color(0xFF494652),
+ inverseSurface = Color(0xFFE6E6EE),
+ inverseOnSurface = Color(0xFF2E3038),
+ inversePrimary = Color(0xFF5145D8),
+ surfaceTint = Color(0xFF9C8CFF),
+ scrim = Color(0xFF000000),
+ surfaceDim = Color(0xFF121318),
+ surfaceBright = Color(0xFF373740),
+ surfaceContainerLowest = Color(0xFF0E0F13),
+ surfaceContainerLow = Color(0xFF1A1B22),
+ surfaceContainer = Color(0xFF20212A),
+ surfaceContainerHigh = Color(0xFF272832),
+ surfaceContainerHighest = Color(0xFF32333D),
+ )
+}
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/Theme.kt
index 21b04231b..26233b82f 100644
--- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/Theme.kt
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/theme/Theme.kt
@@ -52,7 +52,7 @@ object SurfaceStyles {
/** Display labels for the settings UI. */
val entries = listOf(
- Entry(CLASSIC_DYNAMIC, "boxlore classic", "Gentle gray when dark, warm cream when light."),
+ Entry(CLASSIC_DYNAMIC, "boxlore classic", "Rich iris and seafoam on cool neutral surfaces."),
Entry(STANDARD, "Material You Soft", "Colored surfaces from your wallpaper or accent."),
Entry(CLASSIC_DARK, "Blackish", "Soft dark gray · stays dark."),
Entry(CLASSIC_LIGHT, "Whitish", "Soft cream · stays light."),
@@ -114,6 +114,9 @@ fun resolveBoxLoreColorScheme(
/** Shared fixed-colour resolution for app chrome, widgets and Appearance previews. */
fun resolveFixedThemeColorScheme(themeBrand: String, darkTheme: Boolean, surfaceStyle: String): ColorScheme {
val effectiveDarkTheme = computeEffectiveDarkTheme(surfaceStyle, darkTheme)
+ if (themeBrand == "violet" && isClassicSurface(surfaceStyle)) {
+ return if (effectiveDarkTheme) ClassicBrandColors.dark else ClassicBrandColors.light
+ }
val seed = resolveThemeSeedColor(themeBrand)
val scheme = generateBrandColorScheme(seed, effectiveDarkTheme, surfaceStyle)
val preset = findThemePreset(themeBrand)
@@ -175,43 +178,30 @@ fun BoxLoreTheme(
/**
* Applies surface style overrides to an existing ColorScheme (used for dynamic color schemes).
*/
-private fun applyClassicOverrides(base: ColorScheme, isDark: Boolean): ColorScheme = if (isDark) {
- base.copy(
- background = Color(0xFF111316),
- surface = Color(0xFF111316),
- surfaceContainerLowest = Color(0xFF111316),
- surfaceContainerLow = Color(0xFF181B20),
- surfaceContainer = Color(0xFF181B20),
- surfaceContainerHigh = Color(0xFF21252B),
- surfaceContainerHighest = Color(0xFF21252B),
- onBackground = Color(0xFFE8EAED),
- onSurface = Color(0xFFE8EAED),
- surfaceVariant = Color(0xFF181B20),
- onSurfaceVariant = Color(0xFFA4AAB2),
- outline = Color(0xFF2C313A),
- outlineVariant = Color(0xFF2C313A),
- tertiary = Color(0xFF4FB587),
- tertiaryContainer = Color(0xFF1E352A),
- onTertiaryContainer = Color(0xFF4FB587)
- )
-} else {
- base.copy(
- background = Color(0xFFF0EBE0),
- surface = Color(0xFFF0EBE0),
- surfaceContainerLowest = Color(0xFFFFFFFF),
- surfaceContainerLow = Color(0xFFFBF8F1),
- surfaceContainer = Color(0xFFFBF8F1),
- surfaceContainerHigh = Color(0xFFFFFFFF),
- surfaceContainerHighest = Color(0xFFFFFFFF),
- onBackground = Color(0xFF211D15),
- onSurface = Color(0xFF211D15),
- surfaceVariant = Color(0xFFFBF8F1),
- onSurfaceVariant = Color(0xFF5F5A50),
- outline = Color(0xFFE6DFCF),
- outlineVariant = Color(0xFFE6DFCF),
- tertiary = Color(0xFF3E9B6E),
- tertiaryContainer = Color(0xFFE8F5EE),
- onTertiaryContainer = Color(0xFF3E9B6E)
+private fun isClassicSurface(style: String): Boolean = style == SurfaceStyles.CLASSIC_DYNAMIC ||
+ style == SurfaceStyles.CLASSIC_DARK ||
+ style == SurfaceStyles.CLASSIC_LIGHT
+
+private fun applyClassicOverrides(base: ColorScheme, isDark: Boolean): ColorScheme {
+ val colors = if (isDark) ClassicBrandColors.dark else ClassicBrandColors.light
+ return base.copy(
+ background = colors.background,
+ onBackground = colors.onBackground,
+ surface = colors.surface,
+ onSurface = colors.onSurface,
+ surfaceVariant = colors.surfaceVariant,
+ onSurfaceVariant = colors.onSurfaceVariant,
+ outline = colors.outline,
+ outlineVariant = colors.outlineVariant,
+ surfaceDim = colors.surfaceDim,
+ surfaceBright = colors.surfaceBright,
+ surfaceContainerLowest = colors.surfaceContainerLowest,
+ surfaceContainerLow = colors.surfaceContainerLow,
+ surfaceContainer = colors.surfaceContainer,
+ surfaceContainerHigh = colors.surfaceContainerHigh,
+ surfaceContainerHighest = colors.surfaceContainerHighest,
+ inverseSurface = colors.inverseSurface,
+ inverseOnSurface = colors.inverseOnSurface,
)
}
@@ -382,7 +372,8 @@ fun generateBrandColorScheme(seedColor: Color, isDark: Boolean, surfaceStyle: St
surfaceContainerHighest = surfaceLevels.containerHighest
)
}
- return findThemePreset(surfaceStyle)?.let { scheme.withPresetBackground(it, isDark) } ?: scheme
+ val styled = if (isClassicSurface(surfaceStyle)) applyClassicOverrides(scheme, isDark) else scheme
+ return findThemePreset(surfaceStyle)?.let { styled.withPresetBackground(it, isDark) } ?: styled
}
/**
diff --git a/core/designsystem/src/main/res/drawable/ic_boxlore_brief_logo.png b/core/designsystem/src/main/res/drawable/ic_boxlore_brief_logo.png
deleted file mode 100644
index b1c71e545..000000000
Binary files a/core/designsystem/src/main/res/drawable/ic_boxlore_brief_logo.png and /dev/null differ
diff --git a/core/designsystem/src/main/res/drawable/ic_boxlore_brief_logo.xml b/core/designsystem/src/main/res/drawable/ic_boxlore_brief_logo.xml
new file mode 100644
index 000000000..0e8c4b926
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_boxlore_brief_logo.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_boxlore_logo.xml b/core/designsystem/src/main/res/drawable/ic_boxlore_logo.xml
index 4064eb6ad..5be77c0d1 100644
--- a/core/designsystem/src/main/res/drawable/ic_boxlore_logo.xml
+++ b/core/designsystem/src/main/res/drawable/ic_boxlore_logo.xml
@@ -1,1583 +1,2 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
diff --git a/core/designsystem/src/main/res/drawable/ic_lore_brand.xml b/core/designsystem/src/main/res/drawable/ic_lore_brand.xml
new file mode 100644
index 000000000..ceaf6322e
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/ic_lore_brand.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/core/designsystem/src/main/res/drawable/logo_lore.png b/core/designsystem/src/main/res/drawable/logo_lore.png
deleted file mode 100644
index 59e1981e1..000000000
Binary files a/core/designsystem/src/main/res/drawable/logo_lore.png and /dev/null differ
diff --git a/core/designsystem/src/main/res/drawable/logo_lore.xml b/core/designsystem/src/main/res/drawable/logo_lore.xml
new file mode 100644
index 000000000..2d8ecafaa
--- /dev/null
+++ b/core/designsystem/src/main/res/drawable/logo_lore.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/core/designsystem/src/main/res/values/strings.xml b/core/designsystem/src/main/res/values/strings.xml
index 4128aebb4..554d68391 100644
--- a/core/designsystem/src/main/res/values/strings.xml
+++ b/core/designsystem/src/main/res/values/strings.xml
@@ -3,4 +3,6 @@
Close
Library repair in progress
Please keep boxlore open until this finishes.
+ New
+ Listen on
diff --git a/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/share/ShareCardLayoutTest.kt b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/share/ShareCardLayoutTest.kt
index 59bb47ec9..e2426eef6 100644
--- a/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/share/ShareCardLayoutTest.kt
+++ b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/share/ShareCardLayoutTest.kt
@@ -1,27 +1,43 @@
package cx.aswin.boxlore.core.designsystem.share
+import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class ShareCardLayoutTest {
@Test
- fun storyBrandingIsSeparatedAndKeepsListenPrompt() {
+ fun brandingLabelRemainsReadableWhenSharedImageFitsAPhone() {
+ val story = shareBrandingLayout(isStory = true)
+ val message = shareBrandingLayout(isStory = false)
+
+ assertTrue(story.labelSize * 360f / 1_080f >= 12f)
+ assertTrue(message.labelSize * 360f / 1_200f >= 12f)
+ }
+
+ @Test
+ fun storySignatureStaysClearOfContentAndBottomStoryControls() {
val layout = shareBrandingLayout(isStory = true)
+ val signatureHeight = 96f
- assertTrue(layout.contentGap >= 110f)
- assertTrue(layout.labelSize < 34f)
- assertTrue(layout.logoWidth < 480)
- assertTrue(layout.showListenNow)
+ assertFalse(layout.inline)
+ for (contentBottom in listOf(1_200f, 1_300f, 1_380f)) {
+ val top = shareBrandingTop(true, contentBottom, signatureHeight)
+ assertTrue(top >= contentBottom + layout.contentGap)
+ assertTrue(top + signatureHeight <= 1_600f)
+ }
}
@Test
- fun messageBrandingIsCompactAndOmitsListenPrompt() {
+ fun messageFooterKeepsItsBottomInsetForShortAndTwoLineTitles() {
val layout = shareBrandingLayout(isStory = false)
+ val footerHeight = 48f
- assertTrue(layout.contentGap >= 60f)
- assertTrue(layout.labelSize < 28f)
- assertTrue(layout.logoWidth < 400)
- assertFalse(layout.showListenNow)
+ assertTrue(layout.inline)
+ for (contentBottom in listOf(900f, 980f, 1_008f)) {
+ val top = shareBrandingTop(false, contentBottom, footerHeight)
+ assertTrue(top >= contentBottom + layout.contentGap)
+ assertEquals(96f, 1_200f - top - footerHeight)
+ }
}
}
diff --git a/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/theme/ClassicBrandColorsTest.kt b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/theme/ClassicBrandColorsTest.kt
new file mode 100644
index 000000000..4eef4bad7
--- /dev/null
+++ b/core/designsystem/src/test/java/cx/aswin/boxlore/core/designsystem/theme/ClassicBrandColorsTest.kt
@@ -0,0 +1,57 @@
+package cx.aswin.boxlore.core.designsystem.theme
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class ClassicBrandColorsTest {
+ @Test
+ fun `classic resolves authored light and dark accents`() {
+ val light = resolveFixedThemeColorScheme("violet", false, SurfaceStyles.CLASSIC_DYNAMIC)
+ val dark = resolveFixedThemeColorScheme("violet", true, SurfaceStyles.CLASSIC_DYNAMIC)
+ assertEquals(Color(0xFF5145D8), light.primary)
+ assertEquals(Color(0xFF55647C), light.secondary)
+ assertEquals(Color(0xFF397B6D), light.tertiary)
+ assertEquals(Color(0xFF9C8CFF), dark.primary)
+ assertEquals(Color(0xFFA3B1CA), dark.secondary)
+ assertEquals(Color(0xFF89C6B5), dark.tertiary)
+ }
+
+ @Test
+ fun `accent refinement retains classic backgrounds and surface levels`() {
+ assertEquals(Color(0xFFF8F8FB), ClassicBrandColors.light.background)
+ assertEquals(Color(0xFFF8F8FB), ClassicBrandColors.light.surface)
+ assertEquals(Color(0xFFEDEDF4), ClassicBrandColors.light.surfaceContainer)
+ assertEquals(Color(0xFF121318), ClassicBrandColors.dark.background)
+ assertEquals(Color(0xFF121318), ClassicBrandColors.dark.surface)
+ assertEquals(Color(0xFF20212A), ClassicBrandColors.dark.surfaceContainer)
+ }
+
+ @Test
+ fun `legacy classic mode locks still select the matching palette`() {
+ assertEquals(ClassicBrandColors.light, resolveFixedThemeColorScheme("violet", true, SurfaceStyles.CLASSIC_LIGHT))
+ assertEquals(ClassicBrandColors.dark, resolveFixedThemeColorScheme("violet", false, SurfaceStyles.CLASSIC_DARK))
+ }
+
+ @Test
+ fun `classic text remains readable on accents containers and surfaces`() {
+ listOf(ClassicBrandColors.light, ClassicBrandColors.dark).forEach { scheme ->
+ listOf(
+ scheme.primary to scheme.onPrimary,
+ scheme.secondary to scheme.onSecondary,
+ scheme.tertiary to scheme.onTertiary,
+ scheme.primaryContainer to scheme.onPrimaryContainer,
+ scheme.secondaryContainer to scheme.onSecondaryContainer,
+ scheme.tertiaryContainer to scheme.onTertiaryContainer,
+ scheme.surface to scheme.onSurface,
+ scheme.surfaceContainerHighest to scheme.onSurfaceVariant,
+ ).forEach { (background, foreground) ->
+ val a = background.luminance()
+ val b = foreground.luminance()
+ assertTrue((maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f) >= 4.5f)
+ }
+ }
+ }
+}
diff --git a/feature/home/README.md b/feature/home/README.md
index f1f70b38b..e6525f128 100644
--- a/feature/home/README.md
+++ b/feature/home/README.md
@@ -10,11 +10,12 @@ Owns Home feed presentation, Daily and Offline mixes, and local debug surfaces f
- The Home feed import library banner uses inbound download iconography (`Icons.Rounded.Download`) directing the user into the shell-managed library import overlay.
- The selected Your Shows header uses `SelectedShowTitle`: a small RSS icon beside the single-line show title, vertically centered with a fixed gap. Long titles truncate before the icon, and the header reserves spacing before the show-info arrow. The icon has a localized accessibility description and appears for RSS source metadata or a canonical `rss:` identity. JVM layout tests resolve the description from its string resource and cover narrow headers, enlarged text and RTL without screenshots or device automation.
- `TopControlBar` uses the shared `TopBarUtilityActions` capsule for Feedback and Settings, with matching icons and separate accessible touch targets. Existing header placement/collapse, click and long-press actions, and the Cleaner Home shortcut preference remain screen-owned.
-- Because You Like identifies its seed with an expressive editorial stamp: a solid primary-container surface with the same visible `ExpressiveShapes` language as image fallbacks, subtly tilted artwork, overlapping heart badge, and borderless swap icon. Tapping the rest of the selector opens that show.
+- Because You Like preserves its original seed artwork and controls: an extra-large solid secondary-container surface with an 88dp minimum height, a 56dp tilted cover in a 64dp slot, and a 24dp circular on-secondary-container badge containing the shared 14dp `mood_heart_24` icon. Its body decorations use the refined asymmetric placement: a 120dp tertiary burst peeking from the upper end at 10% opacity and a 104dp tertiary cookie peeking from the lower body at 12%. Both are clipped to the opaque card, stay clear of the artwork and emoji, and mirror in RTL without randomizing on recomposition. **Because you like** uses sentence case and the native 14sp/18sp semibold labelLarge style with 0.1sp tracking and on-secondary-container text, giving it a clearer introduction above the show name. The titleMedium two-line show title, 40dp native swap button, emoji badge, original expressive click feedback and surrounding spacing retain their Git presentation. The label and **Change show for recommendations** accessibility description use string resources. Its show-opening and automatic/manual seed selection callbacks are unchanged; the **Similar shows** and **Episodes to try** rails remain below it, using standalone rounded podcast and headphone icons respectively.
- Because You Like anchor show selection rotates automatically across three time-of-day slots (Morning, Afternoon, Night) based on an engagement-threshold candidate pool (subscriptions and listening history) and day-part genre affinity (`BecauseYouLikeRotationLogic`). It introduces day-to-day variation so the same show is not repeatedly selected at the same slot across consecutive days when pool strength permits, and caches slot keys in `BoxcastPrefs` to limit server calls to at most 3 per day. Manual user show overrides in `ChangeRecommendationPodcastSheet` remain strictly sticky until reset to auto-detect.
-- Because You Like and daypart editorial rails size cards to 41% of the available viewport, keeping two cards complete while exposing enough of card three to signal horizontal scrolling. Video Spotlight and the two-column vertical grids keep their own sizing. Because You Like rails protect show and episode lists against duplicate key collisions using `LazyListKeyPolicy`.
-- Home Explore genre row (`GenreSelector`) uses shared `PillFilterChip` icon+label pills (same language as onboarding search) with Top + top genres + **More** opening the existing Browse Genres bottom sheet.
-- Immediately above Explore, Home presents an editorial **Video Spotlight**: TED Talks Daily is the editor’s pick and 13 verified feeds follow in stable popularity order. TED exposes dedicated **HD** and **SD** feed buttons while the card itself opens HD. A borderless, artwork-first horizontal reel sits directly on the page, keeps the next tile visibly peeking at the edge, and states the format once in its header instead of repeating catalog labels. Catalog metadata is bundled and artwork remains lazy Coil content, so the section performs no launch-time API work. Dismiss asks for confirmation and persists `featured_video_showcase_dismissed`; once confirmed, it never returns to Home. Analytics records the section impression and each card/HD/SD target, then carries `home_video_spotlight` through Podcast Info so play taps and confirmed `playback_started` events remain attributable to the spotlight funnel.
+- Lower Home discovery explicitly opts into the shared `ExpressivePoster` / `ExpressiveFeatured` card family. Media cards reserve three title-only lines with shorter titles vertically centered (episode title or show name), with font-scaled 14sp/20sp text and curved lower artwork corners. Because You Like rails use 42% of the viewport capped at 176dp; daypart rails use 52% capped at 208dp, both with 16dp gaps. On phone widths the second daypart card is visibly clipped at the viewport edge, making horizontal scrolling clear without extra labels; show-based rails retain their smaller proportions. Loaded and loading daypart rails share the same sizing. Grids retain their layout and counts. Major sections and related rails use measured 32dp / 20dp gaps including parent-grid spacing; headings inside rail/genre groups use 12dp before content, while separate lazy heading items retain the parent grid's 16dp gap. Existing content keys and `LazyListKeyPolicy` protections are unchanged. Your Shows, hero, app bar and briefing are outside this presentation change.
+- Show-based discovery opens directly with its **Because you like** seed card and related rails; the duplicate **More like this** heading and its browse control are removed. **Picked for you** contains the featured episode and recommendation grid, the existing daypart greeting groups its mood rails, and **Explore shows** keeps a stable title above genre selection and the show grid, without secondary header text. Regional fallback recommendations are named **Popular in your region**, including when the seed group is absent. Major headings use wrapping headlineMedium text and 48dp native browse actions: distinct native Material shapes for each chapter and standard native press feedback. Child rails pair their titleMedium heading with a standalone 28dp icon in the primary or tertiary accent, placed 10dp from the title and mirrored in RTL. No decorative shape sits behind these icons; a 36dp minimum header height keeps the feed rhythm stable. Sections remain open, without dividers or section-wide containers. Remaining browse destinations, analytics contexts, eligibility, item order and caps are unchanged.
+- Home Explore genre row (`GenreSelector`) uses native solid Material FilterChips with selected semantics and 48dp touch targets; Top + top genres + **More** still open/select through the existing Browse Genres bottom sheet. Shared onboarding/Explore pills retain their defaults.
+- Video Spotlight is removed from the Home presentation. Time-based discovery now leads directly into Explore; the catalog, stored dismissal preference, and playback/ranking logic are unchanged. The first personal recommendation uses a title-first split featured card: a solid tertiary-container title panel with 16sp/24sp three-line text and a standalone forward arrow, alongside artwork flush to the end edge. The artwork takes 44% of the width (up to 176dp) with a sweeping 72dp corner; asymmetric 28dp/12dp outer corners and RTL mirroring give this card a distinct silhouette. The whole card retains the existing episode-opening action, including the artwork and arrow, and omits promotional overlays. It is 152dp tall at normal text size and grows with accessible text; its skeleton shares the same layout.
- Hero carousel grid cards (**Jump back in** / **New episodes**) use title-only cells with a lighter scrim: resume taps play (progress + now-playing ring on the matching episode, not every cell from that show), new-episodes taps open episode info. NEW on that grid (and Your Shows covers via `isLatestEpisodeNew`) shares Room `rssHasNewEpisodes` for true-RSS and PI direct-feed tips, plus the usual 48h window.
- The Your Shows mix module keeps **Daily Mix** as the default and makes its heading a Daily/Offline dropdown only with at least two subscriptions and one downloaded episode that is not playback-completed. The listener's last selection persists in `home_mix_mode`; direction-aware title/rail transitions distinguish mode changes without starting playback. Offline includes every download provenance but filters completed listens, orders the rest by newest release, caps the Home rail and queue to 15 episodes, and links its trailing card to Library Downloads. The module remains borderless on regular surfaces, adding a subtle outline only for Pure, Pitch black, and Pure white styles where low containers merge into the page background. The separate pill-shaped header play control queues the selected mix with `PlaybackEntryPoint.HOME_MIXTAPE`.
- `DebugScreen` and `DebugViewModel` for local learner and runtime diagnostics.
@@ -35,7 +36,6 @@ src/main/java/cx/aswin/boxlore/feature/home/
HomeViewModelSelected.kt
HomeViewModelSerial.kt
HomeFeedEditorialRows.kt
- HomeFeedFeaturedVideos.kt
HomeDataModels.kt
HomeUiModels.kt
DebugScreen.kt
@@ -43,7 +43,6 @@ src/main/java/cx/aswin/boxlore/feature/home/
DebugViewModel.kt
AdaptiveLearnerDebugSection.kt
components/
- FeaturedVideoPodcastsShowcase.kt
LibrarySection.kt
LibrarySectionRows.kt
...
@@ -54,7 +53,6 @@ src/main/java/cx/aswin/boxlore/feature/home/
```
Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel, section-row, and logic files keep UI assembly and behavior testable.
-- `FeaturedVideoPodcastsShowcase`: Curated video showcase with cohesive touch-press spring scaling, solid surface backgrounds, and independent HD/SD action targets.
## Dependencies
@@ -66,13 +64,15 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel
- The two-row Your Shows skeleton draws five cover chips in each row, including while the subscription count is unknown. `YourShowsSkeletonTest` verifies that all ten covers are visible for unknown and known two-row counts.
- Home shares one linear, draw-only `ShimmerScope`, paused when loading sections are outside the visible grid viewport after the 180ms handoff; loading/content sections retain matching keys. The sweep starts with a visible highlight and uses 8%/18% surface contrast so short cached waits still show movement. Discovery and For You poster grids are emitted as lazy two-card rows instead of eagerly composing an entire grid.
-- Poster skeleton feet reserve the same three title lines as real cards. `HomeMixLayout` shares the loaded and placeholder mix dimensions (212dp). Hero and Your Shows draw loaded content fully opaque underneath an opaque loading cover, then fade only that cover for 180ms. The same shimmer clock continues through the handoff. The cover uses `matchParentSize`, so it does not participate in sizing the loaded section, and is disposed after the fade. A stalled animation keeps the cover/content visible rather than exposing a blank frame. Cached-ready sections skip the cover. Mix cards appear immediately in their reserved rail; mode switches retain the whole-rail/title animation without another entrance animation on every card; empty finished sections stop showing skeletons.
+- Discovery poster and featured skeletons share the loaded card layout and font-scaled three-line title measurement; the first recommendation uses a compact horizontal featured card. `HomeMixLayout` shares the loaded and placeholder mix dimensions (324dp). Hero and Your Shows draw loaded content fully opaque underneath an opaque loading cover, then fade only that cover for 180ms. The same shimmer clock continues through the handoff. The cover uses `matchParentSize`, so it does not participate in sizing the loaded section, and is disposed after the fade. A stalled animation keeps the cover/content visible rather than exposing a blank frame. Cached-ready sections skip the cover. Mix cards appear immediately in their reserved rail; mode switches retain the whole-rail/title animation without another entrance animation on every card; empty finished sections stop showing skeletons.
- Subscription selector covers animate placement only, without insertion/removal fades that can hide artwork on an early frame. Same-region bootstrap reloads seed trending from the current snapshot and keep matching editorial rows during refresh; completed responses still replace the snapshot. Discovery cards remain visible while refreshing, and base Home publication cannot overwrite the independently selected category result/loading state.
- The bundled briefing JPEG loads through Coil with a bounded request rather than synchronous `painterResource` decoding.
+- The briefing card uses a larger 72 dp white masthead and a plain, left-aligned date beneath it. A stronger header scrim keeps the dateline readable over artwork; reduced artwork spacing offsets the larger logo without expanding the card at normal text size. Date formatting and briefing controls retain their behavior.
- Recommendation cache parsing, serialization and scoring run on Default. Discovery rankings reuse the observed history snapshot and are recomputed only when candidates, history or subscription IDs change; the cache resets with region/language/daypart collectors.
- `HomeCacheRestoreGate` lets the initial local recommendation / Because You Like caches and fallback flag finish restoring before the first assembled Home snapshot or recommendation refresh. Slow cache reads cannot produce a second startup insertion or overwrite a fresh API result. Empty, malformed, or cancelled restoration releases waiters; API refreshes and their cache writes continue normally afterward. This gate waits only for local storage, never for network completion.
- Oldest-first serial lookup waits for usable Home content and shares a two-request limit. Cancellation propagates through recommendation/serial work.
- Regression coverage: `HomeLoadingRevealTest` (opaque cover/content handoff with a frozen animation clock, draw-only cover fade and disposal, immediate cached content), `HomeArtworkHandoffTest` (transparent successful artwork retains its backing, new subscription covers are immediately visible), `HomeRefreshPresentationLogicTest` (category result isolation and refresh visibility), `HomeCacheRestoreGateTest` (complete initial snapshot, delayed-cache/fresh-response ordering, empty/error/cancel handling), `HomeShimmerVisibilityTest` (visible loading sections and offscreen pause), `HomeDiscoveryRankingCacheTest` (including refreshed payloads with unchanged IDs and fresh empty responses), `HomeSerialResolutionLimiterTest`, `HomeLoadingGeometryTest`, `HomeShimmerAnimationTest` (JVM pixel changes during initial loading, a visible moving band during a short cached wait, visible navigation entry before STARTED, loading restart, movement late in the sweep, seam-free tall blocks, and unchanged composition/drawing-cache counts across animation frames), and the local/remote readiness cases in `HomeUiAssemblyLogicTest`.
+- Discovery presentation regressions: `HomeDiscoveryHeaderTest` covers narrow phone openings, 2× text, RTL, standalone leading rail icons, heading semantics, four appearance modes, native press stability and genre selection without secondary header text; `HomeDiscoveryFeedSpacingTest` covers compact group spacing, the absence of a duplicate seed heading, fallback/absent-data names, keyed loading geometry, recommendation order/caps and existing browse callbacks. `HomeDiscoveryCardTest` and `HomeDiscoveryInteractionTest` cover shared three-line card geometry, split featured geometry and curved join, artwork curves, the restored seed/emoji presentation, theme surfaces and independent media/seed/genre actions. These are JVM tests; no device automation is required.
## Threading / lifecycle
@@ -97,7 +97,7 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel
discovery greeting, editorial-row selection and de-duplication, equal-height poster grid
extent math, and Your Shows pin precedence, foreground refresh cadence, and score hysteresis
(`HomeShowsOrderLogicTest`), plus other pure Home logic helpers.
-- Video podcast editorial showcase catalog order, uniqueness, and TED Talks HD/SD feed routing (`FeaturedVideoPodcastsTest`).
+- Video podcast editorial showcase catalog order, uniqueness, and TED Talks HD/SD feed routing (`FeaturedVideoPodcastsTest`). `HomeDiscoveryInteractionTest` uses native Android graphics to test pointer routing through expressive shape clipping, verifies native episode/featured/seed targets invoke callbacks once, and checks selected genre semantics. `HomeDiscoveryCardTest` verifies phone-width and font-scaled card/skeleton geometry, original seed label/title/action separation and title sizing, soft theme-toned clipped body decorations and a clear artwork/emoji lane with long names, enlarged text and RTL, centered short titles, ellipsis, restored artwork curves, distinct compact rail sizing, a partial second time-rail card at phone widths with enlarged text and RTL, legacy default sizing, and light/dark/dynamic/pure-black surfaces without saved screenshots. `HomeDiscoveryFeedSpacingTest` measures the assembled feed's related and major gaps with optional recommendations, absence of Video Spotlight, and matching loaded/loading time-rail positions.
- Time-of-day rotation slots, candidate thresholding, genre matching, and cross-day variation logic (`BecauseYouLikeRotationLogicTest`).
```bash
@@ -115,3 +115,33 @@ Main Kotlin files should remain below 1000 lines; extracted Home feed, ViewModel
- [`docs/TESTING.md`](../../docs/TESTING.md)
- [`docs/screenshots/README.md`](../../docs/screenshots/README.md)
- [`:app` README](../../app/README.md)
+
+- Time-based editorial subheader icons and their loading placeholders consistently use the primary accent.
+- The main time-of-day heading has a decorative 32 dp primary-coloured mark: sunrise, full sun, sunset or crescent/star. Its one-second reveal waits until the entire header is inside the clipped scroll viewport; an interrupted reveal restarts on the next full entry. Afterward, the sun rays turn through 28 degrees and back over 4.8 seconds. At night two stars twinkle in alternating brightness and size over 2.8 seconds; the second is smaller, and the crescent stays stationary. Ambient motion runs only while the whole header is visible and the screen lifecycle is STARTED, and follows Android's duration scale. Completed reveals survive recomposition and lazy-list re-entry. Drawing reads the animation clock without recomposing text; the greeting remains the accessible heading, and browsing/curation behavior is unchanged.
+
+### Home accent hierarchy
+
+- Primary: navigation, selected filters, browse actions and editorial header icons. Browse shapes vary by chapter; their colour role stays consistent.
+- Secondary: supporting recommendation context (the Because you like seed card), with on-secondary-container text.
+- Tertiary: featured discovery cards and subtle expressive decorations. Both seed decorations use the same tertiary role.
+- Surface containers: ordinary podcast cards and unselected controls.
+
+- Related recommendation headers use a paired cover motif: overlapping covers for Similar shows and a tilted episode cover with a play cutout for Episodes to try. Both retain the primary header accent.
+
+
+- Daily/offline mix uses 176 × 224 dp cover cards with compact artwork above two-line episode titles, consistent 24 dp corners and 48 dp playback buttons at the artwork’s lower edge. Neutral card surfaces give currently playing episodes a primary-container highlight. Shared loading geometry tracks the taller rail and header.
+
+- Your shows uses the Home heading weight and the same outlined Bookmarks library icon as the navigation bar. Its library browse action shares the 48 dp primary-container button with other Home chapter headers. Each chapter has a distinct native Material shape: the broad diagonal Pill for Your shows, Cookie4Sided for Picked for you, Sunny for time-of-day discovery, and Cookie6Sided for Explore. Native press feedback retains each silhouette; destinations and accessibility labels are unchanged.
+
+- Subscription selectors request 320 px artwork, selected-show thumbnails 256 px and mix cards 512 px to avoid enlarging small thumbnails on dense displays.
+
+- Mix cards put episode title first, followed by show and duration. They omit date/new badges and show at most one artwork status badge to keep the compact card uncluttered.
+
+- Mix artwork resolves episode art → episode-carried show art → parent show art → fallback, skipping blank candidates. JVM regression tests cover missing episode art and whitespace.
+
+- Duration/time-left appears in a small solid secondary-container chip on the artwork. Remaining whole minutes include the hours portion and show at least 1m for a sub-minute remainder; `MixTimeTest` covers both regressions. The remaining-time label and library browse accessibility description use string resources. Podcast names use label-small beneath the episode title.
+
+- Mix-card title/show blocks centre vertically in the space below artwork. In-progress cards have a 4 dp inset track with primary fill and a solid secondary-container track. Cards without progress reserve no track space, so text centres in the full area beneath artwork.
+
+- An empty Daily Mix collapses to its heading and caught-up status, without a disabled play button or reserved card rail. The mode dropdown remains available when offline episodes exist. Only entries containing an episode count as playable mix content.
+- Nonempty Daily/Offline mix modules have two faint, cropped Material shapes drawn behind the entire module at 4–5% tertiary opacity. Cached outlines mirror in RTL and stay within the module bounds. Empty modules have no decoration; episode cards and module geometry are unchanged.
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt
index 2fc880c08..120615883 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeed.kt
@@ -92,7 +92,6 @@ internal fun PodcastFeed(
loadingState: PodcastFeedLoadingState,
playback: PodcastFeedPlayback,
callbacks: HomeFeedCallbacks,
- featuredVideos: HomeFeaturedVideoState,
layout: PodcastFeedLayout,
) {
val context = LocalContext.current
@@ -130,7 +129,6 @@ internal fun PodcastFeed(
curatedForYouItems(content, feedState, recommendationState, playback, callbacks, derivedState)
discoveryGreetingItem(feedState, callbacks)
editorialFeedItems(content, feedState, loadingState, callbacks)
- featuredVideoPodcastsItem(featuredVideos, callbacks)
discoverFeedItems(feedState, derivedState, callbacks)
}
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedEditorialRows.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedEditorialRows.kt
index 3584ea53e..e4fafd611 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedEditorialRows.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedEditorialRows.kt
@@ -4,7 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
@@ -14,9 +14,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cx.aswin.boxlore.core.analytics.AnalyticsHelper
import cx.aswin.boxlore.core.designsystem.components.CuratedEpisodeCard
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation
import cx.aswin.boxlore.feature.home.components.EditorialRowSkeleton
import cx.aswin.boxlore.feature.home.components.HomeChildHeaderTone
import cx.aswin.boxlore.feature.home.components.HomeChildSectionHeader
+import cx.aswin.boxlore.feature.home.components.HomeDiscoveryRail
import cx.aswin.boxlore.feature.home.components.HomeFeedSpacing
import cx.aswin.boxlore.feature.home.logic.editorialRowDefinitionsFor
@@ -34,7 +36,12 @@ internal fun LazyStaggeredGridScope.editorialFeedItems(
key = "editorial_${definition.providerId}",
contentType = "editorial_row",
) {
- EditorialRowSkeleton(isLast = index == definitions.lastIndex)
+ EditorialRowSkeleton(
+ title = definition.title,
+ icon = definition.icon.toHomeEditorialIcon(),
+ isLast = index == definitions.lastIndex,
+ tone = HomeChildHeaderTone.PRIMARY,
+ )
}
}
}
@@ -46,13 +53,8 @@ internal fun LazyStaggeredGridScope.editorialFeedItems(
) {
EditorialRow(
row = row,
- tone =
- if (index % 2 == 0) {
- HomeChildHeaderTone.PRIMARY
- } else {
- HomeChildHeaderTone.TERTIARY
- },
isLast = index == content.editorialRows.list.lastIndex,
+ tone = HomeChildHeaderTone.PRIMARY,
callbacks = callbacks,
)
}
@@ -62,8 +64,8 @@ internal fun LazyStaggeredGridScope.editorialFeedItems(
@Composable
private fun EditorialRow(
row: HomeEditorialRow,
- tone: HomeChildHeaderTone,
isLast: Boolean,
+ tone: HomeChildHeaderTone,
callbacks: HomeFeedCallbacks,
) {
LaunchedEffect(row.providerId) {
@@ -76,17 +78,15 @@ private fun EditorialRow(
modifier =
Modifier
.fillMaxWidth()
- .padding(bottom = if (isLast) 20.dp else 12.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
+ .padding(bottom = if (isLast) 0.dp else HomeFeedSpacing.RelatedRailGap - HomeFeedSpacing.GridGap),
+ verticalArrangement = Arrangement.spacedBy(HomeFeedSpacing.HeaderContentGap),
) {
HomeChildSectionHeader(
title = row.title,
icon = row.icon.toHomeEditorialIcon(),
tone = tone,
)
- LazyRow(
- horizontalArrangement = Arrangement.spacedBy(HomeFeedSpacing.RailItemGap),
- ) {
+ HomeDiscoveryRail { cardWidth ->
itemsIndexed(
items = row.podcasts,
key = { _, podcast ->
@@ -116,7 +116,8 @@ private fun EditorialRow(
)
},
showSubtitle = false,
- modifier = Modifier.fillParentMaxWidth(HomeFeedSpacing.RAIL_CARD_WIDTH_FRACTION),
+ modifier = Modifier.width(cardWidth),
+ presentation = FeedMediaCardPresentation.ExpressivePoster,
)
}
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedFeaturedVideos.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedFeaturedVideos.kt
deleted file mode 100644
index b3272b87f..000000000
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedFeaturedVideos.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package cx.aswin.boxlore.feature.home
-
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
-import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import cx.aswin.boxlore.core.analytics.AnalyticsGlossary
-import cx.aswin.boxlore.core.analytics.AnalyticsHelper
-import cx.aswin.boxlore.feature.home.components.FeaturedVideoPodcastsShowcase
-import cx.aswin.boxlore.feature.home.logic.featuredTedTalksSdPodcast
-
-internal fun LazyStaggeredGridScope.featuredVideoPodcastsItem(
- state: HomeFeaturedVideoState,
- callbacks: HomeFeedCallbacks,
-) {
- if (state.isDismissed || state.podcasts.list.isEmpty()) return
- item(
- span = StaggeredGridItemSpan.FullLine,
- key = "featured_video_podcasts",
- contentType = "featured_video_podcasts",
- ) {
- FeaturedVideoPodcastsShowcase(
- podcasts = state.podcasts.list,
- tedTalksSdPodcast = featuredTedTalksSdPodcast(),
- onPodcastClick = { podcast, index, clickTarget ->
- AnalyticsHelper.trackVideoSpotlightPodcastTapped(
- podcastId = podcast.id,
- podcastName = podcast.title,
- positionIndex = index,
- clickTarget = clickTarget,
- )
- callbacks.onPodcastClick(
- podcast,
- AnalyticsGlossary.VIDEO_SPOTLIGHT_ENTRY_POINT,
- "Video Spotlight",
- index,
- )
- },
- onDismissForever = callbacks.onDismissFeaturedVideoShowcaseForever,
- modifier = Modifier.padding(bottom = 8.dp),
- )
- }
-}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedRecommendations.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedRecommendations.kt
index 68ff0e821..40ee7cfd7 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedRecommendations.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeFeedRecommendations.kt
@@ -3,30 +3,31 @@ package cx.aswin.boxlore.feature.home
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.AutoAwesome
-import androidx.compose.material.icons.rounded.ChevronRight
-import androidx.compose.material.icons.rounded.NightsStay
-import androidx.compose.material.icons.rounded.WbSunny
-import androidx.compose.material.icons.rounded.WbTwilight
+import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import cx.aswin.boxlore.core.catalog.content.ContentDaypart
+import cx.aswin.boxlore.core.designsystem.components.DiscoveryExpressiveTheme
import cx.aswin.boxlore.core.designsystem.components.EqualHeightPosterGrid
import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardDensity
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation
import cx.aswin.boxlore.core.model.PlaybackEntryPoint
import cx.aswin.boxlore.feature.home.components.BecauseYouLikeSection
-import cx.aswin.boxlore.feature.home.components.GridSkeletonItem
+import cx.aswin.boxlore.feature.home.components.HomeDiscoveryChapter
import cx.aswin.boxlore.feature.home.components.HomeFeedSpacing
+import cx.aswin.boxlore.feature.home.components.HomeMediaCardSkeleton
+import cx.aswin.boxlore.feature.home.components.HomePersonalRecommendationsHeader
import cx.aswin.boxlore.feature.home.components.HomeTopLevelSectionHeader
import cx.aswin.boxlore.feature.home.components.PodcastCard
import cx.aswin.boxlore.feature.home.components.forYouItems
@@ -40,7 +41,9 @@ internal fun LazyStaggeredGridScope.curatedForYouItems(
derivedState: PodcastFeedDerivedState,
) {
if (!derivedState.hasBecauseYouLike && !derivedState.hasRecommendations) return
- curatedHeaderItem(callbacks)
+ if (!derivedState.hasBecauseYouLike) {
+ curatedHeaderItem(callbacks, recommendationState.isRecommendationsFallback)
+ }
becauseYouLikeItem(feedState, recommendationState, playback, callbacks, derivedState)
if (derivedState.hasRecommendations) {
forYouItems(
@@ -49,24 +52,24 @@ internal fun LazyStaggeredGridScope.curatedForYouItems(
callbacks.onEpisodeClick?.invoke(episode, podcast, "home_for_you")
},
discoveryContextTitle = feedState.discoveryGreeting.title,
+ onBrowseRecommendations = {
+ callbacks.onNavigateToExplore?.invoke(null, "home_for_you_see_all", "foryou")
+ },
showTasteHeader = derivedState.hasBecauseYouLike,
isFallback = recommendationState.isRecommendationsFallback,
)
}
}
-private fun LazyStaggeredGridScope.curatedHeaderItem(callbacks: HomeFeedCallbacks) {
+private fun LazyStaggeredGridScope.curatedHeaderItem(
+ callbacks: HomeFeedCallbacks,
+ isFallback: Boolean,
+) {
item(span = StaggeredGridItemSpan.FullLine, key = "curated_header", contentType = "section_header") {
- HomeTopLevelSectionHeader(
- title = "Curated For You",
- icon = Icons.Rounded.AutoAwesome,
- seeAllIcon = Icons.Rounded.ChevronRight,
- seeAllContentDescription = "See all curated recommendations",
- onSeeAllClick = {
- callbacks.onNavigateToExplore?.invoke(null, "home_for_you_see_all", "foryou")
- },
- modifier = Modifier.padding(top = 4.dp),
- )
+ val onBrowse: () -> Unit = { callbacks.onNavigateToExplore?.invoke(null, "home_for_you_see_all", "foryou") }
+ // The unchanged upper cards already reserve 12dp after their content.
+ val modifier = Modifier.padding(top = HomeFeedSpacing.SectionGap - HomeFeedSpacing.GridGap - 12.dp)
+ HomePersonalRecommendationsHeader(isFallback, onBrowse, modifier)
}
}
@@ -95,7 +98,10 @@ private fun LazyStaggeredGridScope.becauseYouLikeItem(
callbacks.onPodcastClick(clickedPodcast, "home_because_you_like", null, null)
},
onChangePodcastClick = recommendationState.onChangePodcastClick,
- modifier = Modifier.padding(bottom = 16.dp),
+ modifier = Modifier.padding(
+ top = HomeFeedSpacing.SectionGap - HomeFeedSpacing.GridGap - 12.dp,
+ bottom = if (derivedState.hasRecommendations) HomeFeedSpacing.SectionGap - HomeFeedSpacing.GridGap else 0.dp,
+ ),
)
}
}
@@ -115,7 +121,7 @@ internal fun LazyStaggeredGridScope.discoveryGreetingItem(
onSeeAllClick = {
callbacks.onNavigateToExplore?.invoke(null, "home_discovery_greeting", "foryou")
},
- modifier = Modifier.padding(top = 16.dp),
+ modifier = Modifier.padding(top = HomeFeedSpacing.SectionGap - HomeFeedSpacing.GridGap),
)
}
}
@@ -134,7 +140,7 @@ internal fun LazyStaggeredGridScope.discoverFeedItems(
item(span = StaggeredGridItemSpan.FullLine, key = "discover_grid_$row", contentType = "discover_grid") {
EqualHeightPosterGrid {
repeat(2) {
- GridSkeletonItem()
+ HomeMediaCardSkeleton()
}
}
}
@@ -151,7 +157,7 @@ private fun LazyStaggeredGridScope.discoverHeaderItem(
selectedCategory = feedState.selectedCategory,
onCategorySelected = callbacks.onSelectCategory,
onHeaderClick = { callbacks.onNavigateToExplore?.invoke(feedState.selectedCategory ?: "All", "home_discover_header", null) },
- modifier = Modifier.padding(bottom = 8.dp),
+ modifier = Modifier.padding(top = HomeFeedSpacing.SectionGap - HomeFeedSpacing.GridGap),
)
}
}
@@ -170,6 +176,7 @@ private fun LazyStaggeredGridScope.discoverPodcastItems(
showGenreChip = false,
showSubtitle = false,
density = FeedMediaCardDensity.Grid,
+ presentation = FeedMediaCardPresentation.ExpressivePoster,
onClick = {
callbacks.onPodcastClick(
podcast,
@@ -197,16 +204,22 @@ private fun LazyStaggeredGridScope.discoverViewMoreItem(
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
- androidx.compose.material3.FilledTonalButton(
- onClick = { callbacks.onNavigateToExplore?.invoke(feedState.selectedCategory ?: "All", "home_discover_view_all_button", null) },
- ) {
- Text("View more in ${feedState.selectedCategory ?: "Explore"}")
- Spacer(modifier = Modifier.width(8.dp))
- Icon(
- imageVector = Icons.Rounded.ChevronRight,
- contentDescription = null,
- modifier = Modifier.size(16.dp),
- )
+ DiscoveryExpressiveTheme {
+ androidx.compose.material3.FilledTonalButton(
+ onClick = { callbacks.onNavigateToExplore?.invoke(feedState.selectedCategory ?: "All", "home_discover_view_all_button", null) },
+ modifier = Modifier.heightIn(min = 48.dp),
+ ) {
+ Text(
+ feedState.selectedCategory?.let { stringResource(R.string.home_view_more_in, it) }
+ ?: stringResource(R.string.home_view_more_shows),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowForward,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
}
}
}
@@ -218,20 +231,12 @@ private fun DiscoveryGreetingHeader(
onSeeAllClick: () -> Unit,
modifier: Modifier = Modifier,
) {
- val icon =
- when (greeting.daypart) {
- ContentDaypart.MORNING,
- ContentDaypart.AFTERNOON,
- -> Icons.Rounded.WbSunny
- ContentDaypart.EVENING -> Icons.Rounded.WbTwilight
- ContentDaypart.LATE_NIGHT -> Icons.Rounded.NightsStay
- }
HomeTopLevelSectionHeader(
title = greeting.title,
- icon = icon,
- seeAllIcon = Icons.Rounded.ChevronRight,
- seeAllContentDescription = "See all discoveries",
+ seeAllContentDescription = stringResource(R.string.home_discovery_see_all),
onSeeAllClick = onSeeAllClick,
+ chapter = HomeDiscoveryChapter.MOMENT,
+ daypart = greeting.daypart,
modifier = modifier,
)
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt
index c9dd44b9a..eae378d30 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/HomeScreen.kt
@@ -118,12 +118,6 @@ data class HomeSheetUi(
val candidatePodcasts: List = emptyList(),
)
-@androidx.compose.runtime.Stable
-data class HomeFeaturedVideoState(
- val podcasts: StablePodcastList = StablePodcastList(emptyList()),
- val isDismissed: Boolean = false,
-)
-
@androidx.compose.runtime.Stable
data class HomeFeedCallbacks(
val onPodcastClick: (Podcast, String, String?, Int?) -> Unit,
@@ -293,8 +287,6 @@ fun HomeRoute(
val completedDownloadItems by viewModel.completedDownloadItems.collectAsState(initial = emptyList())
val homeMixMode by viewModel.homeMixMode.collectAsState()
val lastSeenEpisodes by viewModel.lastSeenEpisodes.collectAsState()
- val featuredVideoPodcasts by viewModel.featuredVideoPodcasts.collectAsState()
- val featuredVideoShowcaseDismissed by viewModel.featuredVideoShowcaseDismissed.collectAsState()
val callbacks =
remember(
@@ -393,11 +385,6 @@ fun HomeRoute(
showFeedback = showFeedback,
candidatePodcasts = candidatePodcasts,
),
- featuredVideos =
- HomeFeaturedVideoState(
- podcasts = StablePodcastList(featuredVideoPodcasts),
- isDismissed = featuredVideoShowcaseDismissed,
- ),
callbacks = callbacks,
snackbarHostState = snackbarHostState,
modifier = modifier,
@@ -420,7 +407,6 @@ private fun HomeScreenFeedContent(
uiState: HomeUiState,
gridState: LazyStaggeredGridState,
playback: HomePlaybackUi,
- featuredVideos: HomeFeaturedVideoState,
callbacks: HomeFeedCallbacks,
onChangePodcastClick: () -> Unit,
) {
@@ -476,7 +462,6 @@ private fun HomeScreenFeedContent(
softExpireProgressEpisodeIds = uiState.softExpireProgressEpisodeIds,
),
callbacks = callbacks,
- featuredVideos = featuredVideos,
layout = PodcastFeedLayout(gridState = gridState),
)
}
@@ -565,7 +550,6 @@ fun HomeScreen(
playback: HomePlaybackUi,
sheets: HomeSheetUi,
callbacks: HomeScreenCallbacks,
- featuredVideos: HomeFeaturedVideoState = HomeFeaturedVideoState(),
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = SnackbarHostState(),
showTopBarShortcuts: Boolean = true,
@@ -610,7 +594,6 @@ fun HomeScreen(
uiState = uiState,
gridState = gridState,
playback = playback,
- featuredVideos = featuredVideos,
callbacks = callbacks.feed,
onChangePodcastClick = { showChangePodcastSheet = true },
)
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/BecauseYouLikeSection.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/BecauseYouLikeSection.kt
index d95d20131..c74ccd5dd 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/BecauseYouLikeSection.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/BecauseYouLikeSection.kt
@@ -1,13 +1,9 @@
package cx.aswin.boxlore.feature.home.components
-import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.PlaylistPlay
-import androidx.compose.material.icons.rounded.Subscriptions
import androidx.compose.material.icons.rounded.SwapHoriz
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
@@ -15,21 +11,31 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.rotate
-import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import cx.aswin.boxlore.core.designsystem.components.CuratedEpisodeCard
+import cx.aswin.boxlore.core.designsystem.components.DiscoveryExpressiveTheme
import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardDensity
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation
import cx.aswin.boxlore.core.designsystem.components.OptimizedImage
+import cx.aswin.boxlore.core.designsystem.components.drawOutline
import cx.aswin.boxlore.core.designsystem.list.LazyListKeyPolicy
import cx.aswin.boxlore.core.designsystem.theme.ExpressiveShapes
import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight
import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable
import cx.aswin.boxlore.core.model.Episode
import cx.aswin.boxlore.core.model.Podcast
+import cx.aswin.boxlore.feature.home.R
import cx.aswin.boxlore.feature.home.StableEpisodeList
import cx.aswin.boxlore.feature.home.StablePodcastList
import cx.aswin.boxlore.feature.home.logic.HomeBecauseYouLikeLogic
@@ -47,199 +53,179 @@ fun BecauseYouLikeSection(
onChangePodcastClick: () -> Unit,
modifier: Modifier = Modifier,
) {
- Column(
- modifier = modifier.fillMaxWidth(),
- ) {
- // Editorial seed stamp: the tilted cover and heart badge give this rail a distinct identity.
- Surface(
- shape = MaterialTheme.shapes.extraLarge,
- color = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
- modifier =
- Modifier
- .fillMaxWidth()
- .heightIn(min = 88.dp)
- .expressiveClickable(onClick = { onPodcastClick(podcast) }),
- ) {
- Box {
- Box(
- modifier =
- Modifier
- .matchParentSize()
- .clip(MaterialTheme.shapes.extraLarge),
- ) {
- Box(
- modifier =
- Modifier
- .align(Alignment.TopEnd)
- .offset(x = 20.dp, y = (-34).dp)
- .size(104.dp)
- .rotate(10f)
- .clip(ExpressiveShapes.SoftBurst)
- .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.055f)),
- )
- Box(
- modifier =
- Modifier
- .align(Alignment.BottomStart)
- .offset(x = 78.dp, y = 38.dp)
- .size(84.dp)
- .rotate(-12f)
- .clip(ExpressiveShapes.Cookie6)
- .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.055f)),
- )
+ Column(modifier = modifier.fillMaxWidth()) {
+ BecauseYouLikeSeed(
+ podcast = podcast,
+ onPodcastClick = onPodcastClick,
+ onChangePodcastClick = onChangePodcastClick,
+ )
+ DiscoveryExpressiveTheme {
+ Column {
+ // Suggested shows retain their existing filtering and stable keys.
+ val distinctSuggestedPodcasts = remember(suggestedPodcasts.list) {
+ HomeBecauseYouLikeLogic.filterRailPodcasts(suggestedPodcasts.list)
}
+ if (distinctSuggestedPodcasts.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(HomeFeedSpacing.RelatedRailGap))
+ Column(verticalArrangement = Arrangement.spacedBy(HomeFeedSpacing.HeaderContentGap)) {
+ HomeChildSectionHeader(
+ title = stringResource(R.string.home_similar_shows),
+ icon = RecommendationHeaderIcons.SimilarShows,
+ )
- Row(
- modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(14.dp),
- ) {
- Box(modifier = Modifier.size(64.dp)) {
- Surface(
- shape = MaterialTheme.shapes.large,
- shadowElevation = 3.dp,
- modifier =
- Modifier
- .align(Alignment.Center)
- .size(56.dp)
- .rotate(-3f),
- ) {
- OptimizedImage(
- url = podcast.imageUrl,
- proxyWidth = 120,
- contentDescription = null,
- modifier =
- Modifier
- .fillMaxSize()
- .clip(MaterialTheme.shapes.large),
- )
- }
- Surface(
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
- shadowElevation = 2.dp,
- modifier =
- Modifier
- .align(Alignment.BottomEnd)
- .size(24.dp),
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- painter = painterResource(id = cx.aswin.boxlore.core.designsystem.R.drawable.mood_heart_24),
- contentDescription = null,
- modifier = Modifier.size(14.dp),
+ HomeDiscoveryRail(compact = true) { cardWidth ->
+ items(
+ distinctSuggestedPodcasts,
+ key = { LazyListKeyPolicy.safeKey(it.id, prefix = "byl_show") }
+ ) { suggestedPodcast ->
+ PodcastCard(
+ podcast = suggestedPodcast,
+ onClick = { onPodcastClick(suggestedPodcast) },
+ showSubtitle = false,
+ density = FeedMediaCardDensity.Rail,
+ modifier = Modifier.width(cardWidth),
+ presentation = FeedMediaCardPresentation.ExpressivePoster,
)
}
}
}
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "BECAUSE YOU LIKE",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = GoogleSansWeight.bold,
- letterSpacing = 0.7.sp,
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = podcast.title,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = GoogleSansWeight.bold,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- }
- IconButton(
- onClick = onChangePodcastClick,
- modifier = Modifier.size(40.dp),
- ) {
- Icon(
- imageVector = Icons.Rounded.SwapHoriz,
- contentDescription = "Change seed podcast",
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(22.dp),
- )
- }
}
- }
- }
- // --- Subsection 1: Suggested Shows (OutlinedCard Grid Matching CuratedEpisodeCard) ---
- val distinctSuggestedPodcasts = remember(suggestedPodcasts.list) {
- HomeBecauseYouLikeLogic.filterRailPodcasts(suggestedPodcasts.list)
- }
- if (distinctSuggestedPodcasts.isNotEmpty()) {
- Spacer(modifier = Modifier.height(28.dp))
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- BecauseYouLikeSectionHeader(
- title = "Similar Shows",
- icon = Icons.Rounded.Subscriptions,
- )
-
- LazyRow(
- modifier = Modifier.fillMaxWidth(),
- contentPadding = PaddingValues(horizontal = 0.dp),
- horizontalArrangement = Arrangement.spacedBy(HomeFeedSpacing.RailItemGap),
- ) {
- items(
- distinctSuggestedPodcasts,
- key = { LazyListKeyPolicy.safeKey(it.id, prefix = "byl_show") }
- ) { suggestedPodcast ->
- PodcastCard(
- podcast = suggestedPodcast,
- onClick = { onPodcastClick(suggestedPodcast) },
- showSubtitle = false,
- density = FeedMediaCardDensity.Rail,
- modifier = Modifier.fillParentMaxWidth(HomeFeedSpacing.RAIL_CARD_WIDTH_FRACTION),
+ // Recommended episodes use the same poster presentation.
+ val distinctRecommendations = remember(recommendations.list) {
+ HomeBecauseYouLikeLogic.filterRailEpisodes(recommendations.list)
+ }
+ if (distinctRecommendations.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(HomeFeedSpacing.RelatedRailGap))
+ Column(verticalArrangement = Arrangement.spacedBy(HomeFeedSpacing.HeaderContentGap)) {
+ HomeChildSectionHeader(
+ title = stringResource(R.string.home_recommended_episodes),
+ icon = RecommendationHeaderIcons.EpisodesToTry,
)
+
+ HomeDiscoveryRail(compact = true) { cardWidth ->
+ items(
+ distinctRecommendations,
+ key = { LazyListKeyPolicy.safeKey(it.id, prefix = "byl_ep") }
+ ) { episode ->
+ val parentPodcast =
+ Podcast(
+ id = episode.podcastId ?: "",
+ title = episode.podcastTitle ?: "Podcast",
+ artist = "",
+ imageUrl =
+ episode.podcastImageUrl?.takeIf { it.isNotBlank() } ?: episode.imageUrl?.takeIf { it.isNotBlank() }
+ ?: "",
+ description = "",
+ genre = episode.podcastGenre ?: "Podcast",
+ )
+ CuratedEpisodeCard(
+ podcast = parentPodcast,
+ episode = episode,
+ onClick = { onEpisodeClick(episode, parentPodcast) },
+ showSubtitle = false,
+ modifier = Modifier.width(cardWidth),
+ presentation = FeedMediaCardPresentation.ExpressivePoster,
+ )
+ }
+ }
}
}
}
}
+ }
+}
- // --- Subsection 2: Recommended Episodes ---
- val distinctRecommendations = remember(recommendations.list) {
- HomeBecauseYouLikeLogic.filterRailEpisodes(recommendations.list)
- }
- if (distinctRecommendations.isNotEmpty()) {
- Spacer(modifier = Modifier.height(28.dp))
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- BecauseYouLikeSectionHeader(
- title = "Recommended Episodes",
- icon = Icons.AutoMirrored.Rounded.PlaylistPlay,
- )
+@Composable
+private fun BecauseYouLikeSeed(
+ podcast: Podcast,
+ onPodcastClick: (Podcast) -> Unit,
+ onChangePodcastClick: () -> Unit,
+) {
+ // Editorial seed stamp: the tilted cover and heart badge give this rail a distinct identity.
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(min = 88.dp)
+ .expressiveClickable(onClick = { onPodcastClick(podcast) }),
+ ) {
+ Box {
+ BecauseYouLikeSeedDecorations()
- LazyRow(
- modifier = Modifier.fillMaxWidth(),
- contentPadding = PaddingValues(horizontal = 0.dp),
- horizontalArrangement = Arrangement.spacedBy(HomeFeedSpacing.RailItemGap),
- ) {
- items(
- distinctRecommendations,
- key = { LazyListKeyPolicy.safeKey(it.id, prefix = "byl_ep") }
- ) { episode ->
- val parentPodcast =
- Podcast(
- id = episode.podcastId ?: "",
- title = episode.podcastTitle ?: "Podcast",
- artist = "",
- imageUrl =
- episode.podcastImageUrl?.takeIf { it.isNotBlank() } ?: episode.imageUrl?.takeIf { it.isNotBlank() }
- ?: "",
- description = "",
- genre = episode.podcastGenre ?: "Podcast",
- )
- CuratedEpisodeCard(
- podcast = parentPodcast,
- episode = episode,
- onClick = { onEpisodeClick(episode, parentPodcast) },
- showSubtitle = false,
- modifier = Modifier.fillParentMaxWidth(HomeFeedSpacing.RAIL_CARD_WIDTH_FRACTION),
+ Row(
+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Box(modifier = Modifier.size(64.dp)) {
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ shadowElevation = 3.dp,
+ modifier =
+ Modifier
+ .align(Alignment.Center)
+ .size(56.dp)
+ .rotate(-3f),
+ ) {
+ OptimizedImage(
+ url = podcast.imageUrl,
+ proxyWidth = 120,
+ contentDescription = null,
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .clip(MaterialTheme.shapes.large),
)
}
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ shadowElevation = 2.dp,
+ modifier =
+ Modifier
+ .align(Alignment.BottomEnd)
+ .size(24.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ painter = painterResource(id = cx.aswin.boxlore.core.designsystem.R.drawable.mood_heart_24),
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ )
+ }
+ }
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.home_because_you_like),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = podcast.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = GoogleSansWeight.bold,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ IconButton(
+ onClick = onChangePodcastClick,
+ modifier = Modifier.size(40.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.SwapHoriz,
+ contentDescription = stringResource(R.string.home_change_recommendation_show),
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(22.dp),
+ )
}
}
}
@@ -247,27 +233,31 @@ fun BecauseYouLikeSection(
}
@Composable
-private fun BecauseYouLikeSectionHeader(
- title: String,
- icon: ImageVector,
- modifier: Modifier = Modifier,
-) {
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(18.dp),
- )
- Text(
- text = title,
- style = MaterialTheme.typography.titleSmall,
- fontWeight = GoogleSansWeight.semiBold,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
+private fun BoxScope.BecauseYouLikeSeedDecorations() {
+ val upperColor = MaterialTheme.colorScheme.tertiary
+ val lowerColor = MaterialTheme.colorScheme.tertiary
+ Box(
+ modifier = Modifier.matchParentSize().clip(MaterialTheme.shapes.extraLarge).drawWithCache {
+ val upperSize = 120.dp.toPx()
+ val lowerSize = 104.dp.toPx()
+ val upperOutline = ExpressiveShapes.SoftBurst.createOutline(Size(upperSize, upperSize), layoutDirection, this)
+ val lowerOutline = ExpressiveShapes.Cookie9.createOutline(Size(lowerSize, lowerSize), layoutDirection, this)
+ // Stable edge peeks keep the artwork and its original mood badge unobstructed.
+ val lowerLeft = maxOf(104.dp.toPx(), size.width * 0.52f - lowerSize / 2f)
+ onDrawBehind {
+ scale(scaleX = if (layoutDirection == LayoutDirection.Rtl) -1f else 1f, scaleY = 1f) {
+ translate(left = size.width - 88.dp.toPx(), top = -72.dp.toPx()) {
+ rotate(11f, pivot = Offset(upperSize / 2f, upperSize / 2f)) {
+ drawOutline(upperOutline, upperColor, alpha = 0.10f)
+ }
+ }
+ translate(left = lowerLeft, top = size.height - 34.dp.toPx()) {
+ rotate(-16f, pivot = Offset(lowerSize / 2f, lowerSize / 2f)) {
+ drawOutline(lowerOutline, lowerColor, alpha = 0.12f)
+ }
+ }
+ }
+ }
+ },
+ )
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/DailyBriefingCard.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/DailyBriefingCard.kt
index c87e421f5..5cc3d5640 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/DailyBriefingCard.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/DailyBriefingCard.kt
@@ -196,8 +196,8 @@ fun DailyBriefingCard(
Brush.verticalGradient(
colorStops =
arrayOf(
- 0.0f to Color.Black.copy(alpha = 0.45f),
- 0.3f to Color.Black.copy(alpha = 0.6f),
+ 0.0f to Color.Black.copy(alpha = 0.65f),
+ 0.3f to Color.Black.copy(alpha = 0.65f),
0.55f to Color.Black.copy(alpha = 0.8f),
1.0f to Color.Black.copy(alpha = 0.95f),
),
@@ -231,10 +231,9 @@ fun DailyBriefingCard(
verticalAlignment = Alignment.Top,
) {
// Boxlore Brief logo and date column
- val primaryColor = MaterialTheme.colorScheme.primary
-
Column(
- verticalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
) {
androidx.compose.foundation.Image(
painter =
@@ -242,32 +241,20 @@ fun DailyBriefingCard(
id = cx.aswin.boxlore.core.designsystem.R.drawable.ic_boxlore_brief_logo,
),
contentDescription = "The Boxlore Brief",
- modifier = Modifier.height(48.dp),
+ modifier = Modifier.height(72.dp),
+ alignment = Alignment.CenterStart,
colorFilter =
androidx.compose.ui.graphics.ColorFilter
.tint(Color.White),
)
- // Date chip: using opaque Material 3 container colors to avoid transparency visibility issues on dark backdrop
- Surface(
- shape = RoundedCornerShape(10.dp),
- color = MaterialTheme.colorScheme.primaryContainer,
- border =
- androidx.compose.foundation.BorderStroke(
- 1.dp,
- primaryColor,
- ),
- modifier = Modifier.padding(start = 4.dp),
- ) {
- Text(
- text = formattedDate,
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- style = MaterialTheme.typography.labelMedium,
- fontWeight = GoogleSansWeight.bold,
- letterSpacing = 0.3.sp,
- modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
- )
- }
+ Text(
+ text = formattedDate,
+ color = Color.White.copy(alpha = 0.82f),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Normal,
+ letterSpacing = 0.2.sp,
+ )
}
// Dismiss / Cancel button
@@ -305,7 +292,7 @@ fun DailyBriefingCard(
}
// Static Spacer for the art to breathe
- Spacer(modifier = Modifier.height(80.dp))
+ Spacer(modifier = Modifier.height(64.dp))
// Animated body content
AnimatedContent(
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/DiscoverSection.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/DiscoverSection.kt
index 47173761c..3cc529dfd 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/DiscoverSection.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/DiscoverSection.kt
@@ -1,30 +1,15 @@
package cx.aswin.boxlore.feature.home.components
-import androidx.compose.foundation.layout.*
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.TrendingUp
-import androidx.compose.material.icons.rounded.ChevronRight
-import androidx.compose.material3.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import cx.aswin.boxlore.core.designsystem.theme.rememberSectionHeaderFontFamily
+import androidx.compose.ui.res.stringResource
+import cx.aswin.boxlore.core.designsystem.components.DiscoveryExpressiveTheme
+import cx.aswin.boxlore.feature.home.R
-/**
- * Discover Section - Genre-filtered podcast discovery.
- *
- * Contains:
- * - Section Header ("Discover" or "Top in [Category]")
- * - Genre Selector (FilterChips)
- * - Loading State (BoxLoreLoader.Expressive)
- * - Podcast Grid (via callback - grid is rendered in parent for staggered layout)
- *
- * @param selectedCategory Current selected category (null = "For You")
- * @param isLoading Whether content is loading
- * @param onCategorySelected Callback when a category chip is tapped
- * @param modifier Modifier for the section
- */
+/** Genre-filtered discovery; selection and navigation remain owned by the feed. */
@Composable
fun DiscoverSection(
selectedCategory: String?,
@@ -32,54 +17,15 @@ fun DiscoverSection(
onHeaderClick: () -> Unit,
modifier: Modifier = Modifier,
) {
- Column(modifier = modifier.fillMaxWidth()) {
- // Section Header (Matches OnTheRiseSection styling)
- Row(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(top = 0.dp, bottom = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- // Title Group
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- imageVector = Icons.AutoMirrored.Rounded.TrendingUp,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(24.dp),
- )
- Spacer(modifier = Modifier.width(12.dp))
- Text(
- text = if (selectedCategory == null) "Explore" else "Top in $selectedCategory",
- style =
- MaterialTheme.typography.headlineSmall.copy(
- fontFamily = rememberSectionHeaderFontFamily(),
- ),
- color = MaterialTheme.colorScheme.onSurface,
- )
- }
-
- // Action / Decorator
- androidx.compose.material3.FilledTonalIconButton(
- onClick = onHeaderClick,
- modifier = Modifier.size(32.dp),
- ) {
- Icon(
- imageVector = Icons.Rounded.ChevronRight,
- contentDescription = "See All",
- tint = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.size(18.dp),
- )
- }
+ DiscoveryExpressiveTheme {
+ Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(HomeFeedSpacing.HeaderContentGap)) {
+ HomeTopLevelSectionHeader(
+ title = stringResource(R.string.home_explore_shows),
+ seeAllContentDescription = stringResource(R.string.home_shows_see_all),
+ onSeeAllClick = onHeaderClick,
+ chapter = HomeDiscoveryChapter.EXPLORE,
+ )
+ GenreSelector(selectedCategory = selectedCategory, onCategorySelected = onCategorySelected)
}
-
- // Genre Selector Chips
- GenreSelector(
- selectedCategory = selectedCategory,
- onCategorySelected = onCategorySelected,
- modifier = Modifier.padding(vertical = 4.dp),
- )
}
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/FeaturedVideoPodcastsShowcase.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/FeaturedVideoPodcastsShowcase.kt
deleted file mode 100644
index 80dd4237a..000000000
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/FeaturedVideoPodcastsShowcase.kt
+++ /dev/null
@@ -1,311 +0,0 @@
-@file:Suppress("ktlint:standard:function-naming")
-
-package cx.aswin.boxlore.feature.home.components
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyRow
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.AutoAwesome
-import androidx.compose.material.icons.rounded.ChevronRight
-import androidx.compose.material.icons.rounded.Close
-import androidx.compose.material.icons.rounded.Movie
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import cx.aswin.boxlore.core.designsystem.components.OptimizedImage
-import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight
-import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable
-import cx.aswin.boxlore.core.model.Podcast
-
-private val ArtworkShape = RoundedCornerShape(22.dp)
-private val SpotlightTileWidth = 232.dp
-
-@Composable
-internal fun FeaturedVideoPodcastsShowcase(
- podcasts: List,
- tedTalksSdPodcast: Podcast,
- onPodcastClick: (Podcast, Int, String) -> Unit,
- onDismissForever: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- if (podcasts.isEmpty()) return
- var showDismissConfirmation by rememberSaveable { mutableStateOf(false) }
-
- LaunchedEffect(podcasts) {
- cx.aswin.boxlore.core.analytics.AnalyticsHelper.trackVideoSpotlightImpression(
- itemsCount = podcasts.size,
- podcastIds = podcasts.map(Podcast::id),
- )
- }
-
- Column(modifier = modifier.fillMaxWidth()) {
- VideoSpotlightHeader(
- onDismissClick = { showDismissConfirmation = true },
- )
- Spacer(Modifier.height(10.dp))
- LazyRow(
- contentPadding = PaddingValues(end = 20.dp, bottom = 4.dp),
- horizontalArrangement = Arrangement.spacedBy(14.dp),
- verticalAlignment = Alignment.Top,
- ) {
- itemsIndexed(
- items = podcasts,
- key = { _, podcast -> podcast.id },
- ) { index, podcast ->
- SpotlightArtworkTile(
- podcast = podcast,
- rank = index + 1,
- isLead = index == 0,
- onClick = { onPodcastClick(podcast, index, "card") },
- onHdClick =
- if (index == 0) {
- { onPodcastClick(podcast, index, "hd") }
- } else {
- null
- },
- onSdClick =
- if (index == 0) {
- { onPodcastClick(tedTalksSdPodcast, index, "sd") }
- } else {
- null
- },
- )
- }
- }
- }
-
- if (showDismissConfirmation) {
- AlertDialog(
- onDismissRequest = { showDismissConfirmation = false },
- icon = { Icon(Icons.Rounded.AutoAwesome, contentDescription = null) },
- title = { Text("Hide this spotlight?") },
- text = {
- Text(
- "It won’t appear on Home again. You can still find every show through search.",
- )
- },
- confirmButton = {
- TextButton(
- onClick = {
- showDismissConfirmation = false
- onDismissForever()
- },
- ) {
- Text("Hide forever")
- }
- },
- dismissButton = {
- TextButton(onClick = { showDismissConfirmation = false }) {
- Text("Keep it")
- }
- },
- )
- }
-}
-
-@Composable
-private fun VideoSpotlightHeader(
- onDismissClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- HomeChildSectionHeader(
- title = "Video Spotlight",
- icon = Icons.Rounded.Movie,
- modifier = Modifier.weight(1f),
- )
- IconButton(
- onClick = onDismissClick,
- modifier = Modifier.size(36.dp),
- colors =
- IconButtonDefaults.iconButtonColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
- contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
- ),
- ) {
- Icon(
- imageVector = Icons.Rounded.Close,
- contentDescription = "Hide this spotlight",
- modifier = Modifier.size(19.dp),
- )
- }
- }
-}
-
-@Composable
-private fun SpotlightArtworkTile(
- podcast: Podcast,
- rank: Int,
- isLead: Boolean,
- onClick: () -> Unit,
- onHdClick: (() -> Unit)?,
- onSdClick: (() -> Unit)?,
-) {
- Column(
- modifier = Modifier.width(SpotlightTileWidth),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- Box(
- modifier =
- Modifier
- .fillMaxWidth()
- .aspectRatio(1f)
- .expressiveClickable(
- onClick = onClick,
- ).shadow(
- elevation = 3.dp,
- shape = ArtworkShape,
- clip = false,
- ).background(
- color = MaterialTheme.colorScheme.surfaceVariant,
- shape = ArtworkShape,
- ).border(
- width = 1.dp,
- color = MaterialTheme.colorScheme.outlineVariant,
- shape = ArtworkShape,
- ).clip(ArtworkShape),
- ) {
- OptimizedImage(
- url = podcast.imageUrl,
- proxyWidth = 560,
- contentDescription = podcast.title,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Crop,
- )
- Surface(
- modifier =
- Modifier
- .align(Alignment.TopStart)
- .padding(12.dp),
- shape = RoundedCornerShape(11.dp),
- color =
- if (isLead) {
- MaterialTheme.colorScheme.primaryContainer
- } else {
- MaterialTheme.colorScheme.surfaceContainerHighest
- },
- contentColor =
- if (isLead) {
- MaterialTheme.colorScheme.onPrimaryContainer
- } else {
- MaterialTheme.colorScheme.onSurface
- },
- ) {
- Text(
- text = if (isLead) "EDITOR’S PICK" else rank.toString().padStart(2, '0'),
- modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
- style = MaterialTheme.typography.labelSmall,
- fontWeight = GoogleSansWeight.bold,
- )
- }
- if (onSdClick != null) {
- Row(
- modifier =
- Modifier
- .align(Alignment.BottomEnd)
- .padding(12.dp),
- horizontalArrangement = Arrangement.spacedBy(7.dp),
- ) {
- Button(
- onClick = onHdClick ?: onClick,
- modifier = Modifier.height(38.dp),
- contentPadding = PaddingValues(horizontal = 15.dp),
- ) {
- Text(
- text = "HD",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = GoogleSansWeight.bold,
- )
- }
- FilledTonalButton(
- onClick = onSdClick,
- modifier = Modifier.height(38.dp),
- contentPadding = PaddingValues(horizontal = 15.dp),
- ) {
- Text(
- text = "SD",
- style = MaterialTheme.typography.labelLarge,
- fontWeight = GoogleSansWeight.bold,
- )
- }
- }
- }
- }
- Column(
- modifier =
- Modifier
- .fillMaxWidth()
- .expressiveClickable(
- shape = RoundedCornerShape(12.dp),
- onClick = onClick,
- ).padding(horizontal = 3.dp, vertical = 2.dp),
- verticalArrangement = Arrangement.spacedBy(3.dp),
- ) {
- Text(
- text = podcast.title,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = GoogleSansWeight.semiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(3.dp),
- ) {
- Text(
- text = podcast.artist,
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Icon(
- imageVector = Icons.Rounded.ChevronRight,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(18.dp),
- )
- }
- }
- }
-}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ForYouSection.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ForYouSection.kt
index 30baad8c1..9b1792833 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ForYouSection.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/ForYouSection.kt
@@ -1,45 +1,30 @@
package cx.aswin.boxlore.feature.home.components
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.AutoAwesome
-import androidx.compose.material.icons.rounded.Videocam
-import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
+import androidx.compose.ui.res.stringResource
import cx.aswin.boxlore.core.analytics.AnalyticsHelper
import cx.aswin.boxlore.core.designsystem.components.CuratedEpisodeCard
import cx.aswin.boxlore.core.designsystem.components.EqualHeightPosterGrid
-import cx.aswin.boxlore.core.designsystem.components.OptimizedImage
-import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight
-import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable
-import cx.aswin.boxlore.core.designsystem.theme.m3Shimmer
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation
import cx.aswin.boxlore.core.model.Episode
import cx.aswin.boxlore.core.model.Podcast
+import cx.aswin.boxlore.feature.home.R
import cx.aswin.boxlore.feature.home.StableEpisodeList
/**
* Emits the "For You" section into a [LazyStaggeredGridScope].
- * Hero stays full-line; body cards load one [EqualHeightPosterGrid] row at a time
- * with fixed 3-line title feet.
+ * The first recommendation stays full-line; body cards load one [EqualHeightPosterGrid]
+ * row at a time with font-scaled three-line title feet.
*/
fun LazyStaggeredGridScope.forYouItems(
recommendations: StableEpisodeList,
onEpisodeClick: (Episode, Podcast) -> Unit,
discoveryContextTitle: String,
+ onBrowseRecommendations: () -> Unit,
showTasteHeader: Boolean = true,
isFallback: Boolean = true,
) {
@@ -47,10 +32,7 @@ fun LazyStaggeredGridScope.forYouItems(
if (showTasteHeader) {
item(span = StaggeredGridItemSpan.FullLine, key = "for_you_header", contentType = "for_you_header") {
- HomeChildSectionHeader(
- title = if (isFallback) "Popular in your Region" else "Based on Your Taste",
- icon = Icons.Rounded.AutoAwesome,
- )
+ HomePersonalRecommendationsHeader(isFallback, onBrowseRecommendations)
}
}
@@ -77,10 +59,11 @@ fun LazyStaggeredGridScope.forYouItems(
description = "",
genre = ep.podcastGenre ?: "Podcast",
)
- ForYouHeroCard(
+ CuratedEpisodeCard(
episode = ep,
- parentPodcast = parentPodcast,
- isFallback = isFallback,
+ podcast = parentPodcast,
+ showSubtitle = false,
+ presentation = FeedMediaCardPresentation.ExpressiveFeatured,
onClick = {
AnalyticsHelper.trackHomeRecommendationCardTapped(
episodeId = ep.id,
@@ -128,6 +111,7 @@ fun LazyStaggeredGridScope.forYouItems(
onEpisodeClick(ep, parentPodcast)
},
showSubtitle = false,
+ presentation = FeedMediaCardPresentation.ExpressivePoster,
)
}
}
@@ -135,340 +119,31 @@ fun LazyStaggeredGridScope.forYouItems(
}
}
-private fun LazyStaggeredGridScope.forYouSkeletonItems() {
- item(span = StaggeredGridItemSpan.FullLine, key = "for_you_hero", contentType = "for_you_hero") {
- val baseColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
- val highlightColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f)
- ForYouHeroSkeleton(baseColor = baseColor, highlightColor = highlightColor)
- }
- repeat((HomeFeedSpacing.ForYouBodyCount + 1) / 2) { row ->
- item(span = StaggeredGridItemSpan.FullLine, key = "for_you_body_$row", contentType = "for_you_body") {
- EqualHeightPosterGrid {
- repeat(2) { GridSkeletonItem() }
- }
- }
- }
-}
-
+/** A personal chapter keeps its identity even when show-based recommendations are absent. */
@Composable
-private fun ForYouHeroCard(
- episode: Episode,
- parentPodcast: Podcast,
- isFallback: Boolean = true,
- onClick: () -> Unit,
+internal fun HomePersonalRecommendationsHeader(
+ isFallback: Boolean,
+ onBrowse: () -> Unit,
modifier: Modifier = Modifier,
) {
- Box(
- modifier =
- modifier
- .fillMaxWidth()
- .height(200.dp)
- .clip(RoundedCornerShape(20.dp))
- .expressiveClickable(shape = RoundedCornerShape(20.dp), onClick = onClick),
- ) {
- // Full-bleed artwork background
- OptimizedImage(
- url = episode.imageUrl?.takeIf { it.isNotBlank() } ?: episode.podcastImageUrl?.takeIf { it.isNotBlank() },
- proxyWidth = 600,
- contentDescription = episode.title,
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize(),
- )
-
- // Dark gradient scrim — heavier at bottom for text legibility (Option A)
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .background(
- Brush.verticalGradient(
- colorStops =
- arrayOf(
- 0.0f to Color.Transparent,
- 0.3f to Color.Black.copy(alpha = 0.15f),
- 0.6f to Color.Black.copy(alpha = 0.65f),
- 1.0f to Color.Black,
- ),
- ),
- ),
- )
-
- // Premium tag for the Hero card
- Box(
- modifier =
- Modifier
- .padding(14.dp)
- .align(Alignment.TopStart)
- .background(
- color = MaterialTheme.colorScheme.primary.copy(alpha = 0.85f),
- shape = RoundedCornerShape(12.dp),
- ).padding(horizontal = 8.dp, vertical = 4.dp),
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Icon(
- imageVector = Icons.Rounded.AutoAwesome,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.onPrimary,
- modifier = Modifier.size(12.dp),
- )
- Text(
- text = if (isFallback) "POPULAR IN YOUR REGION" else "FEATURED RECOMMENDATION",
- style =
- MaterialTheme.typography.labelSmall.copy(
- color = MaterialTheme.colorScheme.onPrimary,
- fontSize = 9.sp,
- fontWeight = GoogleSansWeight.bold,
- letterSpacing = 0.5.sp,
- ),
- )
- }
- }
-
- // Metadata anchored to bottom
- Column(
- modifier =
- Modifier
- .align(Alignment.BottomStart)
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- // Podcast name
- Text(
- text = episode.podcastTitle ?: "",
- style =
- MaterialTheme.typography.labelMedium.copy(
- color = Color.White.copy(alpha = 0.8f),
- fontSize = 11.sp,
- fontWeight = GoogleSansWeight.semiBold,
- letterSpacing = 0.4.sp,
- ),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
-
- // Episode title (larger font since it's the hero card)
- Text(
- text = episode.title,
- style =
- MaterialTheme.typography.titleMedium.copy(
- color = Color.White,
- fontWeight = GoogleSansWeight.bold,
- fontSize = 16.sp,
- lineHeight = 20.sp,
- ),
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
-
- // Duration & additional context
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- if (episode.duration > 0) {
- val minutes = episode.duration / 60
- Text(
- text = "$minutes min listen",
- style =
- MaterialTheme.typography.bodySmall.copy(
- color = Color.White.copy(alpha = 0.6f),
- fontSize = 10.sp,
- fontWeight = GoogleSansWeight.medium,
- ),
- )
- }
-
- val genre = episode.podcastGenre ?: parentPodcast.genre
- if (!genre.isNullOrBlank()) {
- Text(
- text = "•",
- color = Color.White.copy(alpha = 0.4f),
- fontSize = 10.sp,
- )
- Text(
- text = genre,
- style =
- MaterialTheme.typography.bodySmall.copy(
- color = Color.White.copy(alpha = 0.6f),
- fontSize = 10.sp,
- fontWeight = GoogleSansWeight.medium,
- ),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- }
- }
- }
- }
-}
-
-@Composable
-private fun ForYouHeroSkeleton(
- baseColor: Color,
- highlightColor: Color,
-) {
- Box(
- modifier =
- Modifier
- .fillMaxWidth()
- .height(200.dp)
- .clip(RoundedCornerShape(20.dp))
- .m3Shimmer(baseColor, highlightColor, shape = RoundedCornerShape(20.dp)),
+ HomeTopLevelSectionHeader(
+ title = stringResource(if (isFallback) R.string.home_popular_region else R.string.home_picked_for_you),
+ seeAllContentDescription = stringResource(R.string.home_recommendations_see_all),
+ onSeeAllClick = onBrowse,
+ chapter = HomeDiscoveryChapter.PERSONAL,
+ modifier = modifier,
)
}
-@Composable
-private fun ForYouHorizontalBentoCard(
- episode: Episode,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
-) {
- Box(
- modifier =
- modifier
- .fillMaxWidth()
- .height(115.dp)
- .clip(RoundedCornerShape(20.dp))
- .expressiveClickable(onClick = onClick),
- ) {
- // Full-bleed artwork background
- OptimizedImage(
- url = episode.imageUrl?.takeIf { it.isNotBlank() } ?: episode.podcastImageUrl?.takeIf { it.isNotBlank() },
- proxyWidth = 600,
- contentDescription = null,
- contentScale = ContentScale.Crop,
- modifier = Modifier.fillMaxSize(),
- )
-
- // Heavy dark gradient scrim for pristine text contrast
- Box(
- modifier =
- Modifier
- .fillMaxSize()
- .background(
- Brush.verticalGradient(
- colorStops =
- arrayOf(
- 0.0f to Color.Transparent,
- 0.4f to Color.Black.copy(alpha = 0.3f),
- 1.0f to Color.Black.copy(alpha = 0.85f),
- ),
- ),
- ),
- )
-
- // Duration chip — top left
- if (episode.duration > 0) {
- val minutes = episode.duration / 60
- Surface(
- shape = RoundedCornerShape(8.dp),
- color = Color.Black.copy(alpha = 0.6f),
- modifier =
- Modifier
- .padding(10.dp)
- .align(Alignment.TopStart),
- ) {
- Text(
- text = "$minutes min",
- style =
- MaterialTheme.typography.labelSmall.copy(
- color = Color.White,
- fontSize = 9.sp,
- fontWeight = GoogleSansWeight.bold,
- ),
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
- )
- }
- }
-
- // Genre chip — top right (or video badge if video)
- val genre = episode.podcastGenre
- if (episode.enclosureType?.startsWith("video/") == true) {
- Surface(
- shape = androidx.compose.foundation.shape.CircleShape,
- color = Color.Black.copy(alpha = 0.6f),
- modifier =
- Modifier
- .padding(10.dp)
- .align(Alignment.TopEnd),
- ) {
- Box(
- modifier = Modifier.padding(4.dp),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = Icons.Rounded.Videocam,
- contentDescription = "Video",
- tint = Color.White,
- modifier = Modifier.size(12.dp),
- )
- }
- }
- } else if (!genre.isNullOrBlank()) {
- Surface(
- shape = RoundedCornerShape(8.dp),
- color = Color.Black.copy(alpha = 0.6f),
- modifier =
- Modifier
- .padding(10.dp)
- .align(Alignment.TopEnd),
- ) {
- Text(
- text = genre,
- style =
- MaterialTheme.typography.labelSmall.copy(
- color = Color.White,
- fontSize = 9.sp,
- fontWeight = GoogleSansWeight.bold,
- ),
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
- )
+private fun LazyStaggeredGridScope.forYouSkeletonItems() {
+ item(span = StaggeredGridItemSpan.FullLine, key = "for_you_hero", contentType = "for_you_hero") {
+ HomeMediaCardSkeleton(presentation = FeedMediaCardPresentation.ExpressiveFeatured)
+ }
+ repeat((HomeFeedSpacing.ForYouBodyCount + 1) / 2) { row ->
+ item(span = StaggeredGridItemSpan.FullLine, key = "for_you_body_$row", contentType = "for_you_body") {
+ EqualHeightPosterGrid {
+ repeat(2) { HomeMediaCardSkeleton() }
}
}
-
- // Episode title — bottom aligned
- Column(
- modifier =
- Modifier
- .align(Alignment.BottomStart)
- .fillMaxWidth()
- .padding(14.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- ) {
- Text(
- text = episode.title,
- style =
- MaterialTheme.typography.titleMedium.copy(
- fontWeight = GoogleSansWeight.bold,
- fontSize = 14.sp,
- lineHeight = 18.sp,
- color = Color.White,
- ),
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- }
}
}
-
-@Composable
-private fun ForYouHorizontalBentoSkeleton(
- baseColor: Color,
- highlightColor: Color,
- modifier: Modifier = Modifier,
-) {
- Box(
- modifier =
- modifier
- .fillMaxWidth()
- .height(115.dp)
- .clip(RoundedCornerShape(20.dp))
- .m3Shimmer(baseColor, highlightColor, shape = RoundedCornerShape(20.dp)),
- )
-}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/GenreSelector.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/GenreSelector.kt
index f4480f21e..8889ee9e8 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/GenreSelector.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/GenreSelector.kt
@@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.AccountBalance
import androidx.compose.material.icons.rounded.AutoStories
@@ -31,7 +34,12 @@ import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.Weekend
import androidx.compose.material.icons.rounded.Whatshot
import androidx.compose.material.icons.rounded.Work
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -44,8 +52,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import cx.aswin.boxlore.core.designsystem.components.PillFilterChip
+import cx.aswin.boxlore.feature.home.R
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@@ -84,15 +93,15 @@ fun GenreSelector(
.horizontalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- PillFilterChip(
- label = "Top",
+ HomeGenreFilterChip(
+ label = stringResource(R.string.home_genres_top),
selected = selectedCategory == null,
onClick = { onCategorySelected(null) },
icon = Icons.Rounded.Whatshot,
)
displayGenres.forEach { genre ->
- PillFilterChip(
+ HomeGenreFilterChip(
label = genre.label,
selected = selectedCategory == genre.value,
onClick = { onCategorySelected(genre.value) },
@@ -100,11 +109,14 @@ fun GenreSelector(
)
}
- PillFilterChip(
- label = "More",
- selected = false,
+ AssistChip(
onClick = { showSheet = true },
- trailingIcon = Icons.Rounded.KeyboardArrowDown,
+ label = { Text(stringResource(R.string.home_genres_more)) },
+ trailingIcon = { Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = null) },
+ modifier = Modifier.heightIn(min = 48.dp),
+ shape = CircleShape,
+ border = null,
+ colors = AssistChipDefaults.assistChipColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
)
}
@@ -114,52 +126,84 @@ fun GenreSelector(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
- Column(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp)
- .padding(bottom = 48.dp),
- ) {
- Text(
- text = "Browse Genres",
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.padding(bottom = 16.dp),
- )
+ HomeGenreSheetContent(selectedCategory) { category ->
+ onCategorySelected(category)
+ showSheet = false
+ }
+ }
+ }
+}
- FlowRow(
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- modifier = Modifier.fillMaxWidth(),
- ) {
- PillFilterChip(
- label = "Top",
- selected = selectedCategory == null,
- onClick = {
- onCategorySelected(null)
- showSheet = false
- },
- icon = Icons.Rounded.Whatshot,
- )
+@Composable
+private fun HomeGenreSheetContent(selectedCategory: String?, onCategorySelected: (String?) -> Unit) {
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 48.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.home_browse_genres),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 16.dp),
+ )
- GENRES.forEach { genre ->
- PillFilterChip(
- label = genre.label,
- selected = selectedCategory == genre.value,
- onClick = {
- onCategorySelected(genre.value)
- showSheet = false
- },
- icon = genre.icon,
- )
- }
- }
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ HomeGenreFilterChip(
+ label = stringResource(R.string.home_genres_top),
+ selected = selectedCategory == null,
+ onClick = {
+ onCategorySelected(null)
+ },
+ icon = Icons.Rounded.Whatshot,
+ )
+
+ GENRES.forEach { genre ->
+ HomeGenreFilterChip(
+ label = genre.label,
+ selected = selectedCategory == genre.value,
+ onClick = {
+ onCategorySelected(genre.value)
+ },
+ icon = genre.icon,
+ )
}
}
}
}
+@Composable
+private fun HomeGenreFilterChip(
+ label: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+ icon: ImageVector,
+) {
+ FilterChip(
+ selected = selected,
+ onClick = onClick,
+ label = { Text(label) },
+ leadingIcon = { Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) },
+ modifier = Modifier.heightIn(min = 48.dp),
+ shape = CircleShape,
+ border = null,
+ colors = FilterChipDefaults.filterChipColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ labelColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ iconColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ )
+}
+
// Synced with ExploreGenreSelector / onboarding search icons. TODO: Move to core:model
private data class GenreItem(
val label: String,
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeDaypartMark.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeDaypartMark.kt
new file mode 100644
index 000000000..11af633c9
--- /dev/null
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeDaypartMark.kt
@@ -0,0 +1,181 @@
+package cx.aswin.boxlore.feature.home.components
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathOperation
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.clipRect
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import cx.aswin.boxlore.core.catalog.content.ContentDaypart
+import kotlin.math.cos
+import kotlin.math.sin
+
+/** Decorative daypart identity. The greeting remains the accessible heading. */
+@Composable
+internal fun HomeDaypartMark(daypart: ContentDaypart, fullyVisible: Boolean) {
+ var hasAnimated by rememberSaveable(daypart) { mutableStateOf(false) }
+ val progress = remember(daypart) { Animatable(if (hasAnimated) 1f else 0f) }
+ val lifecycle by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
+ val active = fullyVisible && lifecycle.isAtLeast(Lifecycle.State.STARTED)
+ val ambientPhase = if (active && hasAnimated) rememberDaypartAmbientPhase(daypart) else remember { mutableStateOf(0f) }
+ val accent = MaterialTheme.colorScheme.primary
+ val horizon = MaterialTheme.colorScheme.onSurfaceVariant
+
+ LaunchedEffect(daypart, active) {
+ if (active && !hasAnimated) {
+ // If scrolling interrupted the reveal, save it for the next fully visible entry.
+ progress.snapTo(0f)
+ progress.animateTo(1f, tween(1_000, easing = FastOutSlowInEasing))
+ hasAnimated = true
+ }
+ }
+
+ Box(
+ modifier = Modifier.size(32.dp).testTag("home_daypart_mark").drawWithCache {
+ val moon = crescentPath(size.width)
+ val star = starPath(size.width)
+ onDrawBehind {
+ // Read the clock during drawing, without recomposing the heading.
+ val amount = progress.value
+ val phase = ambientPhase.value
+ when (daypart) {
+ ContentDaypart.MORNING -> drawMorningMark(amount, phase * 28f, accent, horizon)
+ ContentDaypart.AFTERNOON -> drawAfternoonMark(amount, phase * 28f, accent)
+ ContentDaypart.EVENING -> drawEveningMark(amount, phase * 28f, accent, horizon)
+ ContentDaypart.LATE_NIGHT -> {
+ clipRect(right = size.width * amount) { drawPath(moon, accent) }
+ val reveal = ((amount - 0.6f) / 0.4f).coerceIn(0f, 1f)
+ scale(1.25f - 0.55f * phase, pivot = Offset(size.width * 0.8f, size.width * 0.23f)) {
+ drawPath(star, accent, alpha = reveal * (1f - 0.8f * phase))
+ }
+ translate(left = size.width * 0.04f, top = size.width * 0.28f) {
+ scale(0.5f + 0.35f * phase, pivot = Offset(size.width * 0.8f, size.width * 0.23f)) {
+ drawPath(star, accent, alpha = reveal * (0.2f + 0.8f * phase))
+ }
+ }
+ }
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun rememberDaypartAmbientPhase(daypart: ContentDaypart): State {
+ val transition = rememberInfiniteTransition(label = "Daypart ambient motion")
+ return transition.animateFloat(
+ initialValue = 0f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ tween(if (daypart == ContentDaypart.LATE_NIGHT) 1_400 else 2_400, easing = FastOutSlowInEasing),
+ RepeatMode.Reverse,
+ ),
+ label = "Sun rays or star brightness",
+ )
+}
+
+private fun DrawScope.drawMorningMark(progress: Float, rotation: Float, accent: Color, horizon: Color) {
+ val unit = size.width
+ val center = Offset(unit * 0.5f, unit * (0.76f - 0.32f * progress))
+ clipRect(bottom = unit * 0.73f) {
+ drawCircle(accent, radius = unit * 0.17f, center = center)
+ rotate(rotation, pivot = center) {
+ drawSunRays(center, unit * 0.24f, unit * 0.31f, accent, ((progress - 0.3f) / 0.7f).coerceIn(0f, 1f))
+ }
+ }
+ drawHorizon(unit * 0.75f, horizon)
+}
+
+private fun DrawScope.drawAfternoonMark(progress: Float, rotation: Float, accent: Color) {
+ val unit = size.width
+ drawCircle(accent, radius = unit * 0.17f, center = center)
+ rotate(rotation) {
+ drawSunRays(center, unit * 0.24f, unit * (0.26f + 0.1f * progress), accent, 0.5f + 0.5f * progress)
+ }
+}
+
+private fun DrawScope.drawEveningMark(progress: Float, rotation: Float, accent: Color, horizon: Color) {
+ val unit = size.width
+ val sun = Offset(unit * 0.5f, unit * (0.43f + 0.27f * progress))
+ clipRect(bottom = unit * 0.67f) {
+ drawCircle(accent, radius = unit * 0.19f, center = sun)
+ rotate(rotation, pivot = sun) {
+ drawSunRays(sun, unit * 0.26f, unit * 0.33f, accent, 1f - 0.4f * progress)
+ }
+ }
+ drawHorizon(unit * 0.7f, horizon)
+}
+
+private fun DrawScope.drawSunRays(center: Offset, inner: Float, outer: Float, color: Color, alpha: Float) {
+ repeat(8) { index ->
+ val angle = index * Math.PI / 4.0
+ val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat())
+ drawLine(
+ color = color,
+ start = center + direction * inner,
+ end = center + direction * outer,
+ strokeWidth = size.width * 0.047f,
+ cap = StrokeCap.Round,
+ alpha = alpha,
+ )
+ }
+}
+
+private fun DrawScope.drawHorizon(y: Float, color: Color) {
+ drawLine(
+ color = color,
+ start = Offset(size.width * 0.2f, y),
+ end = Offset(size.width * 0.8f, y),
+ strokeWidth = size.width * 0.047f,
+ cap = StrokeCap.Round,
+ alpha = 0.6f,
+ )
+}
+
+private fun crescentPath(unit: Float): Path {
+ val disk = Path().apply { addOval(Rect(unit * 0.14f, unit * 0.2f, unit * 0.74f, unit * 0.8f)) }
+ val cutout = Path().apply { addOval(Rect(unit * 0.33f, unit * 0.1f, unit * 0.85f, unit * 0.62f)) }
+ return Path.combine(PathOperation.Difference, disk, cutout)
+}
+
+private fun starPath(unit: Float): Path = Path().apply {
+ moveTo(unit * 0.8f, unit * 0.15f)
+ lineTo(unit * 0.82f, unit * 0.21f)
+ lineTo(unit * 0.88f, unit * 0.23f)
+ lineTo(unit * 0.82f, unit * 0.25f)
+ lineTo(unit * 0.8f, unit * 0.31f)
+ lineTo(unit * 0.78f, unit * 0.25f)
+ lineTo(unit * 0.72f, unit * 0.23f)
+ lineTo(unit * 0.78f, unit * 0.21f)
+ close()
+}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryRail.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryRail.kt
new file mode 100644
index 000000000..5604df8ac
--- /dev/null
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryRail.kt
@@ -0,0 +1,23 @@
+package cx.aswin.boxlore.feature.home.components
+
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+
+@Composable
+internal fun HomeDiscoveryRail(
+ modifier: Modifier = Modifier,
+ compact: Boolean = false,
+ content: LazyListScope.(Dp) -> Unit,
+) {
+ BoxWithConstraints(modifier.fillMaxWidth()) {
+ val cardWidth = HomeFeedSpacing.railCardWidth(maxWidth, compact)
+ LazyRow(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(HomeFeedSpacing.RailItemGap)) {
+ content(cardWidth)
+ }
+ }
+}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeFeedSpacing.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeFeedSpacing.kt
index 3e6648900..29399ebfc 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeFeedSpacing.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeFeedSpacing.kt
@@ -6,13 +6,23 @@ import cx.aswin.boxlore.core.designsystem.components.FeedPosterSpacing
/** Shared Home discovery spacing — rails, grids, and card feet. */
internal object HomeFeedSpacing {
- /** Two complete cards plus a clear preview of card three on every phone width. */
- const val RAIL_CARD_WIDTH_FRACTION = 0.41f
+ /** Phone time rails expose a partial second card to make horizontal scrolling visible. */
+ const val RAIL_CARD_WIDTH_FRACTION = 0.52f
+ val RailCardMaxWidth = 208.dp
+ const val COMPACT_RAIL_CARD_WIDTH_FRACTION = 0.42f
+ val CompactRailCardMaxWidth = 176.dp
val RailItemGap = 16.dp
val GridGap = FeedPosterSpacing.GridGap
+ val SectionGap = 32.dp
+ val RelatedRailGap = 20.dp
+ val HeaderContentGap = 12.dp
- /** 18sp line height reserved per title line in equal-height rail feet. */
- val RailTitleLineHeight = FeedPosterSpacing.TitleLineHeight
+ fun railCardWidth(viewportWidth: Dp, compact: Boolean = false): Dp =
+ if (compact) {
+ (viewportWidth * COMPACT_RAIL_CARD_WIDTH_FRACTION).coerceAtMost(CompactRailCardMaxWidth)
+ } else {
+ (viewportWidth * RAIL_CARD_WIDTH_FRACTION).coerceAtMost(RailCardMaxWidth)
+ }
val CardTextPadding = FeedPosterSpacing.CardTextPadding
@@ -20,6 +30,4 @@ internal object HomeFeedSpacing {
const val ForYouTotalCap = 9
const val ForYouBodyCount = 8
const val ExploreGridCap = 6
-
- fun railTextFootHeight(titleMaxLines: Int): Dp = FeedPosterSpacing.textFootHeight(titleMaxLines)
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeMixBackdrop.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeMixBackdrop.kt
new file mode 100644
index 000000000..283b237de
--- /dev/null
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeMixBackdrop.kt
@@ -0,0 +1,49 @@
+package cx.aswin.boxlore.feature.home.components
+
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.toShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.clipRect
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import cx.aswin.boxlore.core.designsystem.components.drawOutline
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+internal fun Modifier.homeMixBackdrop(enabled: Boolean): Modifier {
+ if (!enabled) return this
+ val color = MaterialTheme.colorScheme.tertiary
+ val upperShape = MaterialShapes.Puffy.toShape()
+ val lowerShape = MaterialShapes.Cookie6Sided.toShape()
+ return drawWithCache {
+ val upperSize = 180.dp.toPx()
+ val lowerSize = 210.dp.toPx()
+ val upperOutline = upperShape.createOutline(Size(upperSize, upperSize), layoutDirection, this)
+ val lowerOutline = lowerShape.createOutline(Size(lowerSize, lowerSize), layoutDirection, this)
+ onDrawBehind {
+ clipRect {
+ scale(scaleX = if (layoutDirection == LayoutDirection.Rtl) -1f else 1f, scaleY = 1f) {
+ translate(left = size.width - 112.dp.toPx(), top = -80.dp.toPx()) {
+ rotate(12f, pivot = Offset(upperSize / 2f, upperSize / 2f)) {
+ drawOutline(upperOutline, color, alpha = 0.05f)
+ }
+ }
+ translate(left = -84.dp.toPx(), top = size.height - 108.dp.toPx()) {
+ rotate(-18f, pivot = Offset(lowerSize / 2f, lowerSize / 2f)) {
+ drawOutline(lowerOutline, color, alpha = 0.04f)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeMixLayout.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeMixLayout.kt
index 000477082..af675e9b6 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeMixLayout.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeMixLayout.kt
@@ -5,8 +5,10 @@ import androidx.compose.ui.unit.dp
/** Shared loaded and loading geometry prevents the mix rail growing during reveal. */
internal object HomeMixLayout {
val VerticalPadding = 16.dp
- val HeaderHeight = 44.dp
+ val HeaderHeight = 48.dp
val HeaderGap = 12.dp
- val RailHeight = 124.dp
+ val CardWidth = 176.dp
+ val CardHeight = 224.dp
+ val RailHeight = CardHeight + 8.dp
val totalHeight = VerticalPadding * 2 + HeaderHeight + HeaderGap + RailHeight
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeSectionHeaders.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeSectionHeaders.kt
index 49f13652e..6ab564075 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeSectionHeaders.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeSectionHeaders.kt
@@ -1,23 +1,38 @@
package cx.aswin.boxlore.feature.home.components
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowForward
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.findRootCoordinates
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
+import cx.aswin.boxlore.core.catalog.content.ContentDaypart
+import cx.aswin.boxlore.core.designsystem.components.DiscoveryExpressiveTheme
import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight
import cx.aswin.boxlore.core.designsystem.theme.rememberSectionHeaderFontFamily
@@ -26,63 +41,111 @@ enum class HomeChildHeaderTone {
TERTIARY,
}
+/** Chapter identity belongs to the opening and browse action, never a feed-wide panel. */
+enum class HomeDiscoveryChapter {
+ LIBRARY,
+ PERSONAL,
+ MOMENT,
+ EXPLORE,
+}
+
@Composable
fun HomeTopLevelSectionHeader(
title: String,
- icon: ImageVector,
- seeAllIcon: ImageVector,
seeAllContentDescription: String,
onSeeAllClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
+ chapter: HomeDiscoveryChapter = HomeDiscoveryChapter.PERSONAL,
+ daypart: ContentDaypart? = null,
) {
- Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- ) {
+ var fullyVisible by remember { mutableStateOf(false) }
+ val visibilityModifier = if (daypart != null) {
+ Modifier.onGloballyPositioned { fullyVisible = it.isFullyVisibleInRoot() }
+ } else {
+ Modifier
+ }
+ DiscoveryExpressiveTheme {
Row(
- modifier = Modifier.weight(1f),
- horizontalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = modifier.fillMaxWidth().heightIn(min = 48.dp).then(visibilityModifier),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(24.dp),
- )
- Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
- Text(
- text = title,
- style =
- MaterialTheme.typography.headlineSmall.copy(
- fontFamily = rememberSectionHeaderFontFamily(),
- fontWeight = FontWeight.Normal,
- ),
- )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ daypart?.let { HomeDaypartMark(it, fullyVisible) }
+ Text(
+ text = title,
+ modifier = Modifier.weight(1f).semantics { heading() },
+ style = MaterialTheme.typography.headlineMedium.copy(
+ fontFamily = rememberSectionHeaderFontFamily(),
+ fontWeight = GoogleSansWeight.semiBold,
+ ),
+ )
+ }
subtitle?.let {
Text(
text = it,
+ modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
- }
- FilledTonalIconButton(
- onClick = onSeeAllClick,
- modifier = Modifier.size(32.dp),
- ) {
- Icon(
- imageVector = seeAllIcon,
- contentDescription = seeAllContentDescription,
- modifier = Modifier.size(18.dp),
- )
+ HomeChapterBrowseButton(chapter, seeAllContentDescription, onSeeAllClick)
}
}
}
+private fun LayoutCoordinates.isFullyVisibleInRoot(): Boolean {
+ if (!isAttached) return false
+ val root = findRootCoordinates()
+ val fullBounds = root.localBoundingBoxOf(this, clipBounds = false)
+ val viewport = Rect(0f, 0f, root.size.width.toFloat(), root.size.height.toFloat())
+ val visibleBounds = root.localBoundingBoxOf(this, clipBounds = true).intersect(viewport)
+ // Compare the whole header with its ancestor-clipped bounds, allowing subpixel rounding.
+ return fullBounds.width > 0f &&
+ fullBounds.height > 0f &&
+ visibleBounds.left <= fullBounds.left + 0.5f &&
+ visibleBounds.top <= fullBounds.top + 0.5f &&
+ visibleBounds.right >= fullBounds.right - 0.5f &&
+ visibleBounds.bottom >= fullBounds.bottom - 0.5f
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+internal fun HomeChapterBrowseButton(
+ chapter: HomeDiscoveryChapter,
+ description: String,
+ onClick: () -> Unit,
+ icon: ImageVector = Icons.AutoMirrored.Rounded.ArrowForward,
+) {
+ val shape = when (chapter) {
+ HomeDiscoveryChapter.LIBRARY -> MaterialShapes.Pill.toShape()
+ HomeDiscoveryChapter.PERSONAL -> MaterialShapes.Cookie4Sided.toShape()
+ HomeDiscoveryChapter.MOMENT -> MaterialShapes.Sunny.toShape()
+ HomeDiscoveryChapter.EXPLORE -> MaterialShapes.Cookie6Sided.toShape()
+ }
+ FilledTonalIconButton(
+ onClick = onClick,
+ modifier = Modifier.size(48.dp),
+ shapes = IconButtonDefaults.shapes(shape = shape, pressedShape = shape),
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ ) {
+ Icon(icon, contentDescription = description, modifier = Modifier.size(24.dp))
+ }
+}
+
@Composable
fun HomeChildSectionHeader(
title: String,
@@ -91,44 +154,24 @@ fun HomeChildSectionHeader(
subtitle: String? = null,
tone: HomeChildHeaderTone = HomeChildHeaderTone.PRIMARY,
) {
- val containerColor =
- when (tone) {
- HomeChildHeaderTone.PRIMARY -> MaterialTheme.colorScheme.primaryContainer
- HomeChildHeaderTone.TERTIARY -> MaterialTheme.colorScheme.tertiaryContainer
- }
val contentColor =
when (tone) {
- HomeChildHeaderTone.PRIMARY -> MaterialTheme.colorScheme.onPrimaryContainer
- HomeChildHeaderTone.TERTIARY -> MaterialTheme.colorScheme.onTertiaryContainer
+ HomeChildHeaderTone.PRIMARY -> MaterialTheme.colorScheme.primary
+ HomeChildHeaderTone.TERTIARY -> MaterialTheme.colorScheme.tertiary
}
Row(
- modifier = modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = modifier.fillMaxWidth().heightIn(min = 36.dp),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Surface(
- modifier = Modifier.size(36.dp),
- shape = MaterialTheme.shapes.medium,
- color = containerColor,
- contentColor = contentColor,
- ) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center,
- ) {
- Icon(
- imageVector = icon,
- contentDescription = null,
- modifier = Modifier.size(20.dp),
- )
- }
- }
+ Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(28.dp), tint = contentColor)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title,
+ modifier = Modifier.fillMaxWidth().semantics { heading() },
style = MaterialTheme.typography.titleMedium,
fontWeight = GoogleSansWeight.semiBold,
)
@@ -137,7 +180,6 @@ fun HomeChildSectionHeader(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
)
}
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeSkeleton.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeSkeleton.kt
index 2978a7e9d..d9deb2fa1 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeSkeleton.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/HomeSkeleton.kt
@@ -30,6 +30,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardSkeleton
import cx.aswin.boxlore.core.designsystem.components.FeedPosterSpacing
import cx.aswin.boxlore.core.designsystem.theme.m3Shimmer
@@ -118,7 +120,7 @@ fun RisingSkeleton() {
fun androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope.GridSkeletonItems() {
items(6) {
- GridSkeletonItem()
+ HomeMediaCardSkeleton()
}
}
@@ -303,24 +305,34 @@ fun YourShowsSkeleton(
/** One loading rail occupies the same keyed section as its eventual editorial row. */
@Composable
-fun EditorialRowSkeleton(isLast: Boolean = false, modifier: Modifier = Modifier) {
- val baseColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
- val highlightColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f)
+fun EditorialRowSkeleton(
+ title: String,
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ isLast: Boolean = false,
+ modifier: Modifier = Modifier,
+ tone: HomeChildHeaderTone = HomeChildHeaderTone.PRIMARY,
+) {
Column(
- modifier = modifier.padding(bottom = if (isLast) 20.dp else 12.dp),
- verticalArrangement = Arrangement.spacedBy(10.dp),
+ modifier = modifier.padding(bottom = if (isLast) 0.dp else HomeFeedSpacing.RelatedRailGap - HomeFeedSpacing.GridGap),
+ verticalArrangement = Arrangement.spacedBy(HomeFeedSpacing.HeaderContentGap),
) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Box(
- modifier = Modifier.size(36.dp).m3Shimmer(baseColor, highlightColor, shape = MaterialTheme.shapes.medium),
- )
- Spacer(modifier = Modifier.width(12.dp))
- SkeletonBlock(width = 132.dp, height = 17.dp, baseColor = baseColor, highlightColor = highlightColor)
- }
- LazyRow(horizontalArrangement = Arrangement.spacedBy(HomeFeedSpacing.RailItemGap)) {
+ HomeChildSectionHeader(title = title, icon = icon, tone = tone)
+ HomeDiscoveryRail { cardWidth ->
items(4) {
- GridSkeletonItem(modifier = Modifier.fillParentMaxWidth(HomeFeedSpacing.RAIL_CARD_WIDTH_FRACTION))
+ HomeMediaCardSkeleton(modifier = Modifier.width(cardWidth))
}
}
}
}
+
+@Composable
+internal fun HomeMediaCardSkeleton(
+ modifier: Modifier = Modifier,
+ presentation: FeedMediaCardPresentation = FeedMediaCardPresentation.ExpressivePoster,
+) {
+ val baseColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
+ val highlightColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f)
+ FeedMediaCardSkeleton(modifier = modifier, presentation = presentation) { placeholderModifier ->
+ Box(placeholderModifier.m3Shimmer(baseColor, highlightColor))
+ }
+}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySection.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySection.kt
index ea5c4b0bc..07d2a91bf 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySection.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySection.kt
@@ -34,12 +34,11 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Bookmark
+import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
@@ -58,7 +57,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
@@ -72,6 +71,7 @@ import cx.aswin.boxlore.core.designsystem.theme.expressiveClickable
import cx.aswin.boxlore.core.designsystem.theme.rememberSectionHeaderFontFamily
import cx.aswin.boxlore.core.model.Episode
import cx.aswin.boxlore.core.model.Podcast
+import cx.aswin.boxlore.feature.home.R
import cx.aswin.boxlore.feature.home.StableCompletedDownloadList
import cx.aswin.boxlore.feature.home.StableEpisodeList
import cx.aswin.boxlore.feature.home.StablePlaybackStateMap
@@ -216,18 +216,18 @@ fun YourShowsSection(
},
) {
Icon(
- imageVector = Icons.Rounded.Bookmark,
+ imageVector = Icons.Outlined.Bookmarks,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "Your Shows",
+ text = stringResource(R.string.home_your_shows_heading),
style =
- MaterialTheme.typography.headlineSmall.copy(
+ MaterialTheme.typography.headlineMedium.copy(
fontFamily = rememberSectionHeaderFontFamily(),
- fontWeight = FontWeight.Normal,
+ fontWeight = GoogleSansWeight.semiBold,
),
letterSpacing = (-0.5).sp,
)
@@ -263,17 +263,12 @@ fun YourShowsSection(
}
}
- FilledTonalIconButton(
+ HomeChapterBrowseButton(
+ chapter = HomeDiscoveryChapter.LIBRARY,
+ description = stringResource(R.string.home_view_library),
onClick = onViewLibrary,
- modifier = Modifier.size(32.dp),
- ) {
- Icon(
- imageVector = Icons.Rounded.ChevronRight,
- contentDescription = "View Library",
- tint = MaterialTheme.colorScheme.onSecondaryContainer,
- modifier = Modifier.size(18.dp),
- )
- }
+ icon = Icons.Rounded.ChevronRight,
+ )
}
// --- The Selector Grid ---
@@ -551,7 +546,7 @@ fun YourShowsSection(
) {
OptimizedImage(
url = (selectedPodcast.imageUrl.takeIf { it.isNotEmpty() } ?: selectedPodcast.fallbackImageUrl),
- proxyWidth = 88,
+ proxyWidth = 256,
contentDescription = selectedPodcast.title,
contentScale = ContentScale.Crop,
modifier =
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySectionMixtape.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySectionMixtape.kt
index dff84bcfd..1d3c007eb 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySectionMixtape.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySectionMixtape.kt
@@ -125,13 +125,24 @@ internal fun HomeMixModule(
}
val visibleItemCount =
when (mode) {
- HomeMixMode.DAILY -> dailyPodcasts.size
+ HomeMixMode.DAILY -> dailyPodcasts.count { it.latestEpisode != null }
HomeMixMode.OFFLINE -> visibleDownloads.size
}
+ if (mode == HomeMixMode.DAILY && visibleItemCount == 0) {
+ HomeMixDropdownHeading(
+ mode = mode,
+ subtitle = stringResource(R.string.home_mix_caught_up_title),
+ enabled = canOfferOffline,
+ onModeSelected = onModeChanged,
+ modifier = modifier.fillMaxWidth().padding(vertical = HomeMixLayout.VerticalPadding, horizontal = 18.dp),
+ )
+ return
+ }
Column(
modifier =
modifier
.fillMaxWidth()
+ .homeMixBackdrop(enabled = visibleItemCount > 0)
.padding(vertical = HomeMixLayout.VerticalPadding),
) {
HomeMixHeader(
@@ -580,7 +591,7 @@ private fun ViewAllDownloadsCard(onClick: () -> Unit) {
modifier =
Modifier
.width(148.dp)
- .height(116.dp)
+ .height(HomeMixLayout.CardHeight)
.expressiveClickable(
shape = RoundedCornerShape(20.dp),
onClick = onClick,
@@ -596,7 +607,7 @@ private fun ViewAllDownloadsCard(onClick: () -> Unit) {
) {
Surface(
shape = CircleShape,
- color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(40.dp),
) {
@@ -704,40 +715,43 @@ internal fun MixtapeEpisodeCard(
val isCompleted = status == EpisodeStatus.COMPLETED
val isCurrentPlaying = currentPlayingEpisodeId == episode.id && isPlaying
+ val cardShape = RoundedCornerShape(24.dp)
Card(
- shape = RoundedCornerShape(20.dp),
+ shape = cardShape,
colors =
CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ containerColor = if (isCurrentPlaying) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainer,
),
modifier =
modifier
- .width(264.dp)
- .height(116.dp)
- .expressiveClickable(shape = RoundedCornerShape(20.dp), onClick = onClick),
+ .width(HomeMixLayout.CardWidth)
+ .height(HomeMixLayout.CardHeight)
+ .expressiveClickable(shape = cardShape, onClick = onClick),
) {
Box(modifier = Modifier.fillMaxSize()) {
- Row(
+ Column(
modifier =
Modifier
.fillMaxSize()
- .padding(10.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp),
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
) {
- // Left: Cover art with download & played badges
+ // Artwork with duration, playback and status controls.
Box(
modifier =
Modifier
- .size(76.dp)
- .clip(RoundedCornerShape(14.dp)),
+ .fillMaxWidth()
+ .height(112.dp)
+ .clip(RoundedCornerShape(12.dp)),
) {
OptimizedImage(
- url = (
- episode.imageUrl?.takeIf { it.isNotEmpty() } ?: podcast.imageUrl.takeIf { it.isNotEmpty() }
- ?: podcast.fallbackImageUrl
- ),
- proxyWidth = 152,
+ url = resolveMixArtwork(
+ episode.imageUrl,
+ episode.podcastImageUrl,
+ podcast.imageUrl,
+ podcast.fallbackImageUrl,
+ ),
+ proxyWidth = 512,
contentDescription = episode.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
@@ -762,11 +776,11 @@ internal fun MixtapeEpisodeCard(
}
}
- if (isDownloaded) {
+ if (isDownloaded && !isCompleted) {
Box(
modifier =
Modifier
- .align(Alignment.TopStart)
+ .align(Alignment.TopEnd)
.padding(4.dp)
.size(18.dp)
.background(MaterialTheme.colorScheme.secondaryContainer, CircleShape)
@@ -782,58 +796,66 @@ internal fun MixtapeEpisodeCard(
}
}
- Box(
- modifier =
- Modifier
- .align(Alignment.BottomEnd)
- .padding(4.dp),
- ) {
+ if (episode.duration > 0) {
+ val h = episode.duration / 3600
+ val m = (episode.duration % 3600) / 60
+ val timeText =
+ if (isInProgress && progress > 0f) {
+ stringResource(R.string.home_mix_minutes_left, remainingMixMinutes(episode.duration, progress))
+ } else {
+ if (h > 0) "${h}h ${m}m" else "${m}m"
+ }
Surface(
- onClick = onPlay,
- shape = CircleShape,
- color = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
- shadowElevation = 2.dp,
- modifier = Modifier.size(36.dp),
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.align(Alignment.TopStart).padding(6.dp),
) {
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier.fillMaxSize(),
- ) {
- Icon(
- imageVector =
- if (isCurrentPlaying) {
- Icons.Rounded.Pause
- } else {
- Icons.Rounded.PlayArrow
- },
- contentDescription = if (isCurrentPlaying) "Pause" else "Play",
- modifier = Modifier.size(19.dp),
- )
- }
+ Text(
+ text = timeText,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = GoogleSansWeight.medium,
+ modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
+ )
+ }
+ }
+
+ Surface(
+ onClick = onPlay,
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ shadowElevation = 0.dp,
+ modifier = Modifier.align(Alignment.BottomEnd).padding(6.dp).size(48.dp),
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ Icon(
+ imageVector =
+ if (isCurrentPlaying) {
+ Icons.Rounded.Pause
+ } else {
+ Icons.Rounded.PlayArrow
+ },
+ contentDescription = if (isCurrentPlaying) "Pause" else "Play",
+ modifier = Modifier.size(24.dp),
+ )
}
}
}
- // Center: Info Column (Titles + Metadata)
+ // Center the titles in the space remaining above any progress indicator.
Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.fillMaxWidth().weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
) {
- Text(
- text = podcast.title,
- style = MaterialTheme.typography.labelMedium,
- fontWeight = GoogleSansWeight.medium,
- color = MaterialTheme.colorScheme.primary,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
-
Text(
text = episode.title,
style =
MaterialTheme.typography.titleSmall.copy(
- fontWeight = GoogleSansWeight.bold,
+ fontWeight = GoogleSansWeight.semiBold,
lineHeight = 18.sp,
),
maxLines = 2,
@@ -841,92 +863,25 @@ internal fun MixtapeEpisodeCard(
color = MaterialTheme.colorScheme.onSurface,
)
- Row(
- horizontalArrangement = Arrangement.spacedBy(5.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- val isNew =
- status == EpisodeStatus.UNPLAYED &&
- podcast.subscribedAt > 0L &&
- episode.publishedDate > (podcast.subscribedAt / 1000L - 7 * 24 * 3600L)
- if (isNew) {
- Box(
- modifier =
- Modifier
- .clip(RoundedCornerShape(4.dp))
- .background(MaterialTheme.colorScheme.primaryContainer)
- .padding(horizontal = 5.dp, vertical = 1.dp),
- ) {
- Text(
- text = "NEW",
- style =
- MaterialTheme.typography.labelSmall.copy(
- fontWeight = GoogleSansWeight.bold,
- fontSize = 9.sp,
- letterSpacing = 0.4.sp,
- ),
- color = MaterialTheme.colorScheme.onPrimaryContainer,
- )
- }
- }
-
- val relativeDate = formatRelativeDate(episode.publishedDate)
- if (relativeDate.isNotEmpty()) {
- val prefix = if (isNew) "• " else ""
- Text(
- text = "$prefix$relativeDate",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- if (episode.duration > 0) {
- Text(
- text = "•",
- style = MaterialTheme.typography.labelSmall,
- color = MaterialTheme.colorScheme.outline,
- )
- }
- }
-
- if (episode.duration > 0) {
- val h = episode.duration / 3600
- val m = (episode.duration % 3600) / 60
- val timeText =
- if (isInProgress && progress > 0f) {
- val remaining = ((1f - progress) * episode.duration).toInt()
- val rm = (remaining % 3600) / 60
- "${rm}m left"
- } else {
- if (h > 0) "${h}h ${m}m" else "${m}m"
- }
- Text(
- text = timeText,
- style = MaterialTheme.typography.labelSmall,
- fontWeight = GoogleSansWeight.medium,
- color =
- if (isInProgress) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurfaceVariant
- },
- )
- }
- }
+ Text(
+ text = podcast.title,
+ style = MaterialTheme.typography.labelSmall,
+ fontWeight = GoogleSansWeight.medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
}
- }
- // Absolute Bottom: Progress Bar spanning full card width
- if (isInProgress && progress > 0f) {
- LinearProgressIndicator(
- progress = { progress },
- modifier =
- Modifier
- .align(Alignment.BottomCenter)
- .fillMaxWidth()
- .height(3.dp),
- color = MaterialTheme.colorScheme.primary,
- trackColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
- drawStopIndicator = {},
- )
+ if (isInProgress && progress > 0f) {
+ LinearProgressIndicator(
+ progress = { progress.coerceIn(0f, 1f) },
+ modifier = Modifier.fillMaxWidth().height(4.dp),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.secondaryContainer,
+ drawStopIndicator = {},
+ )
+ }
}
}
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySectionRows.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySectionRows.kt
index 841fbf1ce..1fbff7dcd 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySectionRows.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/LibrarySectionRows.kt
@@ -117,7 +117,7 @@ internal fun SelectorCover(
) {
OptimizedImage(
url = podcast.imageUrl.takeIf { it.isNotEmpty() } ?: podcast.fallbackImageUrl,
- proxyWidth = 120,
+ proxyWidth = 320,
contentDescription = podcast.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().alpha(alpha),
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/MixArtwork.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/MixArtwork.kt
new file mode 100644
index 000000000..f66163b5b
--- /dev/null
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/MixArtwork.kt
@@ -0,0 +1,11 @@
+package cx.aswin.boxlore.feature.home.components
+
+/** Feed artwork may live on either the episode or its parent show. */
+internal fun resolveMixArtwork(
+ episodeImage: String?,
+ episodeShowImage: String?,
+ showImage: String?,
+ fallbackImage: String?,
+): String? = listOf(episodeImage, episodeShowImage, showImage, fallbackImage)
+ .firstOrNull { !it.isNullOrBlank() }
+ ?.trim()
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/MixTime.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/MixTime.kt
new file mode 100644
index 000000000..06f68fd6f
--- /dev/null
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/MixTime.kt
@@ -0,0 +1,7 @@
+package cx.aswin.boxlore.feature.home.components
+
+/** Total remaining whole minutes, including hours; a partial last minute still displays one. */
+internal fun remainingMixMinutes(durationSeconds: Int, progress: Float): Int {
+ val remainingSeconds = ((1f - progress) * durationSeconds).toInt()
+ return (remainingSeconds / 60).coerceAtLeast(1)
+}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/PodcastCard.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/PodcastCard.kt
index ecd7ce25e..6d51bfff5 100644
--- a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/PodcastCard.kt
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/PodcastCard.kt
@@ -13,12 +13,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cx.aswin.boxlore.core.designsystem.components.FeedMediaCard
import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardDensity
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation
import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight
import cx.aswin.boxlore.core.model.Podcast
+import cx.aswin.boxlore.feature.home.R
@Composable
fun PodcastCard(
@@ -28,6 +31,7 @@ fun PodcastCard(
showGenreChip: Boolean = false,
showSubtitle: Boolean = true,
density: FeedMediaCardDensity = FeedMediaCardDensity.Grid,
+ presentation: FeedMediaCardPresentation = FeedMediaCardPresentation.Default,
) {
FeedMediaCard(
imageUrl = podcast.imageUrl,
@@ -35,8 +39,10 @@ fun PodcastCard(
subtitle = if (showSubtitle) podcast.artist.replace("+", " ") else null,
onClick = onClick,
modifier = modifier,
+ presentation = presentation,
titleMaxLines =
when {
+ presentation != FeedMediaCardPresentation.Default -> 3
showSubtitle -> 2
density == FeedMediaCardDensity.Rail -> 2
else -> 3
@@ -65,7 +71,7 @@ fun PodcastCard(
if (podcast.medium == "video" || podcast.latestEpisode?.enclosureType?.startsWith("video/") == true) {
Surface(
shape = androidx.compose.foundation.shape.CircleShape,
- color = Color.Black.copy(alpha = 0.55f),
+ color = if (presentation == FeedMediaCardPresentation.Default) Color.Black.copy(alpha = 0.55f) else MaterialTheme.colorScheme.secondaryContainer,
modifier =
Modifier
.padding(8.dp)
@@ -77,8 +83,8 @@ fun PodcastCard(
) {
Icon(
imageVector = Icons.Rounded.Videocam,
- contentDescription = "Video",
- tint = Color.White,
+ contentDescription = stringResource(R.string.home_video_type),
+ tint = if (presentation == FeedMediaCardPresentation.Default) Color.White else MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp),
)
}
diff --git a/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/RecommendationHeaderIcons.kt b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/RecommendationHeaderIcons.kt
new file mode 100644
index 000000000..08dd612c1
--- /dev/null
+++ b/feature/home/src/main/java/cx/aswin/boxlore/feature/home/components/RecommendationHeaderIcons.kt
@@ -0,0 +1,41 @@
+package cx.aswin.boxlore.feature.home.components
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.unit.dp
+
+/** Paired cover-based marks for the related-show and episode rails. */
+internal object RecommendationHeaderIcons {
+ val YourShows = mark(
+ "YourShows",
+ "M3 4H5V19H3Q2 19 2 18V5Q2 4 3 4Z M7 2H9V19H7Z " +
+ "M13 4H20Q22 4 22 6V19Q22 21 20 21H13Q11 21 11 19V6Q11 4 13 4Z " +
+ "M13 6V19H20V6Z M15 9H18V12H15Z M14 15H19V16.5H14Z",
+ )
+ val SimilarShows = mark(
+ "SimilarShows",
+ "M3 3H12Q15 3 15 6V15Q15 18 12 18H3Q0 18 0 15V6Q0 3 3 3Z " +
+ "M3 5V16H12Q13 16 13 15V6Q13 5 12 5Z " +
+ "M17 5L22 6Q24 6.4 23.5 9L21 20Q20.5 22.5 18 22L8 20Q6 19.6 6 19L12 19Q16 19 16 15V6Z " +
+ "M7.5 7Q10 7 10 9.5Q10 12 7.5 12Q5 12 5 9.5Q5 7 7.5 7Z M5 13H10V14.5H5Z",
+ )
+ val EpisodesToTry = mark(
+ "EpisodesToTry",
+ "M8 2L20 5Q23 5.8 22.2 9L19.5 20Q18.8 23 15.8 22.2L3.8 19.2Q1 18.5 1.8 15.5L4.5 4Q5.2 1.3 8 2Z " +
+ "M10 7L8 15L16 12Z M4 21L14 23H5Q3 23 3 21Z",
+ )
+
+ private fun mark(name: String, data: String): ImageVector = ImageVector.Builder(
+ name = name,
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f,
+ ).addPath(
+ pathData = PathParser().parsePathString(data).toNodes(),
+ fill = androidx.compose.ui.graphics.SolidColor(Color.Black),
+ pathFillType = PathFillType.EvenOdd,
+ ).build()
+}
diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml
index 8f9dc8a59..90a9e27aa 100644
--- a/feature/home/src/main/res/values/strings.xml
+++ b/feature/home/src/main/res/values/strings.xml
@@ -13,4 +13,23 @@
You’re all caught up
New drops and active sessions will show up here.
All downloads
+ %1$dm left
+ Because you like
+ Change show for recommendations
+ Similar shows
+ Episodes to try
+ Popular in your region
+ Picked for you
+ Browse recommended episodes
+ Browse more episodes
+ Explore shows
+ Browse shows
+ View more in %1$s
+ View more shows
+ Video
+ Top
+ More
+ Browse genres
+ Your shows
+ View library
diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryCardTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryCardTest.kt
new file mode 100644
index 000000000..cf6c752f2
--- /dev/null
+++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryCardTest.kt
@@ -0,0 +1,418 @@
+package cx.aswin.boxlore.feature.home.components
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.view.View
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.test.core.app.ApplicationProvider
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCard
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardSkeleton
+import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme
+import cx.aswin.boxlore.core.designsystem.theme.SurfaceStyles
+import cx.aswin.boxlore.core.model.Podcast
+import cx.aswin.boxlore.feature.home.R
+import cx.aswin.boxlore.feature.home.StableEpisodeList
+import cx.aswin.boxlore.feature.home.StablePodcastList
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], qualifiers = "w900dp-h1200dp-mdpi")
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class HomeDiscoveryCardTest {
+ @get:Rule val composeRule = createComposeRule()
+
+ private data class Scenario(
+ val width: Dp = 156.dp,
+ val fontScale: Float = 1f,
+ val direction: LayoutDirection = LayoutDirection.Ltr,
+ val title: String = "Short",
+ val presentation: FeedMediaCardPresentation = FeedMediaCardPresentation.ExpressivePoster,
+ val dark: Boolean = false,
+ val surface: String = SurfaceStyles.CLASSIC_DYNAMIC,
+ val dynamic: Boolean = false,
+ )
+
+ private val scenario = mutableStateOf(Scenario())
+ private var density = 1f
+ private var containerColor = Color.Unspecified
+ private lateinit var view: View
+
+ @Test fun `poster centers shorter titles within three reserved lines and matches skeleton at phone widths and enlarged text`() {
+ render()
+ for (screenWidth in listOf(320, 360, 412)) {
+ for (scale in listOf(1f, 1.3f, 2f)) {
+ for (direction in LayoutDirection.entries) {
+ scenario.value = Scenario(width = ((screenWidth - 48) / 2f).dp, fontScale = scale, direction = direction)
+ composeRule.waitForIdle()
+ assertMatchingHeights()
+ val card = composeRule.onNodeWithTag("real").fetchSemanticsNode().boundsInRoot
+ val text = composeRule.onNodeWithText("Short", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
+ val artworkBottom = card.top + scenario.value.width.value * density
+ assertEquals((artworkBottom + card.bottom) / 2f, text.center.y, 1f)
+ assertTrue(text.top > artworkBottom + 12f * density)
+ assertEquals(text.top - artworkBottom - 12f * density, card.bottom - text.bottom - 12f * density, 1f)
+ assertFalse(titleLayout().didOverflowHeight)
+ }
+ }
+ }
+ }
+
+ @Test fun `long titles ellipsize at line three in LTR and RTL`() {
+ render()
+ val variants = listOf(FeedMediaCardPresentation.ExpressivePoster, FeedMediaCardPresentation.ExpressiveFeatured)
+ .flatMap { presentation -> LayoutDirection.entries.map { Scenario(width = 288.dp, presentation = presentation, direction = it) } }
+ for (value in variants) {
+ scenario.value = value.copy(title = "A detailed episode title with many words ".repeat(12), fontScale = 2f)
+ composeRule.waitForIdle()
+ val layout = titleLayout()
+ assertEquals(3, layout.lineCount)
+ assertTrue(layout.isLineEllipsized(2))
+ assertMatchingHeights()
+ }
+ }
+
+ @Test fun `featured recommendation matches its skeleton and scales without clipping`() {
+ render()
+ val variants = listOf(288.dp, 328.dp, 380.dp).map { Scenario(width = it, presentation = FeedMediaCardPresentation.ExpressiveFeatured) }
+ .flatMap { value -> listOf(1f, 2f).map { value.copy(fontScale = it) } }
+ .flatMap { value -> LayoutDirection.entries.map { value.copy(direction = it) } }
+ for (value in variants) {
+ scenario.value = value
+ composeRule.waitForIdle()
+ assertMatchingHeights()
+ assertFalse(titleLayout().didOverflowHeight)
+ assertEquals(16f, titleLayout().layoutInput.style.fontSize.value, 0f)
+ val card = composeRule.onNodeWithTag("real").fetchSemanticsNode().boundsInRoot
+ if (value.fontScale == 1f) assertEquals("Featured cards stay 152dp tall at normal text size", 152f * density, card.height, 1f)
+ val text = composeRule.onNodeWithText("Short", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
+ assertTrue(text.left >= card.left && text.right <= card.right)
+ if (value.direction == LayoutDirection.Ltr) {
+ assertEquals(card.left + 16f * density, text.left, 1f)
+ assertTrue(text.right <= card.right - card.width * 0.44f - 16f * density)
+ } else {
+ assertEquals(card.right - 16f * density, text.right, 1f)
+ assertTrue(text.left >= card.left + card.width * 0.44f + 16f * density)
+ }
+ }
+ }
+
+ @Test fun `featured artwork reaches the end edge with a sweeping curved join mirrored in RTL`() {
+ composeRule.setContent {
+ val value = scenario.value
+ view = LocalView.current
+ density = LocalDensity.current.density
+ CompositionLocalProvider(LocalLayoutDirection provides value.direction) {
+ BoxLoreTheme(dynamicColor = false) {
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer
+ FeedMediaCardSkeleton(
+ Modifier.width(328.dp).testTag("featured_curve"),
+ presentation = FeedMediaCardPresentation.ExpressiveFeatured,
+ ) { Box(it.background(Color.Magenta)) }
+ }
+ }
+ }
+ for (direction in LayoutDirection.entries) {
+ scenario.value = Scenario(direction = direction)
+ composeRule.waitForIdle()
+ val bounds = composeRule.onNodeWithTag("featured_curve").fetchSemanticsNode().boundsInRoot
+ val pixels = composeRule.runOnIdle {
+ val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ view.draw(Canvas(bitmap))
+ fun endX(offset: Float) = if (direction == LayoutDirection.Ltr) bounds.right - offset else bounds.left + offset
+ val joinX = endX(bounds.width * 0.44f - 4f * density).toInt()
+ listOf(
+ bitmap.getPixel(endX(2f * density).toInt(), bounds.center.y.toInt()),
+ bitmap.getPixel(joinX, (bounds.top + 4f * density).toInt()),
+ bitmap.getPixel(joinX, bounds.center.y.toInt()),
+ ).also { bitmap.recycle() }
+ }
+ assertEquals(listOf(Color.Magenta.toArgb(), containerColor.toArgb(), Color.Magenta.toArgb()), pixels)
+ }
+ }
+
+ @Test fun `default presentation retains the existing fixed title foot`() {
+ scenario.value = Scenario(presentation = FeedMediaCardPresentation.Default)
+ render()
+ val card = composeRule.onNodeWithTag("real").fetchSemanticsNode().boundsInRoot
+ assertEquals((156f + 78f) * density, card.height, 1f)
+ }
+
+ @Test fun `seed retains original content and softly clipped body shapes with enlarged text and RTL`() {
+ val title = "A show with a very long name that needs to be ellipsized across the available title space"
+ var upperDecorationColor = Color.Unspecified
+ var lowerDecorationColor = Color.Unspecified
+ scenario.value = Scenario(width = 288.dp, title = title)
+ composeRule.setContent {
+ val value = scenario.value
+ view = LocalView.current
+ density = LocalDensity.current.density
+ CompositionLocalProvider(
+ LocalDensity provides Density(density, value.fontScale),
+ LocalLayoutDirection provides value.direction,
+ ) {
+ BoxLoreTheme(darkTheme = value.dark, dynamicColor = value.dynamic, surfaceStyle = value.surface) {
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ upperDecorationColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.10f).compositeOver(containerColor)
+ lowerDecorationColor = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.12f).compositeOver(containerColor)
+ BecauseYouLikeSection(
+ podcast = Podcast(id = "seed", title = value.title, artist = "", imageUrl = ""),
+ recommendations = StableEpisodeList(emptyList()),
+ suggestedPodcasts = StablePodcastList(emptyList()),
+ currentPlayingEpisodeId = null,
+ isPlaying = false,
+ onEpisodeClick = { _, _ -> },
+ onPlayEpisode = { _, _ -> },
+ onPodcastClick = {},
+ onChangePodcastClick = {},
+ modifier = Modifier.width(value.width).testTag("seed"),
+ )
+ }
+ }
+ }
+ val themes = listOf(Scenario(), Scenario(dark = true), Scenario(dark = true, surface = SurfaceStyles.AMOLED), Scenario(dynamic = true))
+ val variants = themes
+ .flatMap { theme -> listOf(288.dp, 328.dp).map { theme.copy(width = it, title = title) } }
+ .flatMap { value -> listOf(1f, 2f).map { value.copy(fontScale = it) } }
+ .flatMap { value -> LayoutDirection.entries.map { value.copy(direction = it) } }
+ for (value in variants) {
+ scenario.value = value
+ composeRule.waitForIdle()
+ val card = composeRule.onNodeWithTag("seed").fetchSemanticsNode().boundsInRoot
+ val text = composeRule.onNodeWithText(title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
+ val action = composeRule.onNodeWithContentDescription(ApplicationProvider.getApplicationContext().getString(R.string.home_change_recommendation_show))
+ .fetchSemanticsNode().boundsInRoot
+ assertTrue(text.left >= card.left && text.right <= card.right && text.bottom <= card.bottom)
+ assertFalse(text.overlaps(action))
+ assertEquals("The original swap button stays 40dp wide", 40f * density, action.width, 1f)
+ assertEquals("The original swap button stays 40dp tall", 40f * density, action.height, 1f)
+ val labelText = ApplicationProvider.getApplicationContext().getString(R.string.home_because_you_like)
+ val label = composeRule.onNodeWithText(labelText, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
+ assertTrue(label.bottom <= text.top)
+ assertEquals(2, titleLayout().lineCount)
+ assertTrue(titleLayout().isLineEllipsized(1))
+ assertTrue("The original seed reserves its 88dp minimum height", card.height >= 88f * density)
+ assertEquals("The original show title uses titleMedium", 16.sp, titleLayout().layoutInput.style.fontSize)
+ val pixels = composeRule.runOnIdle {
+ val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ view.draw(Canvas(bitmap))
+ fun bodyX(offset: Float): Int = if (value.direction == LayoutDirection.Ltr) {
+ (card.left + offset).toInt()
+ } else {
+ (card.right - offset).toInt()
+ }
+ listOf(
+ bitmap.getPixel(bodyX(3 * density), card.center.y.toInt()),
+ bitmap.getPixel(bodyX(card.width - 38 * density), (card.top + 4 * density).toInt()),
+ bitmap.getPixel(bodyX(maxOf(156 * density, card.width * 0.52f)), (card.bottom - 4 * density).toInt()),
+ ).also { bitmap.recycle() }
+ }
+ assertEquals("Decorations leave the artwork and emoji lane clear", containerColor.toArgb(), pixels[0])
+ assertPixelColor(upperDecorationColor, pixels[1])
+ assertPixelColor(lowerDecorationColor, pixels[2])
+ }
+ }
+
+ @Test fun `filled card follows light dark dynamic and pure black theme surfaces`() {
+ render()
+ val themes = listOf(Scenario(), Scenario(dark = true), Scenario(dark = true, surface = SurfaceStyles.AMOLED), Scenario(dynamic = true))
+ for (value in themes.flatMap { listOf(it, it.copy(presentation = FeedMediaCardPresentation.ExpressiveFeatured)) }) {
+ scenario.value = value
+ composeRule.waitForIdle()
+ val bounds = composeRule.onNodeWithTag("real").fetchSemanticsNode().boundsInRoot
+ val pixel = composeRule.runOnIdle {
+ val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ view.draw(Canvas(bitmap))
+ bitmap.getPixel(bounds.center.x.toInt(), (bounds.bottom - 10 * density).toInt()).also { bitmap.recycle() }
+ }
+ assertEquals(containerColor.toArgb(), pixel)
+ }
+ }
+
+ @Test fun `poster artwork retains curved lower corners in the shared loading and loaded layout`() {
+ composeRule.setContent {
+ view = LocalView.current
+ density = LocalDensity.current.density
+ BoxLoreTheme(dynamicColor = false) {
+ containerColor = MaterialTheme.colorScheme.surfaceContainer
+ FeedMediaCardSkeleton(Modifier.width(156.dp).testTag("curve")) { Box(it.background(Color.Magenta)) }
+ }
+ }
+ val bounds = composeRule.onNodeWithTag("curve").fetchSemanticsNode().boundsInRoot
+ val pixels = composeRule.runOnIdle {
+ val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ view.draw(Canvas(bitmap))
+ val bottom = (bounds.top + 155 * density).toInt()
+ listOf(
+ bitmap.getPixel((bounds.left + density).toInt(), bottom),
+ bitmap.getPixel(bounds.center.x.toInt(), bottom),
+ bitmap.getPixel((bounds.right - density).toInt(), bottom),
+ ).also { bitmap.recycle() }
+ }
+ assertEquals(listOf(containerColor.toArgb(), Color.Magenta.toArgb(), containerColor.toArgb()), pixels)
+ }
+
+ @Test fun `Because you like rails are smaller than editorial rails and both cap on wide screens`() {
+ val viewport = mutableStateOf(328.dp)
+ val compact = mutableStateOf(false)
+ composeRule.setContent {
+ density = LocalDensity.current.density
+ BoxLoreTheme(dynamicColor = false) {
+ HomeDiscoveryRail(Modifier.width(viewport.value), compact = compact.value) { width ->
+ item {
+ FeedMediaCard("", "Rail title", null, {}, modifier = Modifier.width(width).testTag("rail"), presentation = FeedMediaCardPresentation.ExpressivePoster)
+ }
+ }
+ }
+ }
+ assertEquals(171f * density, composeRule.onNodeWithTag("rail").fetchSemanticsNode().boundsInRoot.width, 1f)
+ compact.value = true
+ composeRule.waitForIdle()
+ assertEquals(138f * density, composeRule.onNodeWithTag("rail").fetchSemanticsNode().boundsInRoot.width, 1f)
+ viewport.value = 640.dp
+ composeRule.waitForIdle()
+ assertEquals(176f * density, composeRule.onNodeWithTag("rail").fetchSemanticsNode().boundsInRoot.width, 1f)
+ compact.value = false
+ composeRule.waitForIdle()
+ assertEquals(208f * density, composeRule.onNodeWithTag("rail").fetchSemanticsNode().boundsInRoot.width, 1f)
+ }
+
+ @Test fun `time rails show a visibly partial second card at phone widths enlarged text and RTL`() {
+ composeRule.setContent {
+ val value = scenario.value
+ density = LocalDensity.current.density
+ CompositionLocalProvider(
+ LocalDensity provides Density(density, value.fontScale),
+ LocalLayoutDirection provides value.direction,
+ ) {
+ BoxLoreTheme(dynamicColor = false) {
+ HomeDiscoveryRail(Modifier.width(value.width).testTag("time_rail")) { width ->
+ items(3) { index ->
+ FeedMediaCard(
+ "",
+ "Episode $index",
+ null,
+ {},
+ modifier = Modifier.width(width).testTag("time_card_$index"),
+ presentation = FeedMediaCardPresentation.ExpressivePoster,
+ )
+ }
+ }
+ }
+ }
+ }
+ val variants = listOf(288.dp, 328.dp, 380.dp).map { Scenario(width = it) }
+ .flatMap { value -> listOf(1f, 2f).map { value.copy(fontScale = it) } }
+ .flatMap { value -> LayoutDirection.entries.map { value.copy(direction = it) } }
+ for (value in variants) {
+ scenario.value = value
+ composeRule.waitForIdle()
+ val rail = composeRule.onNodeWithTag("time_rail").fetchSemanticsNode().boundsInRoot
+ val first = composeRule.onNodeWithTag("time_card_0").fetchSemanticsNode().boundsInRoot
+ val second = composeRule.onNodeWithTag("time_card_1").fetchSemanticsNode().boundsInRoot
+ assertTrue("The next card must be visibly incomplete: $value", first.width - second.width >= 24f * density)
+ assertTrue("Enough of the next artwork remains visible", second.width > first.width * 0.7f)
+ assertEquals(first.height, second.height, 1f)
+ if (value.direction == LayoutDirection.Ltr) {
+ assertEquals(rail.right, second.right, 1f)
+ assertEquals(16f * density, second.left - first.right, 1f)
+ } else {
+ assertEquals(rail.left, second.left, 1f)
+ assertEquals(16f * density, first.left - second.right, 1f)
+ }
+ }
+ }
+
+ private fun render() {
+ composeRule.setContent {
+ val value = scenario.value
+ view = LocalView.current
+ density = LocalDensity.current.density
+ CompositionLocalProvider(
+ LocalDensity provides Density(density, value.fontScale),
+ LocalLayoutDirection provides value.direction,
+ ) {
+ BoxLoreTheme(darkTheme = value.dark, dynamicColor = value.dynamic, surfaceStyle = value.surface) {
+ containerColor = if (value.presentation == FeedMediaCardPresentation.ExpressiveFeatured) {
+ MaterialTheme.colorScheme.tertiaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainer
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ FeedMediaCard(
+ imageUrl = "",
+ title = value.title,
+ subtitle = null,
+ onClick = {},
+ titleMaxLines = 3,
+ presentation = value.presentation,
+ modifier = Modifier.width(value.width).testTag("real"),
+ )
+ HomeMediaCardSkeleton(
+ presentation = value.presentation,
+ modifier = Modifier.width(value.width).testTag("skeleton"),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun titleLayout(): TextLayoutResult {
+ val layouts = mutableListOf()
+ composeRule.onNodeWithText(scenario.value.title, useUnmergedTree = true)
+ .performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) }
+ return layouts.single()
+ }
+
+ private fun assertMatchingHeights() {
+ val real = composeRule.onNodeWithTag("real").fetchSemanticsNode().boundsInRoot
+ val skeleton = composeRule.onNodeWithTag("skeleton").fetchSemanticsNode().boundsInRoot
+ assertEquals(real.height, skeleton.height, 1f)
+ }
+
+ private fun assertPixelColor(expected: Color, actual: Int) {
+ val expectedArgb = expected.toArgb()
+ for (shift in listOf(0, 8, 16, 24)) {
+ val expectedChannel = (expectedArgb shr shift) and 255
+ val actualChannel = (actual shr shift) and 255
+ assertTrue("Expected softly tinted color $expectedArgb but found $actual", kotlin.math.abs(expectedChannel - actualChannel) <= 1)
+ }
+ }
+}
diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryFeedSpacingTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryFeedSpacingTest.kt
new file mode 100644
index 000000000..ed189671e
--- /dev/null
+++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryFeedSpacingTest.kt
@@ -0,0 +1,300 @@
+package cx.aswin.boxlore.feature.home.components
+
+import android.content.Context
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
+import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
+import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.unit.dp
+import androidx.test.core.app.ApplicationProvider
+import cx.aswin.boxlore.core.catalog.content.ContentDaypart
+import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme
+import cx.aswin.boxlore.core.model.Episode
+import cx.aswin.boxlore.core.model.Podcast
+import cx.aswin.boxlore.feature.home.HomeEditorialRow
+import cx.aswin.boxlore.feature.home.HomeFeedCallbacks
+import cx.aswin.boxlore.feature.home.HomePlaybackUi
+import cx.aswin.boxlore.feature.home.PodcastFeed
+import cx.aswin.boxlore.feature.home.PodcastFeedContent
+import cx.aswin.boxlore.feature.home.PodcastFeedDerivedState
+import cx.aswin.boxlore.feature.home.PodcastFeedLayout
+import cx.aswin.boxlore.feature.home.PodcastFeedLoadingState
+import cx.aswin.boxlore.feature.home.PodcastFeedPlayback
+import cx.aswin.boxlore.feature.home.PodcastFeedRecommendationState
+import cx.aswin.boxlore.feature.home.PodcastFeedUiState
+import cx.aswin.boxlore.feature.home.R
+import cx.aswin.boxlore.feature.home.StableEditorialRowList
+import cx.aswin.boxlore.feature.home.StableEpisodeList
+import cx.aswin.boxlore.feature.home.StableHeroList
+import cx.aswin.boxlore.feature.home.StablePlaybackStateMap
+import cx.aswin.boxlore.feature.home.StablePodcastList
+import cx.aswin.boxlore.feature.home.curatedForYouItems
+import cx.aswin.boxlore.feature.home.discoverFeedItems
+import cx.aswin.boxlore.feature.home.discoveryGreetingFor
+import cx.aswin.boxlore.feature.home.discoveryGreetingItem
+import cx.aswin.boxlore.feature.home.logic.editorialRowDefinitionsFor
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], qualifiers = "w360dp-h2600dp-mdpi")
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class HomeDiscoveryFeedSpacingTest {
+ @get:Rule val composeRule = createComposeRule()
+
+ private val loading = mutableStateOf(false)
+ private val showRecommendations = mutableStateOf(false)
+ private val showRelated = mutableStateOf(true)
+ private val fallback = mutableStateOf(false)
+ private val browseEvents = mutableListOf>()
+ private lateinit var gridState: LazyStaggeredGridState
+ private val definitions = editorialRowDefinitionsFor(ContentDaypart.MORNING)
+ private val rows = definitions.mapIndexed { index, definition ->
+ HomeEditorialRow(
+ definition.providerId,
+ definition.title,
+ definition.subtitle,
+ definition.icon,
+ listOf(podcast("show$index", "Show $index").copy(latestEpisode = Episode("ep$index", "Episode $index", "", ""))),
+ )
+ }
+ private val feedState = PodcastFeedUiState(
+ discoveryGreetingFor(ContentDaypart.MORNING),
+ null,
+ null,
+ null,
+ emptyList(),
+ podcast("seed", "Seed show"),
+ false,
+ )
+ private val derivedState = PodcastFeedDerivedState(true, false, true, false, emptyList(), false, false, false)
+ private val content = PodcastFeedContent(
+ StableHeroList(emptyList()),
+ StablePodcastList(emptyList()),
+ StablePodcastList(emptyList()),
+ StableEditorialRowList(rows),
+ StablePodcastList(emptyList()),
+ StableEpisodeList(listOf(Episode("recommendation", "Recommended episode", "", ""))),
+ StableEpisodeList(emptyList()),
+ )
+ private val callbacks = HomeFeedCallbacks(
+ onPodcastClick = { _, _, _, _ -> }, onHeroArrowClick = { _, _ -> }, onEpisodeClick = null,
+ onPlayClick = null, onNavigateToLibrary = null, onNavigateToDownloads = null,
+ onNavigateToExplore = { category, source, tab -> browseEvents.add(Triple(category, source, tab)) },
+ onToggleSubscription = {}, onTogglePlayback = {}, onSelectCategory = {}, onPodcastSelected = {},
+ onPlayMix = {}, onMixModeChanged = {}, onPlayEpisode = { _, _, _ -> }, onImportClick = {}, onAiOnboardingClick = {},
+ onDismissImportBanner = {}, onBriefingClick = {}, onDismissBriefing = {}, onDismissBriefingForever = {}, onFeedbackClick = {},
+ )
+
+ @Test fun `related time rails keep 20dp gaps and Explore follows directly without Video Spotlight`() {
+ renderEditorial()
+ for (index in 0 until rows.lastIndex) {
+ assertEquals(20f, itemTop("editorial_${rows[index + 1].providerId}") - cardBottom("Episode $index"), 1f)
+ }
+ val lastBottom = cardBottom("Episode ${rows.lastIndex}")
+ assertEquals(32f, sectionOpeningTop("discover_header") - lastBottom, 1f)
+
+ composeRule.onNodeWithText("Video Spotlight", ignoreCase = true).assertDoesNotExist()
+ assertEquals("discover_header", gridState.layoutInfo.visibleItemsInfo.first { it.index > rows.size }.key)
+ }
+
+ @Test fun `time rail loading rows match loaded heights and following section position`() {
+ renderEditorial()
+ val heights = rows.map { itemHeight("editorial_${it.providerId}") }
+ val exploreTop = actionTop(R.string.home_shows_see_all)
+ loading.value = true
+ composeRule.waitForIdle()
+ rows.forEachIndexed { index, row -> assertEquals(heights[index], itemHeight("editorial_${row.providerId}")) }
+ assertEquals(exploreTop, actionTop(R.string.home_shows_see_all), 1f)
+ }
+
+ @Test fun `seed and personal recommendations are separate chapters with major spacing`() {
+ renderRecommendations()
+ val greetingTop = sectionOpeningTop("discovery_greeting")
+ assertEquals(32f, greetingTop - cardBottom("Similar show"), 1f)
+ showRecommendations.value = true
+ composeRule.waitForIdle()
+ assertEquals(32f, itemTop("for_you_header") - cardBottom("Similar show"), 1f)
+ }
+
+ @Test fun `recommendation chapters retain accurate names when seed or personal results are absent`() {
+ renderRecommendations()
+ composeRule.onNodeWithText(label(R.string.home_because_you_like)).assertExists()
+ composeRule.onNodeWithText("More like this").assertDoesNotExist()
+ assertEquals(listOf("because_you_like", "discovery_greeting"), visibleKeys())
+ composeRule.onNodeWithText(label(R.string.home_picked_for_you)).assertDoesNotExist()
+ showRecommendations.value = true
+ composeRule.waitForIdle()
+ composeRule.onNodeWithText(label(R.string.home_picked_for_you)).assertExists()
+ fallback.value = true
+ composeRule.waitForIdle()
+ composeRule.onNodeWithText(label(R.string.home_picked_for_you)).assertDoesNotExist()
+ composeRule.onNodeWithText(label(R.string.home_popular_region)).assertExists()
+ showRelated.value = false
+ composeRule.waitForIdle()
+ composeRule.onNodeWithText(label(R.string.home_because_you_like)).assertDoesNotExist()
+ composeRule.onNodeWithText(label(R.string.home_popular_region)).assertExists()
+ assertEquals(listOf("curated_header", "for_you_hero", "discovery_greeting"), visibleKeys())
+ showRecommendations.value = false
+ composeRule.waitForIdle()
+ assertEquals(listOf("discovery_greeting"), visibleKeys())
+ composeRule.onNodeWithText("Curated for you").assertDoesNotExist()
+ composeRule.onNodeWithText("Based on your taste").assertDoesNotExist()
+ }
+
+ @Test fun `chapter actions retain their existing destinations and invoke navigation once`() {
+ showRecommendations.value = true
+ renderRecommendations()
+ composeRule.onNodeWithContentDescription(label(R.string.home_recommendations_see_all)).performClick()
+ composeRule.onNodeWithContentDescription(label(R.string.home_discovery_see_all)).performClick()
+ showRelated.value = false
+ composeRule.waitForIdle()
+ composeRule.onNodeWithContentDescription(label(R.string.home_recommendations_see_all)).performClick()
+ assertEquals(
+ listOf(
+ Triple(null, "home_for_you_see_all", "foryou"),
+ Triple(null, "home_discovery_greeting", "foryou"),
+ Triple(null, "home_for_you_see_all", "foryou"),
+ ),
+ browseEvents,
+ )
+ }
+
+ @Test fun `personal chapter keeps recommendation order counts and stable grid keys`() {
+ showRecommendations.value = true
+ showRelated.value = false
+ val recommendations = (0 until 12).map { Episode("rec$it", "Recommendation $it", "", "") }
+ renderRecommendations(content.copy(recommendations = StableEpisodeList(recommendations)))
+ assertEquals(
+ listOf("curated_header", "for_you_hero", "for_you_body_0", "for_you_body_1", "for_you_body_2", "for_you_body_3", "discovery_greeting"),
+ visibleKeys(),
+ )
+ recommendations.take(9).forEach { composeRule.onNodeWithText(it.title).assertExists() }
+ recommendations.drop(9).forEach { composeRule.onNodeWithText(it.title).assertDoesNotExist() }
+ val positions = recommendations.take(9).map { composeRule.onNodeWithText(it.title).fetchSemanticsNode().boundsInRoot }
+ assertEquals(positions[1].top, positions[2].top, 1f)
+ assertEquals(positions[3].top, positions[4].top, 1f)
+ org.junit.Assert.assertTrue(positions[0].bottom < positions[1].top && positions[1].left < positions[2].left)
+ }
+
+ @Test fun `Explore browse and view more keep all and genre destinations with existing show tap context`() {
+ val selected = mutableStateOf(null)
+ val showEvents = mutableListOf>()
+ val shows = listOf(podcast("explore1", "Explore show one"), podcast("explore2", "Explore show two"))
+ composeRule.setContent {
+ BoxLoreTheme(dynamicColor = false) {
+ LazyVerticalStaggeredGrid(
+ columns = StaggeredGridCells.Fixed(2),
+ modifier = Modifier.width(328.dp).height(1600.dp),
+ verticalItemSpacing = 16.dp,
+ ) {
+ discoverFeedItems(
+ feedState.copy(selectedCategory = selected.value),
+ derivedState.copy(discoverItems = shows, showDiscoverContent = true),
+ callbacks.copy(onPodcastClick = { _, source, category, position ->
+ assertEquals("home_discover_grid", source)
+ showEvents.add(category to position)
+ }),
+ )
+ }
+ }
+ }
+ composeRule.onNodeWithContentDescription(label(R.string.home_shows_see_all)).performClick()
+ composeRule.onNodeWithText(label(R.string.home_view_more_shows)).performClick()
+ selected.value = "News"
+ composeRule.waitForIdle()
+ composeRule.onNodeWithContentDescription(label(R.string.home_shows_see_all)).performClick()
+ composeRule.onNodeWithText(ApplicationProvider.getApplicationContext().getString(R.string.home_view_more_in, "News")).performClick()
+ composeRule.onNodeWithText(shows[1].title).performClick()
+ assertEquals(
+ listOf(
+ Triple("All", "home_discover_header", null),
+ Triple("All", "home_discover_view_all_button", null),
+ Triple("News", "home_discover_header", null),
+ Triple("News", "home_discover_view_all_button", null),
+ ),
+ browseEvents,
+ )
+ assertEquals(listOf("News" to 1), showEvents)
+ }
+
+ private fun renderRecommendations(recommendationContent: PodcastFeedContent = content) {
+ composeRule.setContent {
+ BoxLoreTheme(dynamicColor = false) {
+ gridState = rememberLazyStaggeredGridState()
+ LazyVerticalStaggeredGrid(
+ columns = StaggeredGridCells.Fixed(2),
+ state = gridState,
+ modifier = Modifier.width(328.dp).height(2200.dp).testTag("feed"),
+ verticalItemSpacing = 16.dp,
+ ) {
+ curatedForYouItems(
+ recommendationContent,
+ feedState,
+ PodcastFeedRecommendationState(
+ StableEpisodeList(emptyList()),
+ StablePodcastList(listOf(podcast("similar", "Similar show"))),
+ isRecommendationsLoading = false,
+ isRecommendationsFallback = fallback.value,
+ ),
+ PodcastFeedPlayback(HomePlaybackUi(null, null, false), StablePlaybackStateMap(emptyMap())),
+ callbacks,
+ derivedState.copy(hasBecauseYouLike = showRelated.value, hasRecommendations = showRecommendations.value),
+ )
+ discoveryGreetingItem(feedState, callbacks)
+ }
+ }
+ }
+ composeRule.waitForIdle()
+ }
+
+ private fun renderEditorial() {
+ composeRule.setContent {
+ BoxLoreTheme(dynamicColor = false) {
+ gridState = rememberLazyStaggeredGridState()
+ PodcastFeed(
+ content = content.copy(
+ editorialRows = if (loading.value) StableEditorialRowList(emptyList()) else content.editorialRows,
+ recommendations = StableEpisodeList(emptyList()),
+ ),
+ feedState = feedState,
+ recommendationState = PodcastFeedRecommendationState(
+ StableEpisodeList(emptyList()),
+ StablePodcastList(emptyList()),
+ isRecommendationsLoading = false,
+ ),
+ loadingState = PodcastFeedLoadingState(isEditorialRowsLoading = loading.value, isFilterLoading = false, isLoading = false),
+ playback = PodcastFeedPlayback(HomePlaybackUi(null, null, false), StablePlaybackStateMap(emptyMap())),
+ callbacks = callbacks,
+ layout = PodcastFeedLayout(gridState, Modifier.width(328.dp).height(2200.dp).testTag("feed")),
+ )
+ }
+ }
+ composeRule.waitForIdle()
+ }
+
+ private fun itemTop(key: String): Float = gridTop() + gridState.layoutInfo.visibleItemsInfo.single { it.key == key }.offset.y
+ private fun itemHeight(key: String): Int = gridState.layoutInfo.visibleItemsInfo.single { it.key == key }.size.height
+ private fun sectionOpeningTop(key: String): Float = itemTop(key) + (HomeFeedSpacing.SectionGap - HomeFeedSpacing.GridGap).value
+ private fun visibleKeys(): List = gridState.layoutInfo.visibleItemsInfo.sortedBy { it.index }.map { it.key }
+ private fun gridTop(): Float = composeRule.onNodeWithTag("feed").fetchSemanticsNode().boundsInRoot.top
+ private fun cardBottom(title: String): Float = composeRule.onNodeWithText(title).fetchSemanticsNode().boundsInRoot.bottom
+ private fun actionTop(id: Int): Float = composeRule.onNodeWithContentDescription(label(id)).fetchSemanticsNode().boundsInRoot.top
+ private fun label(id: Int): String = ApplicationProvider.getApplicationContext().getString(id)
+ private fun podcast(id: String, title: String) = Podcast(id = id, title = title, artist = "", imageUrl = "")
+}
diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryHeaderTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryHeaderTest.kt
new file mode 100644
index 000000000..fb8007df1
--- /dev/null
+++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryHeaderTest.kt
@@ -0,0 +1,337 @@
+package cx.aswin.boxlore.feature.home.components
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.view.View
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Subscriptions
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.test.core.app.ApplicationProvider
+import cx.aswin.boxlore.core.catalog.content.ContentDaypart
+import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme
+import cx.aswin.boxlore.core.designsystem.theme.SurfaceStyles
+import cx.aswin.boxlore.feature.home.R
+import cx.aswin.boxlore.feature.home.discoveryGreetingFor
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], qualifiers = "w500dp-h1400dp-mdpi")
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class HomeDiscoveryHeaderTest {
+ @get:Rule val composeRule = createComposeRule()
+
+ private data class Scenario(
+ val chapter: HomeDiscoveryChapter = HomeDiscoveryChapter.PERSONAL,
+ val width: Dp = 288.dp,
+ val fontScale: Float = 1f,
+ val direction: LayoutDirection = LayoutDirection.Ltr,
+ val dark: Boolean = false,
+ val dynamic: Boolean = false,
+ val surface: String = SurfaceStyles.CLASSIC_DYNAMIC,
+ val daypart: ContentDaypart = ContentDaypart.AFTERNOON,
+ )
+
+ private val scenario = mutableStateOf(Scenario())
+ private var density = 1f
+ private var clicks = 0
+ private lateinit var view: View
+ private val headerTop = mutableStateOf(0.dp)
+ private var headerHeight = 0
+
+ @Test fun `chapter headings wrap without clipping or competing with the shaped action at phone widths enlarged text and RTL`() {
+ render()
+ val variants = HomeDiscoveryChapter.entries.flatMap { chapter ->
+ if (chapter == HomeDiscoveryChapter.MOMENT) {
+ ContentDaypart.entries.map { Scenario(chapter = chapter, daypart = it) }
+ } else {
+ listOf(Scenario(chapter = chapter))
+ }
+ }
+ .flatMap { value -> listOf(288.dp, 328.dp, 380.dp).map { value.copy(width = it) } }
+ .flatMap { value -> listOf(1f, 2f).map { value.copy(fontScale = it) } }
+ .flatMap { value -> LayoutDirection.entries.map { value.copy(direction = it) } }
+ for (value in variants) {
+ scenario.value = value
+ composeRule.waitForIdle()
+ val header = composeRule.onNodeWithTag("header").fetchSemanticsNode().boundsInRoot
+ val title = composeRule.onNodeWithText(title()).fetchSemanticsNode().boundsInRoot
+ val action = composeRule.onNodeWithContentDescription(description()).fetchSemanticsNode().boundsInRoot
+ assertFalse(title.overlaps(action))
+ assertTrue(title.left >= header.left && title.right <= header.right)
+ assertTrue(title.top >= header.top && title.bottom <= header.bottom)
+ assertEquals(48f * density, action.width, 1f)
+ assertEquals(48f * density, action.height, 1f)
+ if (value.direction == LayoutDirection.Ltr) assertTrue(title.right < action.left) else assertTrue(action.right < title.left)
+ val layouts = mutableListOf()
+ composeRule.onNodeWithText(title()).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) }
+ assertFalse("$value: heading must fully wrap within its allocated width", layouts.single().hasVisualOverflow)
+ composeRule.onNodeWithText(title()).assert(SemanticsMatcher.keyIsDefined(SemanticsProperties.Heading))
+ }
+ }
+
+ @Test fun `native browse feedback keeps header geometry and dispatches once across chapters and appearance themes`() {
+ render()
+ val themes = listOf(Scenario(), Scenario(dark = true), Scenario(dynamic = true), Scenario(dark = true, surface = SurfaceStyles.AMOLED))
+ for (theme in themes) {
+ for (chapter in HomeDiscoveryChapter.entries) {
+ scenario.value = theme.copy(chapter = chapter)
+ composeRule.waitForIdle()
+ val header = composeRule.onNodeWithTag("header").fetchSemanticsNode().boundsInRoot
+ val action = composeRule.onNodeWithContentDescription(description())
+ val before = clicks
+ action.performTouchInput { down(center) }
+ composeRule.mainClock.advanceTimeBy(100)
+ assertEquals(header, composeRule.onNodeWithTag("header").fetchSemanticsNode().boundsInRoot)
+ assertEquals(before, clicks)
+ action.performTouchInput { up() }
+ composeRule.waitForIdle()
+ assertEquals(before + 1, clicks)
+ }
+ }
+ }
+
+ @Test fun `daypart marks reveal then keep subtle motion without moving the heading or rotating the night crescent`() {
+ composeRule.mainClock.autoAdvance = false
+ scenario.value = Scenario(chapter = HomeDiscoveryChapter.MOMENT, daypart = ContentDaypart.MORNING)
+ render()
+ for (daypart in ContentDaypart.entries) {
+ scenario.value = scenario.value.copy(daypart = daypart, width = 288.dp)
+ composeRule.mainClock.advanceTimeByFrame()
+ composeRule.waitForIdle()
+ val before = markPixels()
+ val titleBounds = composeRule.onNodeWithText(title()).fetchSemanticsNode().boundsInRoot
+ val actionBounds = composeRule.onNodeWithContentDescription(description()).fetchSemanticsNode().boundsInRoot
+ val markBounds = composeRule.onNodeWithTag("home_daypart_mark").fetchSemanticsNode().boundsInRoot
+ assertEquals(32f * density, markBounds.width, 1f)
+ assertFalse(markBounds.overlaps(titleBounds))
+
+ composeRule.mainClock.advanceTimeBy(1_200)
+ composeRule.waitForIdle()
+ val settled = markPixels()
+ assertFalse("$daypart must visibly animate", before == settled)
+ assertEquals(titleBounds, composeRule.onNodeWithText(title()).fetchSemanticsNode().boundsInRoot)
+ assertEquals(actionBounds, composeRule.onNodeWithContentDescription(description()).fetchSemanticsNode().boundsInRoot)
+
+ composeRule.mainClock.advanceTimeBy(900)
+ composeRule.waitForIdle()
+ val moving = markPixels()
+ assertFalse("$daypart must retain ambient motion", settled == moving)
+ val ambientChanges = settled.indices.count { settled[it] != moving[it] }
+ assertTrue("$daypart motion must be noticeable within a second", ambientChanges >= if (daypart == ContentDaypart.LATE_NIGHT) 4 else 12)
+ if (daypart == ContentDaypart.LATE_NIGHT) {
+ val width = markBounds.width.toInt()
+ val moonPixels: (Int, Int) -> Boolean = { index, _ ->
+ // Exclude both stars' full size ranges, including antialiased edges.
+ index % width < width * 0.68f || index / width > width * 0.63f
+ }
+ assertEquals("Only the stars change at night", settled.filterIndexed(moonPixels), moving.filterIndexed(moonPixels))
+ val upperStarChanges = settled.indices.count { it % width >= width * 0.68f && it / width < width * 0.35f && settled[it] != moving[it] }
+ val lowerStarChanges = settled.indices.count { it % width >= width * 0.75f && it / width > width * 0.4f && settled[it] != moving[it] }
+ assertTrue("Both stars twinkle", upperStarChanges > 0 && lowerStarChanges > 0)
+ }
+ assertEquals(titleBounds, composeRule.onNodeWithText(title()).fetchSemanticsNode().boundsInRoot)
+ assertEquals(actionBounds, composeRule.onNodeWithContentDescription(description()).fetchSemanticsNode().boundsInRoot)
+ scenario.value = scenario.value.copy(width = 328.dp)
+ composeRule.mainClock.advanceTimeByFrame()
+ composeRule.waitForIdle()
+ val recomposed = markPixels()
+ val changedPixels = moving.indices.count { moving[it] != recomposed[it] }
+ assertTrue("$daypart may advance a frame but must not replay its reveal", changedPixels < moving.size / 12)
+ }
+ }
+
+ @Test fun `reveal waits for the entire header inside a clipped viewport and ambient motion pauses when clipped`() {
+ composeRule.mainClock.autoAdvance = false
+ scenario.value = Scenario(chapter = HomeDiscoveryChapter.MOMENT, daypart = ContentDaypart.MORNING, fontScale = 2f)
+ headerTop.value = (-8).dp
+ render(viewportHeight = 192.dp)
+ advanceFrames()
+ val waiting = markPixels()
+ composeRule.mainClock.advanceTimeBy(1_200)
+ assertEquals("A visible icon with a clipped heading must not reveal", waiting, markPixels())
+
+ headerTop.value = 192.dp - (headerHeight / density).dp + 8.dp
+ advanceFrames()
+ val bottomClipped = markPixels()
+ composeRule.mainClock.advanceTimeBy(1_200)
+ assertEquals("The last part of the heading must enter before revealing", bottomClipped, markPixels())
+
+ headerTop.value = 0.dp
+ advanceFrames()
+ val entering = markPixels()
+ composeRule.mainClock.advanceTimeBy(1_200)
+ assertFalse("Fully visible headers reveal", entering == markPixels())
+ composeRule.mainClock.advanceTimeBy(3_000)
+
+ headerTop.value = (-8).dp
+ advanceFrames()
+ val paused = markPixels()
+ composeRule.mainClock.advanceTimeBy(3_000)
+ assertEquals("Clipped headers stop their ambient clock", paused, markPixels())
+ headerTop.value = 0.dp
+ advanceFrames()
+ val reentered = markPixels()
+ assertEquals("Completed reveals do not replay when scrolling back", paused, reentered)
+ composeRule.mainClock.advanceTimeBy(3_000)
+ assertFalse("Ambient motion resumes when fully visible", reentered == markPixels())
+ }
+
+ private fun advanceFrames() {
+ repeat(2) {
+ composeRule.mainClock.advanceTimeByFrame()
+ composeRule.waitForIdle()
+ }
+ }
+
+ private fun markPixels(): List {
+ val bounds = composeRule.onNodeWithTag("home_daypart_mark").fetchSemanticsNode().boundsInRoot
+ var pixels = emptyList()
+ composeRule.runOnIdle {
+ val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
+ view.draw(Canvas(bitmap))
+ val width = bounds.width.toInt()
+ pixels = List(width * bounds.height.toInt()) { index ->
+ bitmap.getPixel(bounds.left.toInt() + index % width, bounds.top.toInt() + index / width)
+ }
+ bitmap.recycle()
+ }
+ return pixels
+ }
+
+ @Test fun `Explore keeps genre selection without showing secondary header text`() {
+ val selected = mutableStateOf(null)
+ composeRule.setContent {
+ BoxLoreTheme(dynamicColor = false) {
+ DiscoverSection(selected.value, { selected.value = it }, {}, Modifier.width(288.dp))
+ }
+ }
+ composeRule.onNodeWithText(label(R.string.home_explore_shows)).assertExists()
+ composeRule.onNodeWithText(label(R.string.home_genres_top)).assertIsSelected()
+ composeRule.onNodeWithText("Browse by genre").assertDoesNotExist()
+ selected.value = "News"
+ composeRule.waitForIdle()
+ composeRule.onNodeWithText(label(R.string.home_explore_shows)).assertExists()
+ composeRule.onNodeWithText("News").assertIsSelected()
+ composeRule.onNodeWithText("Top in News").assertDoesNotExist()
+ composeRule.onNodeWithText("Browse by genre").assertDoesNotExist()
+ }
+
+ @Test fun `subheader icons sit directly before wrapping titles in LTR and RTL`() {
+ val tone = mutableStateOf(HomeChildHeaderTone.PRIMARY)
+ val title = "Shows for curious minds"
+ composeRule.setContent {
+ val value = scenario.value
+ density = LocalDensity.current.density
+ CompositionLocalProvider(
+ LocalDensity provides Density(density, value.fontScale),
+ LocalLayoutDirection provides value.direction,
+ ) {
+ BoxLoreTheme(dynamicColor = false) {
+ HomeChildSectionHeader(title, Icons.Rounded.Subscriptions, Modifier.width(value.width).testTag("rail_header"), tone = tone.value)
+ }
+ }
+ }
+ val variants = listOf(288.dp, 380.dp).map { Scenario(width = it) }
+ .flatMap { value -> listOf(1f, 2f).map { value.copy(fontScale = it) } }
+ .flatMap { value -> LayoutDirection.entries.map { value.copy(direction = it) } }
+ for (value in variants) {
+ for (colorTone in HomeChildHeaderTone.entries) {
+ scenario.value = value
+ tone.value = colorTone
+ composeRule.waitForIdle()
+ val header = composeRule.onNodeWithTag("rail_header").fetchSemanticsNode().boundsInRoot
+ val text = composeRule.onNodeWithText(title).fetchSemanticsNode().boundsInRoot
+ if (value.direction == LayoutDirection.Ltr) {
+ assertEquals(header.left + 38f * density, text.left, 1f)
+ } else {
+ assertEquals(header.right - 38f * density, text.right, 1f)
+ }
+ assertTrue(text.top >= header.top && text.bottom <= header.bottom)
+ assertTrue(header.height >= 36f * density)
+ val layouts = mutableListOf()
+ composeRule.onNodeWithText(title).performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) }
+ assertFalse(layouts.single().hasVisualOverflow)
+ }
+ }
+ }
+
+ private fun render(viewportHeight: Dp? = null) {
+ composeRule.setContent {
+ val value = scenario.value
+ view = LocalView.current
+ density = LocalDensity.current.density
+ CompositionLocalProvider(
+ LocalDensity provides Density(density, value.fontScale),
+ LocalLayoutDirection provides value.direction,
+ ) {
+ BoxLoreTheme(darkTheme = value.dark, dynamicColor = value.dynamic, surfaceStyle = value.surface) {
+ val viewportModifier = if (viewportHeight == null) Modifier else Modifier.height(viewportHeight).clipToBounds()
+ Box(Modifier.width(value.width).then(viewportModifier)) {
+ HomeTopLevelSectionHeader(
+ title = title(),
+ seeAllContentDescription = description(),
+ onSeeAllClick = { clicks++ },
+ chapter = value.chapter,
+ daypart = if (value.chapter == HomeDiscoveryChapter.MOMENT) value.daypart else null,
+ modifier = Modifier.offset(y = headerTop.value).width(value.width)
+ .onSizeChanged { headerHeight = it.height }.testTag("header"),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun title(): String = when (scenario.value.chapter) {
+ HomeDiscoveryChapter.LIBRARY -> label(R.string.home_your_shows_heading)
+ HomeDiscoveryChapter.PERSONAL -> label(R.string.home_popular_region)
+ HomeDiscoveryChapter.MOMENT -> discoveryGreetingFor(scenario.value.daypart).title
+ HomeDiscoveryChapter.EXPLORE -> label(R.string.home_explore_shows)
+ }
+
+ private fun description(): String = when (scenario.value.chapter) {
+ HomeDiscoveryChapter.LIBRARY -> "View Library"
+ HomeDiscoveryChapter.PERSONAL -> label(R.string.home_recommendations_see_all)
+ HomeDiscoveryChapter.MOMENT -> label(R.string.home_discovery_see_all)
+ HomeDiscoveryChapter.EXPLORE -> label(R.string.home_shows_see_all)
+ }
+
+ private fun label(id: Int, vararg args: Any): String = ApplicationProvider.getApplicationContext().getString(id, *args)
+}
diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryInteractionTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryInteractionTest.kt
new file mode 100644
index 000000000..5699caea0
--- /dev/null
+++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeDiscoveryInteractionTest.kt
@@ -0,0 +1,123 @@
+package cx.aswin.boxlore.feature.home.components
+
+import android.content.Context
+import androidx.compose.foundation.layout.width
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.assertIsSelected
+import androidx.compose.ui.test.click
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.unit.dp
+import androidx.test.core.app.ApplicationProvider
+import cx.aswin.boxlore.core.designsystem.components.CuratedEpisodeCard
+import cx.aswin.boxlore.core.designsystem.components.DiscoveryExpressiveTheme
+import cx.aswin.boxlore.core.designsystem.components.FeedMediaCardPresentation
+import cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme
+import cx.aswin.boxlore.core.model.Episode
+import cx.aswin.boxlore.core.model.Podcast
+import cx.aswin.boxlore.feature.home.R
+import cx.aswin.boxlore.feature.home.StableEpisodeList
+import cx.aswin.boxlore.feature.home.StablePodcastList
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], qualifiers = "w412dp-h1000dp-mdpi")
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class HomeDiscoveryInteractionTest {
+ @get:Rule val composeRule = createComposeRule()
+
+ @Test fun `episode card exposes only episode title and opens it once`() {
+ var clicks = 0
+ val parent = podcast("parent", "Hidden show name")
+ val episode = Episode("episode", "Visible episode title", "", "")
+ composeRule.setContent {
+ BoxLoreTheme(dynamicColor = false) {
+ CuratedEpisodeCard(parent, episode, { clicks++ }, showSubtitle = false, presentation = FeedMediaCardPresentation.ExpressivePoster)
+ }
+ }
+ composeRule.onNodeWithText(parent.title).assertDoesNotExist()
+ composeRule.onNodeWithText(episode.title).performClick()
+ assertEquals(1, clicks)
+ }
+
+ @Test fun `featured title and artwork share one episode callback without promotional overlays`() {
+ var clicks = 0
+ val episode = Episode("featured", "Featured episode", "", "", publishedDate = System.currentTimeMillis() / 1000L)
+ composeRule.setContent {
+ BoxLoreTheme(dynamicColor = false) {
+ CuratedEpisodeCard(
+ podcast("parent", "Hidden show"),
+ episode,
+ { clicks++ },
+ modifier = Modifier.width(328.dp).testTag("featured"),
+ showSubtitle = false,
+ presentation = FeedMediaCardPresentation.ExpressiveFeatured,
+ )
+ }
+ }
+ composeRule.onNodeWithText(episode.title).performClick()
+ assertEquals(1, clicks)
+ composeRule.onNodeWithTag("featured").performTouchInput { click(Offset(width - 24f, height / 2f)) }
+ assertEquals(2, clicks)
+ composeRule.onNodeWithText("Hidden show").assertDoesNotExist()
+ composeRule.onNodeWithText(label(cx.aswin.boxlore.core.designsystem.R.string.feed_media_new)).assertDoesNotExist()
+ }
+
+ @Test fun `seed card and change show button invoke independent callbacks once`() {
+ val seed = podcast("seed", "Seed show")
+ var showClicks = 0
+ var swapClicks = 0
+ composeRule.setContent {
+ BoxLoreTheme(dynamicColor = false) {
+ BecauseYouLikeSection(
+ podcast = seed,
+ recommendations = StableEpisodeList(emptyList()),
+ suggestedPodcasts = StablePodcastList(emptyList()),
+ currentPlayingEpisodeId = null,
+ isPlaying = false,
+ onEpisodeClick = { _, _ -> },
+ onPlayEpisode = { _, _ -> },
+ onPodcastClick = { showClicks++ },
+ onChangePodcastClick = { swapClicks++ },
+ )
+ }
+ }
+ composeRule.onNodeWithContentDescription(label(R.string.home_change_recommendation_show)).performClick()
+ assertEquals(1, swapClicks)
+ assertEquals(0, showClicks)
+ composeRule.onNodeWithText(seed.title).performClick()
+ assertEquals(1, showClicks)
+ assertEquals(1, swapClicks)
+ }
+
+ @Test fun `genre selection exposes selected semantics and invokes existing selection callback once`() {
+ val selections = mutableListOf()
+ composeRule.setContent {
+ BoxLoreTheme(dynamicColor = false) {
+ DiscoveryExpressiveTheme {
+ GenreSelector(selectedCategory = null, onCategorySelected = { selections.add(it) }, modifier = Modifier.width(380.dp))
+ }
+ }
+ }
+ composeRule.onNodeWithText(label(R.string.home_genres_top)).assertIsSelected()
+ composeRule.onNodeWithText("News").performClick()
+ assertEquals(listOf("News"), selections)
+ composeRule.onNodeWithText(label(R.string.home_genres_top)).performClick()
+ assertEquals(listOf("News", null), selections)
+ }
+
+ private fun podcast(id: String, title: String) = Podcast(id = id, title = title, artist = "Hidden artist", imageUrl = "")
+ private fun label(id: Int): String = ApplicationProvider.getApplicationContext().getString(id)
+}
diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeLoadingGeometryTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeLoadingGeometryTest.kt
index e24cad02a..a5cd558c1 100644
--- a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeLoadingGeometryTest.kt
+++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/HomeLoadingGeometryTest.kt
@@ -8,11 +8,13 @@ import org.junit.jupiter.api.Test
class HomeLoadingGeometryTest {
@Test
fun `mix skeleton reserves the complete loaded rail height`() {
- assertEquals(212.dp, HomeMixLayout.totalHeight)
+ assertEquals(324.dp, HomeMixLayout.totalHeight)
+ assertEquals(176.dp, HomeMixLayout.CardWidth)
+ assertEquals(224.dp, HomeMixLayout.CardHeight)
}
@Test
- fun `poster skeleton reserves the fixed three line title foot`() {
+ fun `legacy poster geometry retains its fixed three line title foot`() {
assertEquals(78.dp, FeedPosterSpacing.textFootHeight(3) + HomeFeedSpacing.CardTextPadding * 2)
}
}
diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/MixArtworkTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/MixArtworkTest.kt
new file mode 100644
index 000000000..9c9f79d4a
--- /dev/null
+++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/MixArtworkTest.kt
@@ -0,0 +1,19 @@
+package cx.aswin.boxlore.feature.home.components
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+
+class MixArtworkTest {
+ @Test fun `missing episode art uses the episode show artwork like details`() {
+ assertEquals("show", resolveMixArtwork(null, "show", "parent", "fallback"))
+ assertEquals("show", resolveMixArtwork(" ", " show ", "parent", "fallback"))
+ }
+
+ @Test fun `episode artwork wins and empty candidates fall through`() {
+ assertEquals("episode", resolveMixArtwork("episode", "show", "parent", "fallback"))
+ assertEquals("parent", resolveMixArtwork(null, "", "parent", "fallback"))
+ assertEquals("fallback", resolveMixArtwork(null, "", " ", "fallback"))
+ assertNull(resolveMixArtwork(null, "", " ", null))
+ }
+}
diff --git a/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/MixTimeTest.kt b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/MixTimeTest.kt
new file mode 100644
index 000000000..8cbb45936
--- /dev/null
+++ b/feature/home/src/test/java/cx/aswin/boxlore/feature/home/components/MixTimeTest.kt
@@ -0,0 +1,26 @@
+package cx.aswin.boxlore.feature.home.components
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class MixTimeTest {
+ @Test
+ fun `remaining minutes include complete hours`() {
+ assertEquals(90, remainingMixMinutes(10800, 0.5f))
+ assertEquals(60, remainingMixMinutes(7200, 0.5f))
+ assertEquals(180, remainingMixMinutes(14400, 0.25f))
+ }
+
+ @Test
+ fun `sub minute remainders display one minute rather than zero`() {
+ assertEquals(1, remainingMixMinutes(120, 0.75f))
+ assertEquals(1, remainingMixMinutes(60, 0.99f))
+ assertEquals(1, remainingMixMinutes(120, 0.5f))
+ }
+
+ @Test
+ fun `ordinary remainders retain whole minute formatting`() {
+ assertEquals(30, remainingMixMinutes(3600, 0.5f))
+ assertEquals(9, remainingMixMinutes(1200, 0.51f))
+ }
+}