mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
feat: redesign the store game page's facts, description and achievements (#775)
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user