From 06bd0de4d77cee42c559249d56c374a95ef20132 Mon Sep 17 00:00:00 2001 From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:33:43 -0400 Subject: [PATCH] feat: redesign the store game page's facts, description and achievements (#775) --- .../launcher/ui/StoreAchievementsUi.kt | 147 ++++++--- .../droiddeck/launcher/ui/StoreGameFacts.kt | 283 +++++++++++------- .../com/droiddeck/launcher/ui/StoresDetail.kt | 83 ++++- 3 files changed, 353 insertions(+), 160 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt index f033aa9d..e0d5099a 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreAchievementsUi.kt @@ -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): List>> 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, 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, 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, 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) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt index d57b63b5..5a770942 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoreGameFacts.kt @@ -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 Looked(lookup: Lookup, exit: ExitTransition, content: @Composable (Lookup) -> Unit) { var shown by remember { mutableStateOf(lookup) } @@ -120,35 +201,33 @@ private fun Looked(lookup: Lookup, 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> = 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) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt index 125e4f10..41e46975 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt @@ -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(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