feat: redesign the store game page's facts, description and achievements (#775)

This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-10-13 23:33:43 -04:00
1 parent 3a408058ba
commit 06bd0de4d7
3 files changed
+353 -160

No files matched your search

@@ -1,6 +1,18 @@
package com.droiddeck.launcher.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.foundation.border
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -139,6 +151,17 @@ private fun groups(list: List<Achievement>): List<Pair<Int, List<Achievement>>>
R.string.achievements_group_hidden to list.filter { !it.unlocked && it.hidden },
).filter { it.second.isNotEmpty() }
@Composable
private fun AchievementIcon(a: Achievement, size: Dp) {
Box(Modifier.size(size).clip(RoundedCornerShape(8.dp)).background(LocalPalette.current.surfaceVariant)) {
val url = if (a.unlocked) a.icon else a.iconLocked ?: a.icon
if (url != null) AsyncImage(
model = url, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().alpha(if (a.unlocked) 1f else 0.55f),
)
}
}
@Composable
internal fun AchievementRow(a: Achievement, compact: Boolean, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
@@ -156,13 +179,7 @@ internal fun AchievementRow(a: Achievement, compact: Boolean, modifier: Modifier
.focusable(interactionSource = interaction)
.padding(if (compact) 8.dp else 10.dp),
) {
Box(Modifier.size(icon).clip(RoundedCornerShape(8.dp)).background(pal.surfaceVariant)) {
val url = if (a.unlocked) a.icon else a.iconLocked ?: a.icon
if (url != null) AsyncImage(
model = url, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().alpha(if (a.unlocked) 1f else 0.55f),
)
}
AchievementIcon(a, icon)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
@@ -188,34 +205,75 @@ internal fun AchievementRow(a: Achievement, compact: Boolean, modifier: Modifier
}
@Composable
internal fun StoreAchievementsSection(game: StoreAchievements.Game) {
internal fun StoreAchievementsCard(game: StoreAchievements.Game, title: String, modifier: Modifier = Modifier) {
val label = StoreAchievements.forSource(game.source)?.label ?: return
StoreAchievementsSectionContent(label, rememberAchievements(game) ?: return)
}
@Composable
internal fun StoreAchievementsSectionContent(label: String, state: AchievementsState) {
val colors = MaterialTheme.colorScheme
val list = state.list
if (list.isNullOrEmpty() && !state.reading && state.reason != Reason.SIGNED_OUT && state.reason != Reason.UNREACHABLE) return
val narrow = LocalNarrowPane.current
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(top = 18.dp)) {
Text(stringResource(R.string.achievements_title), fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
achievementsNote(state, label)?.let { Text(it, fontSize = 13.sp, color = colors.onSurfaceVariant) }
if (!list.isNullOrEmpty()) {
AchievementsProgress(state)
for ((label, group) in groups(list)) {
AchievementGroupLabel(stringResource(label, group.size))
for (a in group) AchievementRow(a, compact = narrow)
val state = rememberAchievements(game) ?: return
var open by remember { mutableStateOf(false) }
StoreAchievementsCardContent(label, state, modifier) { open = true }
if (open) {
VeilRing()
Dialog(onDismissRequest = { open = false }, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
CompositionLocalProvider(LocalFrontFocus provides null) {
SessionAchievementsPanel(label, state, title, veil = Color.Transparent, floating = true, onClose = { open = false })
}
}
}
}
private fun LazyListScope.achievementItems(list: List<Achievement>, compact: Boolean, first: FocusRequester) {
@Composable
internal fun StoreAchievementsCardContent(label: String, state: AchievementsState, modifier: Modifier = Modifier, onOpen: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val list = state.list
if (list.isNullOrEmpty() && !state.reading && state.reason != Reason.SIGNED_OUT && state.reason != Reason.UNREACHABLE) return
val shape = RoundedCornerShape(16.dp)
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = modifier.clip(shape).background(colors.surface).border(1.dp, pal.line, shape).padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.achievements_title).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = colors.onSurfaceVariant, modifier = Modifier.weight(1f))
if (!list.isNullOrEmpty()) {
if (state.all) Trophy(16.dp, Modifier.padding(end = 6.dp))
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)) { append("${state.done}") }
append(" / ${list.size}")
},
fontSize = 13.sp, color = colors.onSurfaceVariant,
)
}
}
if (list.isNullOrEmpty()) {
Text(achievementsNote(state, label).orEmpty(), fontSize = 13.sp, color = colors.onSurfaceVariant)
return@Column
}
LinearProgressIndicator(
progress = { state.done / list.size.toFloat() },
color = pal.signal, trackColor = pal.line,
modifier = Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
)
BoxWithConstraints(Modifier.fillMaxWidth()) {
val size = 40.dp
val gap = 8.dp
val fit = ((maxWidth + gap) / (size + gap)).toInt().coerceAtLeast(1)
val ordered = groups(list).flatMap { it.second }
val shown = if (ordered.size <= fit) ordered else ordered.take(fit - 1)
Row(horizontalArrangement = Arrangement.spacedBy(gap)) {
for (a in shown) AchievementIcon(a, size)
if (shown.size < ordered.size) Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(RoundedCornerShape(8.dp)).background(pal.surfaceVariant)) {
Text("+${ordered.size - shown.size}", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.onSurfaceVariant, maxLines = 1)
}
}
}
SecondaryButton(stringResource(R.string.achievements_show_all, list.size), compact = true, onClick = onOpen)
}
}
private fun LazyListScope.achievementItems(list: List<Achievement>, compact: Boolean, first: FocusRequester, labels: Boolean = true) {
var index = 0
for ((label, group) in groups(list)) {
item(key = "group-$label") { AchievementGroupLabel(stringResource(label, group.size)) }
if (labels) item(key = "group-$label") { AchievementGroupLabel(stringResource(label, group.size)) }
val offset = index
itemsIndexed(group, key = { _, a -> "a-" + a.id }) { i, a ->
AchievementRow(a, compact, if (offset + i == 0) Modifier.focusRequester(first) else Modifier)
@@ -227,25 +285,36 @@ private fun LazyListScope.achievementItems(list: List<Achievement>, compact: Boo
@Composable
internal fun SessionAchievements(game: StoreAchievements.Game, title: String?, onClose: () -> Unit) {
val label = StoreAchievements.forSource(game.source)?.label.orEmpty()
SessionAchievementsPanel(label, rememberAchievements(game) ?: AchievementsState.READING, title, onClose)
SessionAchievementsPanel(label, rememberAchievements(game) ?: AchievementsState.READING, title, onClose = onClose)
}
@Composable
internal fun SessionAchievementsPanel(label: String, state: AchievementsState, title: String?, onClose: () -> Unit) {
internal fun SessionAchievementsPanel(label: String, state: AchievementsState, title: String?, veil: Color = Color.Black.copy(alpha = 0.45f), floating: Boolean = false, onClose: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
BackHandler(onBack = onClose)
val kinds = remember(state.list) {
val list = state.list.orEmpty()
val split = groups(list)
if (split.size < 2) emptyList() else listOf(R.string.stores_tab_all to list) + split
}
var pick by remember { mutableIntStateOf(0) }
if (pick >= kinds.size) pick = 0
val slide = remember { MutableTransitionState(false).apply { targetState = true } }
BoxWithConstraints(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f))
Modifier.fillMaxSize().background(veil)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClose),
contentAlignment = Alignment.CenterEnd,
) {
val short = maxHeight < 440.dp
AnimatedVisibility(slide, enter = slideInHorizontally(Motion.sp(0.8f)) { it } + fadeIn(Motion.tw(160)), label = "achievementsPanel") {
Column(
Modifier.fillMaxHeight().width(minOf(420.dp, maxWidth * 0.92f))
.background(pal.background.copy(alpha = 0.97f))
(if (floating) Modifier.padding(12.dp).fillMaxHeight().width(minOf(420.dp, maxWidth * 0.92f)).clip(RoundedCornerShape(20.dp))
.background(pal.background).border(1.dp, pal.line2, RoundedCornerShape(20.dp))
else Modifier.fillMaxHeight().width(minOf(420.dp, maxWidth * 0.92f)).background(pal.background.copy(alpha = 0.97f)))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {}
.controllerBack(onClose)
.then(if (kinds.isEmpty()) Modifier else Modifier.bumpers({ pick = (pick + kinds.size - 1) % kinds.size }, { pick = (pick + 1) % kinds.size }))
.padding(if (short) 12.dp else 16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
@@ -267,18 +336,24 @@ internal fun SessionAchievementsPanel(label: String, state: AchievementsState, t
color = pal.signal, trackColor = pal.line,
modifier = Modifier.padding(top = if (short) 6.dp else 10.dp).fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)),
)
if (kinds.isNotEmpty()) TabStrip(
kinds.map { (name, group) -> if (name == R.string.stores_tab_all) stringResource(name) + " · " + group.size else stringResource(name, group.size) },
pick, { pick = it }, Modifier.padding(top = if (short) 8.dp else 12.dp),
)
val first = remember { FocusRequester() }
val shown = kinds.getOrNull(pick)?.second ?: list
LazyColumn(
Modifier.weight(1f).padding(top = 6.dp),
contentPadding = PaddingValues(vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) { achievementItems(list, short, first) }
LaunchedEffect(list.isNotEmpty()) {
) { achievementItems(shown, short, first, labels = pick == 0) }
LaunchedEffect(list.isNotEmpty(), pick) {
withFrameNanos {}
runCatching { first.requestFocus() }
}
}
}
}
}
}
@@ -326,13 +401,13 @@ private fun PreviewSurface(content: @Composable () -> Unit) {
@Preview(name = "Store page section", widthDp = 800, heightDp = 600)
@Composable
private fun StoreAchievementsSectionPreview() = PreviewSurface {
Column(Modifier.width(560.dp)) { StoreAchievementsSectionContent("GOG", AchievementsState(previewList, null, false)) }
Column(Modifier.width(340.dp)) { StoreAchievementsCardContent("GOG", AchievementsState(previewList, null, false)) {} }
}
@Preview(name = "Store page section, signed out", widthDp = 800, heightDp = 200)
@Composable
private fun StoreAchievementsSectionSignedOutPreview() = PreviewSurface {
Column(Modifier.width(560.dp)) { StoreAchievementsSectionContent("Epic", AchievementsState(null, Reason.SIGNED_OUT, false)) }
Column(Modifier.width(340.dp)) { StoreAchievementsCardContent("Epic", AchievementsState(null, Reason.SIGNED_OUT, false)) {} }
}
@Preview(name = "Session panel", widthDp = 800, heightDp = 500)
@@ -10,11 +10,8 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -49,13 +46,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
@@ -85,34 +83,117 @@ internal fun rememberGameScores(title: String?): GameScores {
return GameScores(metacritic, hltb)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun StoreGameFacts(developer: String, publisher: String, releaseDate: String, scores: GameScores, modifier: Modifier = Modifier) {
val released = remember(releaseDate) { GameFacts.releaseLabel(releaseDate) }
val publishedBy = publisher.takeUnless { it.isBlank() || it.trim().equals(developer.trim(), ignoreCase = true) }
val firstRow = developer.isNotBlank() || publishedBy != null || released != null || scores.metacritic != Lookup.None
if (!firstRow && scores.hltb == Lookup.None) return
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier.fillMaxWidth().padding(top = 12.dp)) {
if (firstRow) FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (developer.isNotBlank()) FactTile(stringResource(R.string.stores_fact_developer)) { FactText(developer) }
if (publishedBy != null) FactTile(stringResource(R.string.stores_fact_publisher)) { FactText(publishedBy) }
if (released != null) FactTile(stringResource(R.string.stores_fact_released)) { FactText(released) }
Looked(scores.metacritic, exit = shrinkHorizontally()) { lookup ->
val found = (lookup as? Lookup.Found)?.value
FactTile(stringResource(R.string.stores_fact_metacritic), link = found?.url) {
if (found != null) MetacriticBadge(found.score) else Placeholder(ValueHeight)
}
internal fun HeroScores(scores: GameScores, modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Looked(scores.metacritic, exit = fadeOut()) { lookup ->
val found = (lookup as? Lookup.Found)?.value
HeroStat(stringResource(R.string.stores_fact_metacritic), found?.url) {
if (found != null) MetacriticBadge(found.score) else Placeholder(BadgeHeight)
}
}
Looked(scores.hltb, exit = shrinkVertically()) { lookup ->
Looked(scores.hltb, exit = fadeOut()) { lookup ->
val found = (lookup as? Lookup.Found)?.value
FactTile(stringResource(R.string.stores_fact_hltb), fill = true, link = found?.url) {
if (found != null) HltbTimes(found) else Placeholder(220.dp)
val lead = found?.let { hltbRuns(it).firstOrNull() }
HeroStat(lead?.second ?: stringResource(R.string.stores_hltb_main), found?.url) {
if (lead != null) Text(hltbTime(lead.first), fontSize = 26.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 1)
else Placeholder(64.dp)
}
}
}
}
@Composable
private fun HeroStat(label: String, link: String?, value: @Composable () -> Unit) {
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = link != null && rememberHot(src)
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.padding(horizontal = 6.dp).width(1.dp).height(44.dp).background(Color.White.copy(alpha = 0.18f)))
Column(
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.then(linkSurface(label, link, src, hot, StatShape, hotFill = Color.Black.copy(alpha = 0.45f))).widthIn(min = 96.dp).padding(horizontal = 14.dp, vertical = 8.dp),
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.height(BadgeHeight)) { value() }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
val tint = Color.White.copy(alpha = if (hot) 1f else 0.78f)
Text(label.uppercase(), fontSize = 10.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = tint, maxLines = 1)
if (link != null) Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = tint, modifier = Modifier.size(10.dp))
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun StoreGameFacts(developer: String, publisher: String, releaseDate: String, scores: GameScores, scoresInHero: Boolean, modifier: Modifier = Modifier) {
val pal = LocalPalette.current
val colors = MaterialTheme.colorScheme
val released = remember(releaseDate) { GameFacts.releaseLabel(releaseDate) }
val publishedBy = publisher.takeUnless { it.isBlank() || it.trim().equals(developer.trim(), ignoreCase = true) }
val rest = listOfNotNull(publishedBy, released)
val credits = if (developer.isBlank() && rest.isEmpty()) null else buildAnnotatedString {
if (developer.isNotBlank()) withStyle(SpanStyle(color = colors.onBackground, fontWeight = FontWeight.SemiBold)) { append(developer) }
rest.forEachIndexed { i, part -> append(if (i == 0 && developer.isBlank()) part else " · $part") }
}
val hltbHere = scores.hltb.let { it is Lookup.Pending || (it as? Lookup.Found)?.value?.let { t -> hltbRuns(t).size > if (scoresInHero) 1 else 0 } == true }
val scoreHere = !scoresInHero && scores.metacritic != Lookup.None
if (credits == null && !hltbHere && !scoreHere) return
val links: @Composable () -> Unit = {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!scoresInHero) Looked(scores.metacritic, exit = fadeOut()) { lookup ->
val found = (lookup as? Lookup.Found)?.value
if (found != null) LineLink(stringResource(R.string.stores_fact_metacritic), found.url) { MetacriticBadge(found.score, small = true) }
else Placeholder(BadgeHeight)
}
if (hltbHere) Looked(scores.hltb, exit = fadeOut()) { lookup ->
val found = (lookup as? Lookup.Found)?.value
if (found != null) LineLink(stringResource(R.string.stores_fact_hltb), found.url) {
for ((secs, what) in hltbRuns(found).drop(if (scoresInHero) 1 else 0)) Text(
buildAnnotatedString {
withStyle(SpanStyle(color = colors.onBackground, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)) { append(hltbTime(secs)) }
append(" ")
append(what)
},
fontSize = 13.sp, color = pal.onSurfaceVariant, maxLines = 1,
)
}
else Placeholder(160.dp)
}
}
}
Column(modifier.fillMaxWidth().padding(top = 12.dp)) {
val creditsText: @Composable (Modifier) -> Unit = { m ->
if (credits != null) Text(credits, fontSize = 14.sp, color = pal.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = m)
}
if (scoresInHero) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp)) {
creditsText(Modifier.weight(1f))
links()
} else Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
creditsText(Modifier)
links()
}
Box(Modifier.padding(top = 8.dp).fillMaxWidth().height(1.dp).background(pal.line))
}
}
@Composable
private fun LineLink(label: String, link: String, content: @Composable () -> Unit) {
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
val hot = rememberHot(src)
Row(
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp),
modifier = Modifier.then(linkSurface(label, link, src, hot, LinkShape)).padding(horizontal = 10.dp, vertical = 6.dp),
) {
content()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = pal.signal, modifier = Modifier.size(12.dp))
}
}
}
@Composable
private fun <T> Looked(lookup: Lookup<T>, exit: ExitTransition, content: @Composable (Lookup<T>) -> Unit) {
var shown by remember { mutableStateOf(lookup) }
@@ -120,35 +201,33 @@ private fun <T> Looked(lookup: Lookup<T>, exit: ExitTransition, content: @Compos
AnimatedVisibility(visible = lookup != Lookup.None, enter = fadeIn(), exit = fadeOut() + exit) { content(shown) }
}
private val FactShape = RoundedCornerShape(12.dp)
private val ValueHeight = 24.dp
private val StatShape = RoundedCornerShape(12.dp)
private val LinkShape = RoundedCornerShape(10.dp)
private val BadgeHeight = 32.dp
@Composable
private fun FactTile(label: String, fill: Boolean = false, link: String? = null, value: @Composable () -> Unit) {
private fun linkSurface(label: String, link: String?, src: MutableInteractionSource, hot: Boolean, shape: RoundedCornerShape, hotFill: Color? = null): Modifier {
if (link == null) return Modifier
val pal = LocalPalette.current
val ctx = LocalContext.current
val src = remember { MutableInteractionSource() }
val hot = link != null && rememberHot(src)
val open = { if (link != null) openUrl(ctx, link) }
val base = if (fill) Modifier.fillMaxWidth() else Modifier.widthIn(max = 360.dp)
val tile = if (link == null) base.clip(FactShape).background(pal.surface).border(1.dp, pal.line, FactShape)
else base.paneItem("fact:$label").clip(FactShape)
.background(if (hot) pal.signal.copy(alpha = 0.14f) else pal.surface)
.glideBorder(hot, FactShape, pal.signal, restColor = pal.line)
val open = { openUrl(ctx, link) }
return Modifier.paneItem("fact:$label").clip(shape)
.background(if (hot) hotFill ?: pal.signal.copy(alpha = 0.16f) else Color.Transparent)
.glideBorder(hot, shape, pal.signal, restColor = Color.Transparent)
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = open)
.controllerConfirm(onClick = open)
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = tile.padding(horizontal = 14.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, color = if (hot) pal.signal else pal.onSurfaceVariant, maxLines = 1)
if (link != null) Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null, tint = if (hot) pal.signal else pal.onSurfaceVariant, modifier = Modifier.size(12.dp))
}
Box(contentAlignment = Alignment.CenterStart, modifier = (if (fill) Modifier.fillMaxWidth() else Modifier).heightIn(min = ValueHeight)) { value() }
}
}
@Composable
private fun FactText(text: String) =
Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
private fun hltbRuns(t: GameFacts.Hltb): List<Pair<Int, String>> = listOf(
t.main to stringResource(R.string.stores_hltb_main),
t.extras to stringResource(R.string.stores_hltb_extras),
t.completionist to stringResource(R.string.stores_hltb_completionist),
).filter { it.first > 0 }
@Composable
private fun hltbTime(secs: Int): String =
GameFacts.hours(secs)?.let { stringResource(R.string.stores_hltb_hours, it) } ?: stringResource(R.string.stores_hltb_minutes, maxOf(1, secs / 60))
internal fun metacriticColour(score: Int): Color = when {
score >= 75 -> Color(0xFF66CC33)
@@ -157,29 +236,10 @@ internal fun metacriticColour(score: Int): Color = when {
}
@Composable
private fun MetacriticBadge(score: Int) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(ValueHeight).clip(RoundedCornerShape(6.dp)).background(metacriticColour(score))) {
Text("$score", fontSize = 13.sp, fontWeight = FontWeight.Black, color = Color(0xFF0A0B0D), maxLines = 1)
}
}
@Composable
private fun HltbTimes(t: GameFacts.Hltb) {
val colors = MaterialTheme.colorScheme
val runs = listOf(
t.main to stringResource(R.string.stores_hltb_main),
t.extras to stringResource(R.string.stores_hltb_extras),
t.completionist to stringResource(R.string.stores_hltb_completionist),
).filter { it.first > 0 }
Row(Modifier.fillMaxWidth()) {
for ((secs, what) in runs) {
val hours = GameFacts.hours(secs)?.let { stringResource(R.string.stores_hltb_hours, it) } ?: stringResource(R.string.stores_hltb_minutes, maxOf(1, secs / 60))
val run = buildAnnotatedString {
withStyle(SpanStyle(fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)) { append(hours) }
withStyle(SpanStyle(fontSize = 12.sp, color = colors.onSurfaceVariant)) { append(" $what") }
}
Text(run, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
}
private fun MetacriticBadge(score: Int, small: Boolean = false) {
val height = if (small) 24.dp else BadgeHeight
Box(contentAlignment = Alignment.Center, modifier = Modifier.height(height).widthIn(min = height + 6.dp).clip(RoundedCornerShape(if (small) 6.dp else 8.dp)).background(metacriticColour(score)).padding(horizontal = 6.dp)) {
Text("$score", fontSize = if (small) 13.sp else 17.sp, fontWeight = FontWeight.Black, color = Color(0xFF0A0B0D), maxLines = 1)
}
}
@@ -213,58 +273,55 @@ private fun FactsPreview(theme: String = Themes.GRAPHITE, narrow: Boolean = fals
@Composable
private fun PreviewCaption(text: String) = Text(text, fontSize = 11.sp, color = LocalPalette.current.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
@Preview(name = "Facts, all found", widthDp = 800, heightDp = 200)
@Composable
private fun FactsFoundPreview() = FactsPreview {
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores)
}
@Preview(name = "Facts, looking up", widthDp = 800, heightDp = 200)
@Composable
private fun FactsPendingPreview() = FactsPreview {
StoreGameFacts("Supergiant Games", "Supergiant Games", "2020.09.17", GameScores(Lookup.Pending, Lookup.Pending))
}
@Preview(name = "Facts, mixed", widthDp = 800, heightDp = 640)
@Composable
private fun FactsMixedPreview() = FactsPreview {
PreviewCaption("Another studio's publisher; score found, times still coming")
StoreGameFacts("New World Computing", "Ubisoft", "1999-02-28", GameScores(previewScore(87), Lookup.Pending))
PreviewCaption("Score found, no times")
StoreGameFacts("Mane6", "Maximum Entertainment", "2022-12-19T16:00:00.000Z", GameScores(previewScore(68), Lookup.None))
PreviewCaption("Neither site knows it: store facts only")
StoreGameFacts("Tiny Indie Studio", "", "2024.03.01", GameScores(Lookup.None, Lookup.None))
PreviewCaption("A low score, a short game with only a main-story time")
StoreGameFacts("", "", "2019-11-05", GameScores(previewScore(42), Lookup.Found(GameFacts.Hltb(main = 2_400, extras = 0, completionist = 0, url = "https://howlongtobeat.com/"))))
PreviewCaption("Epic's no-date placeholder (2099) is not shown")
StoreGameFacts("Studio", "", "2099-01-01T00:00:00.000Z", GameScores(previewScore(81), Lookup.None))
}
@Preview(name = "Facts, narrow pane", widthDp = 420, heightDp = 300)
@Composable
private fun FactsNarrowPreview() = FactsPreview(narrow = true) {
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores)
}
@Preview(name = "Facts, Paper theme", widthDp = 800, heightDp = 200)
@Composable
private fun FactsPaperPreview() = FactsPreview(theme = Themes.PAPER) {
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores)
}
@Preview(name = "Game page, facts in place", widthDp = 800, heightDp = 520)
@Composable
private fun FactsOnPagePreview() = FactsPreview {
val title = "The Witcher 3: Wild Hunt"
private fun PreviewHero(title: String, scores: GameScores) {
Box(Modifier.fillMaxWidth().height(180.dp).clip(Shape16).background(artBrush(hueOf(title)))) {
Box(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to Color.Black.copy(alpha = 0.6f))))
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) {
Text(stringResource(R.string.stores_eyebrow_library).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = LocalPalette.current.signal)
Text(title, fontSize = 30.sp, fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onBackground)
}
HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp))
}
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores)
Text(
"As war rages on throughout the Northern Realms, you take on the greatest contract of your life — tracking down the Child of Prophecy, a living weapon that can alter the shape of the world.",
fontSize = 13.sp, lineHeight = 19.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 12.dp),
)
}
@Preview(name = "Game page, all found", widthDp = 800, heightDp = 320)
@Composable
private fun FactsFoundPreview() = FactsPreview {
PreviewHero("The Witcher 3: Wild Hunt", previewScores)
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores, scoresInHero = true)
}
@Preview(name = "Game page, looking up", widthDp = 800, heightDp = 320)
@Composable
private fun FactsPendingPreview() = FactsPreview {
val pending = GameScores(Lookup.Pending, Lookup.Pending)
PreviewHero("Hades", pending)
StoreGameFacts("Supergiant Games", "Supergiant Games", "2020.09.17", pending, scoresInHero = true)
}
@Preview(name = "Facts line, mixed", widthDp = 800, heightDp = 420)
@Composable
private fun FactsMixedPreview() = FactsPreview {
PreviewCaption("Another studio's publisher; times still coming")
StoreGameFacts("New World Computing", "Ubisoft", "1999-02-28", GameScores(previewScore(87), Lookup.Pending), scoresInHero = true)
PreviewCaption("No times: credits only")
StoreGameFacts("Mane6", "Maximum Entertainment", "2022-12-19T16:00:00.000Z", GameScores(previewScore(68), Lookup.None), scoresInHero = true)
PreviewCaption("Only a main-story time, which the hero shows: credits only")
StoreGameFacts("Tiny Indie Studio", "", "2019-11-05", GameScores(previewScore(42), Lookup.Found(GameFacts.Hltb(main = 2_400, extras = 0, completionist = 0, url = "https://howlongtobeat.com/"))), scoresInHero = true)
PreviewCaption("Epic's no-date placeholder (2099) is not shown")
StoreGameFacts("Studio", "", "2099-01-01T00:00:00.000Z", GameScores(previewScore(81), Lookup.None), scoresInHero = true)
}
@Preview(name = "Facts, narrow pane", widthDp = 420, heightDp = 200)
@Composable
private fun FactsNarrowPreview() = FactsPreview(narrow = true) {
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores, scoresInHero = false)
}
@Preview(name = "Game page, Paper theme", widthDp = 800, heightDp = 320)
@Composable
private fun FactsPaperPreview() = FactsPreview(theme = Themes.PAPER) {
PreviewHero("The Witcher 3: Wild Hunt", previewScores)
StoreGameFacts("CD PROJEKT RED", "CD PROJEKT RED", "2015-05-19T00:00:00+02:00", previewScores, scoresInHero = true)
}
@@ -4,6 +4,17 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.input.key.onPreviewKeyEvent
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -26,10 +37,14 @@ 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.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
@@ -48,6 +63,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val stacked = narrow || LocalConfiguration.current.screenWidthDp < 700
val id = key.substringAfter(':')
val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
val installed = StoresState.installedGame(store, id)
@@ -56,11 +72,9 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
val failedDownload = rawDownload?.takeIf { it.state == DownloadState.FAILED }
val title = item?.title ?: installed?.sidecar?.title ?: id
Rise(0) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(bottom = 12.dp)) {
BackLink(store.label, compact = true, onClick = onBack)
SourceChip(store.id)
}
Box(Modifier.padding(bottom = 12.dp)) { BackLink(stringResource(R.string.common_back), compact = true, onClick = onBack) }
}
val scores = if (store == Store.GOG || store == Store.EPIC) rememberGameScores(title.takeIf { it != id }) else null
val hero: @Composable () -> Unit = {
androidx.compose.runtime.DisposableEffect(Unit) { onDispose { StoresMotion.heroAt = null } }
Box(
@@ -71,6 +85,10 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
val art = item?.imageUrl ?: installed?.sidecar?.hero ?: installed?.sidecar?.cover
if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.matchParentSize())
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0f to colors.background.copy(alpha = 0.94f), 0.5f to colors.background.copy(alpha = 0.55f), 1f to Color.Transparent)))
if (scores != null && !stacked) {
Spacer(Modifier.matchParentSize().background(Brush.horizontalGradient(0.45f to Color.Transparent, 1f to colors.background.copy(alpha = 0.75f))))
HeroScores(scores, Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 18.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.align(Alignment.BottomStart).padding(horizontal = 22.dp, vertical = 18.dp)) {
val state = when {
installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder)
@@ -135,19 +153,15 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
}
}
if (StoresMotion.heroLands) hero() else Rise(1) { hero() }
if (store == Store.GOG || store == Store.EPIC) {
if (scores != null) Rise(2) {
val listed = StoresState.shelves[store]?.all.orEmpty().firstOrNull { it.id == id }
val developer = item?.developer?.ifBlank { null } ?: listed?.developer.orEmpty()
val publisher = item?.publisher?.ifBlank { null } ?: listed?.publisher.orEmpty()
val releaseDate = item?.releaseDate?.ifBlank { null } ?: listed?.releaseDate.orEmpty()
val scores = rememberGameScores(title.takeIf { it != id })
Rise(2) { StoreGameFacts(developer, publisher, releaseDate, scores) }
StoreGameFacts(developer, publisher, releaseDate, scores, scoresInHero = !stacked)
}
val description = item?.let { com.droiddeck.launcher.stores.cleanStoreText(it.description) }.orEmpty()
.takeUnless { it.trim().equals(title.trim(), ignoreCase = true) }.orEmpty()
if (description.isNotBlank()) Rise(3) {
Text(description, fontSize = 13.sp, lineHeight = 19.sp, color = colors.onSurfaceVariant, maxLines = 6, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 12.dp))
}
val achievements = remember(store, id, item?.owned, installed) {
when {
StoreAchievements.forSource(store.id) == null -> null
@@ -156,7 +170,54 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
else -> null
}
}
if (achievements != null) Rise(4) { StoreAchievementsSection(achievements) }
if (description.isNotBlank() || achievements != null) Rise(3) {
val about: @Composable (Modifier, Dp) -> Unit = { m, h -> if (description.isNotBlank()) StoreAbout(description, h, m) }
val card: @Composable (Modifier) -> Unit = { m -> if (achievements != null) StoreAchievementsCard(achievements, title, m) }
if (stacked) Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) {
about(Modifier.fillMaxWidth(), AboutHeight)
card(Modifier.fillMaxWidth())
} else Row(horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.fillMaxWidth().padding(top = 14.dp)) {
val density = LocalDensity.current
var cardHeight by remember { mutableStateOf<Dp?>(null) }
about(Modifier.weight(1f), cardHeight ?: AboutHeight)
card(Modifier.width(340.dp).onSizeChanged { cardHeight = with(density) { it.height.toDp() } })
}
}
}
private val AboutHeight = 150.dp
@Composable
private fun StoreAbout(text: String, height: Dp, modifier: Modifier = Modifier) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val scroll = rememberScrollState()
val scope = rememberCoroutineScope()
val src = remember { MutableInteractionSource() }
val more = scroll.maxValue > 0
val hot = more && rememberHot(src)
val shape = RoundedCornerShape(12.dp)
Box(
modifier.heightIn(max = height).clip(shape)
.glideBorder(hot, shape, pal.signal, restColor = Color.Transparent)
.then(if (more) Modifier.paneItem("about") else Modifier)
.focusable(enabled = more, interactionSource = src)
.onPreviewKeyEvent { e ->
val step = when (e.nativeKeyEvent.keyCode) {
android.view.KeyEvent.KEYCODE_DPAD_DOWN -> if (scroll.value < scroll.maxValue) 120f else return@onPreviewKeyEvent false
android.view.KeyEvent.KEYCODE_DPAD_UP -> if (scroll.value > 0) -120f else return@onPreviewKeyEvent false
else -> return@onPreviewKeyEvent false
}
if (e.nativeKeyEvent.action == android.view.KeyEvent.ACTION_DOWN) scope.launch { scroll.animateScrollBy(step) }
true
},
) {
Text(text, fontSize = 14.sp, lineHeight = 21.sp, color = colors.onSurfaceVariant, modifier = Modifier.verticalScroll(scroll).padding(horizontal = 10.dp, vertical = 6.dp))
if (scroll.value < scroll.maxValue) Box(
Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(28.dp)
.background(Brush.verticalGradient(listOf(Color.Transparent, colors.background))),
)
}
}
@Composable