Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src/main/java/app/gamenative/PrefManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,14 @@ object PrefManager {
setPref(GAME_COMPATIBILITY_CACHE, value)
}

// HLTB cache (JSON string)
private val HLTB_CACHE = stringPreferencesKey("hltb_cache")
var hltbCache: String
get() = getPref(HLTB_CACHE, "{}")
set(value) {
setPref(HLTB_CACHE, value)
}

/* Security / Attestation */
private val KEY_ATTESTATION_AVAILABLE = booleanPreferencesKey("key_attestation_available")
var keyAttestationAvailable: Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ data class GameDisplayInfo(
val headerUrl: String? = null, // Header image URL (for SteamGridDB, can use grid as header)
val compatibilityMessage: String? = null, // Compatibility message text (e.g., "Works on your GPU")
val compatibilityColor: ULong? = null, // Compatibility message color (ARGB)
val hltbStats: app.gamenative.utils.HltbService.Stats? = null, // How Long To Beat stats
)

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
package app.gamenative.ui.screen.library

import android.content.Intent
import androidx.compose.foundation.clickable
import app.gamenative.ui.screen.library.components.HltbHeroStrip
import android.content.res.Configuration
import app.gamenative.ui.screen.library.components.ambient.AmbientDownloadOverlay
import android.view.KeyEvent
Expand Down Expand Up @@ -46,6 +48,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
Expand Down Expand Up @@ -95,6 +98,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -803,6 +807,12 @@ internal fun AppScreenContent(

Spacer(modifier = Modifier.height(16.dp))

// HLTB stats strip (above play bar)
displayInfo.hltbStats?.let { hltb ->
HltbHeroStrip(hltb)
Spacer(modifier = Modifier.height(8.dp))
}

// Integrated action bar - overlaid on hero
val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT
Column(
Expand Down Expand Up @@ -1094,6 +1104,7 @@ internal fun AppScreenContent(
}
}
}

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -895,9 +895,23 @@ abstract class BaseAppScreen {
onBack: () -> Unit,
) {
val context = LocalContext.current
val displayInfo = getGameDisplayInfo(context, libraryItem)
val displayInfoBase = getGameDisplayInfo(context, libraryItem)
val appId = libraryItem.appId

// Fetch HLTB stats asynchronously (best-effort)
var hltbStats by remember(displayInfoBase.name) {
mutableStateOf<app.gamenative.utils.HltbService.Stats?>(null)
}
LaunchedEffect(displayInfoBase.name) {
if (displayInfoBase.name.isNotBlank())
hltbStats = try {
app.gamenative.utils.HltbService.getStats(displayInfoBase.name)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (_: Exception) { null }
}
val displayInfo = displayInfoBase.copy(hltbStats = hltbStats)

// Use composable state for values that change over time
var isInstalledState by remember(libraryItem.appId) {
mutableStateOf(isInstalled(context, libraryItem))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package app.gamenative.ui.screen.library.components

import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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.TextAlign
import androidx.compose.ui.unit.dp
import app.gamenative.R
import app.gamenative.utils.HltbService
import timber.log.Timber

@Composable
fun HltbHeroStrip(stats: HltbService.Stats) {
val context = LocalContext.current
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
listOf(
stringResource(R.string.hltb_main_story) to stats.mainHours,
stringResource(R.string.hltb_main_plus_extras) to stats.mainPlusHours,
stringResource(R.string.hltb_completionist) to stats.completeHours,
stringResource(R.string.hltb_all_styles) to stats.allStylesHours,
).forEach { (label, hours) ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (hours == "--") "--" else "${hours}h",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
color = Color.White,
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center,
)
}
}
if (stats.gameId > 0) {
IconButton(
onClick = {
try {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))
)
} catch (e: ActivityNotFoundException) {
Timber.tag("HLTB").w(e, "No handler for HLTB game URL")
}
},
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = stringResource(R.string.hltb_view_on_hltb),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp),
)
}
}
Comment on lines +66 to +86
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Icon click risks crash and has an undersized touch target.

Two concerns on the external-link icon:

  1. context.startActivity(Intent.ACTION_VIEW, …) can throw ActivityNotFoundException on devices without a browser/handler (e.g., stripped-down Android TV images). Wrap the call in a try/catch so a missing handler doesn't crash the app.
  2. The clickable area is 18dp, well under Material's 48dp minimum touch target. Consider sizing the clickable region to ≥ 48dp (via minimumInteractiveComponentSize()/IconButton, or wrapping in a 48dp clickable Box) and adding a Role.Button semantic for accessibility.
♻️ Proposed refactor
+import androidx.compose.material3.IconButton
+import androidx.compose.ui.semantics.Role
+import timber.log.Timber
...
         if (stats.gameId > 0) {
-            Icon(
-                imageVector = Icons.AutoMirrored.Filled.OpenInNew,
-                contentDescription = stringResource(R.string.hltb_view_on_hltb),
-                tint = Color.White.copy(alpha = 0.7f),
-                modifier = Modifier
-                    .size(18.dp)
-                    .clickable {
-                        context.startActivity(
-                            Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))
-                        )
-                    },
-            )
+            IconButton(
+                onClick = {
+                    val intent = Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))
+                    try {
+                        context.startActivity(intent)
+                    } catch (e: android.content.ActivityNotFoundException) {
+                        Timber.tag("HLTB").w(e, "No activity to handle HLTB link")
+                    }
+                },
+            ) {
+                Icon(
+                    imageVector = Icons.AutoMirrored.Filled.OpenInNew,
+                    contentDescription = stringResource(R.string.hltb_view_on_hltb),
+                    tint = Color.White.copy(alpha = 0.7f),
+                    modifier = Modifier.size(18.dp),
+                )
+            }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (stats.gameId > 0) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = stringResource(R.string.hltb_view_on_hltb),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier
.size(18.dp)
.clickable {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))
)
},
)
}
if (stats.gameId > 0) {
IconButton(
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))
try {
context.startActivity(intent)
} catch (e: android.content.ActivityNotFoundException) {
Timber.tag("HLTB").w(e, "No activity to handle HLTB link")
}
},
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = stringResource(R.string.hltb_view_on_hltb),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp),
)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/app/gamenative/ui/screen/library/components/HltbHeroStrip.kt`
around lines 64 - 77, The external-link Icon in HltbHeroStrip.kt (the branch
checking stats.gameId > 0 and the Icon with clickable modifier) needs two fixes:
wrap the context.startActivity(Intent(Intent.ACTION_VIEW,
Uri.parse("${HltbService.GAME_URL}${stats.gameId}"))) call in a try/catch that
catches ActivityNotFoundException and logs/ignores it to avoid crashes, and
increase the touch target to at least 48dp by using an IconButton or wrapping
the Icon in a Box/Modifier with minimumInteractiveComponentSize(48.dp) (or a
48.dp clickable Box) and add semantics role = Role.Button for accessibility so
the element is both safe and reachable.

}
}
Loading
Loading