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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -42,15 +43,18 @@ fun LoadingDialog(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (progress < 0) {
// Show spinner for indeterminate progress
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
}
Text(message)
Spacer(modifier = Modifier.height(16.dp))
if (progress >= 0) {
Spacer(modifier = Modifier.height(16.dp))
Text(min(100, (progress * 100.0).roundToInt()).toString() + "%")
LinearProgressIndicator(
progress = { progress },
)
} else {
LinearProgressIndicator()
}
}
}
Expand Down
154 changes: 154 additions & 0 deletions app/src/main/java/app/gamenative/ui/component/dialog/LoadingToast.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package app.gamenative.ui.component.dialog

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.filled.CheckCircle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.delay

/**
* Toast-style loading indicator that appears at the bottom of the screen.
*
* @param visible Whether the toast is visible
* @param message The loading message to display
* @param doneMessage Optional message to show when done (triggers checkmark and fade out)
* @param onDismiss Called when the toast should be dismissed (after showing done message)
*/
@Composable
fun LoadingToast(
visible: Boolean,
message: String,
doneMessage: String? = null,
onDismiss: () -> Unit = {},
) {
var showDone by remember { mutableStateOf(false) }
var shouldFadeOut by remember { mutableStateOf(false) }

// When doneMessage is set, show checkmark and then fade out
LaunchedEffect(doneMessage) {
if (doneMessage != null) {
showDone = true
// Show done message for duration similar to Toast.LENGTH_SHORT (~2 seconds)
delay(2000)
shouldFadeOut = true
delay(300) // Wait for fade out animation
onDismiss()
}
}

// Reset state when visibility changes
LaunchedEffect(visible) {
if (!visible) {
showDone = false
shouldFadeOut = false
}
}

// Keep toast visible during fade out animation
val toastVisible = visible || shouldFadeOut

if (toastVisible) {
// Custom toast overlay - no dialog, just a Box with high z-index
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(1000f) // High z-index to appear on top
) {
AnimatedVisibility(
visible = visible && !shouldFadeOut,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 50.dp), // Match toast positioning (50dp from bottom)
contentAlignment = Alignment.BottomCenter
) {
// Toast-style card at the bottom
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF323232).copy(alpha = 0.95f))
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Animated transition between spinner and checkmark
AnimatedContent(
targetState = showDone && doneMessage != null,
transitionSpec = {
(fadeIn() + scaleIn(initialScale = 0.8f)) togetherWith
(fadeOut() + scaleOut(targetScale = 0.8f))
},
label = "iconTransition"
) { isDone ->
if (isDone) {
// Show checkmark when done
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.White
)
} else {
// Show spinner while loading
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
}

// Animated transition between messages
Crossfade(
targetState = if (showDone && doneMessage != null) doneMessage else message,
label = "messageTransition"
) { currentMessage ->
Text(
text = currentMessage ?: message,
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,12 @@ internal fun AppScreenContent(
.fillMaxWidth()
.height(250.dp)
) {
// Hero background image
if (displayInfo.heroImageUrl != null) {
// Header background image (use headerUrl, fallback to heroImageUrl if header not available)
val headerImageUrl = displayInfo.headerUrl ?: displayInfo.heroImageUrl
if (headerImageUrl != null) {
CoilImage(
modifier = Modifier.fillMaxSize(),
imageModel = { displayInfo.heroImageUrl },
imageModel = { headerImageUrl },
imageOptions = ImageOptions(contentScale = ContentScale.Crop),
loading = { LoadingScreen() },
failure = {
Expand All @@ -264,7 +265,7 @@ internal fun AppScreenContent(
previewPlaceholder = painterResource(R.drawable.testhero),
)
} else {
// Fallback gradient background when no hero image
// Fallback gradient background when no header image
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.primary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
Expand All @@ -28,6 +29,7 @@ import app.gamenative.data.LibraryItem
import app.gamenative.events.AndroidEvent
import app.gamenative.PluviaApp
import app.gamenative.ui.component.dialog.ContainerConfigDialog
import app.gamenative.ui.component.dialog.LoadingToast
import app.gamenative.ui.data.AppMenuOption
import app.gamenative.ui.data.GameDisplayInfo
import app.gamenative.ui.enums.AppOptionMenuType
Expand All @@ -46,6 +48,31 @@ import kotlinx.coroutines.withContext
* This defines the contract that all game source-specific screens must implement.
*/
abstract class BaseAppScreen {
companion object {
// Track which appId is currently fetching images
private val fetchingImagesAppId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
val fetchingImagesFlow: kotlinx.coroutines.flow.StateFlow<String?> = fetchingImagesAppId

// Track done message for the currently fetching app
private val fetchingImagesDoneMessage = kotlinx.coroutines.flow.MutableStateFlow<Pair<String?, String?>?>(null)
val fetchingImagesDoneMessageFlow: kotlinx.coroutines.flow.StateFlow<Pair<String?, String?>?> = fetchingImagesDoneMessage

fun isFetchingImages(appId: String): Boolean {
return fetchingImagesAppId.value == appId
}

fun setFetchingImages(appId: String?, isFetching: Boolean) {
fetchingImagesAppId.value = if (isFetching) appId else null
if (!isFetching) {
// Clear done message when not fetching
fetchingImagesDoneMessage.value = null
}
}

fun setFetchingImagesDone(appId: String?, doneMessage: String?) {
fetchingImagesDoneMessage.value = Pair(appId, doneMessage)
}
}
/**
* Get the game display information for rendering the UI.
* This is called to get all the data needed for the common UI layout.
Expand Down Expand Up @@ -290,6 +317,9 @@ abstract class BaseAppScreen {
return AppMenuOption(
optionType = AppOptionMenuType.FetchSteamGridDBImages,
onClick = {
// Set loading state
setFetchingImages(libraryItem.appId, true)

CoroutineScope(Dispatchers.IO).launch {
try {
val gameName = libraryItem.name
Expand All @@ -305,18 +335,29 @@ abstract class BaseAppScreen {
)

SteamGridDB.fetchGameImages(gameName, gameFolderPath)
PluviaApp.events.emit(AndroidEvent.CustomGameImagesFetched(libraryItem.appId))

// Call hook for post-fetch processing (e.g., icon extraction)
onAfterFetchImages(context, libraryItem, gameFolderPath)

// Notify UI that images have been refreshed
app.gamenative.utils.GameImageUtils.notifyImagesRefreshed()

// Emit event to notify UI that images have been fetched
PluviaApp.events.emit(AndroidEvent.CustomGameImagesFetched(libraryItem.appId))

// Set done message to show checkmark and fade out (don't clear fetching state yet)
withContext(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.base_app_images_fetched),
Toast.LENGTH_SHORT
).show()
setFetchingImagesDone(
libraryItem.appId,
context.getString(R.string.base_app_images_fetched)
)
}
// Note: Don't clear fetching state in finally block for success case
// It will be cleared by LoadingToast's onDismiss after fade animation
} else {
withContext(Dispatchers.Main) {
// Clear loading state immediately for error case
setFetchingImages(libraryItem.appId, false)
Toast.makeText(
context,
context.getString(R.string.base_app_game_folder_not_found),
Expand All @@ -326,6 +367,8 @@ abstract class BaseAppScreen {
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
// Clear loading state immediately for error case
setFetchingImages(libraryItem.appId, false)
Toast.makeText(
context,
context.getString(
Expand Down Expand Up @@ -489,6 +532,26 @@ abstract class BaseAppScreen {
val context = LocalContext.current
val displayInfo = getGameDisplayInfo(context, libraryItem)

// Observe fetching images state
val fetchingAppId by BaseAppScreen.fetchingImagesFlow.collectAsState()
val fetchingDoneMessage by BaseAppScreen.fetchingImagesDoneMessageFlow.collectAsState()
val isFetchingImages = fetchingAppId == libraryItem.appId
val doneMessage = if (fetchingDoneMessage?.first == libraryItem.appId) {
fetchingDoneMessage?.second
} else null

// Show toast-style loading indicator while fetching images
LoadingToast(
visible = isFetchingImages || doneMessage != null,
message = context.getString(R.string.base_app_images_fetching),
doneMessage = doneMessage,
onDismiss = {
// Clear both fetching state and done message
BaseAppScreen.setFetchingImages(libraryItem.appId, false)
BaseAppScreen.setFetchingImagesDone(libraryItem.appId, null)
}
)

// Use composable state for values that change over time
var isInstalledState by remember(libraryItem.appId) {
mutableStateOf(isInstalled(context, libraryItem))
Expand Down Expand Up @@ -648,6 +711,13 @@ abstract class BaseAppScreen {
saveContainerConfig(context, libraryItem, it)
showConfigDialog = false
},
mediaHeroUrl = displayInfo.heroImageUrl,
mediaLogoUrl = displayInfo.logoUrl,
mediaCapsuleUrl = displayInfo.capsuleUrl,
mediaHeaderUrl = displayInfo.headerUrl,
mediaIconUrl = displayInfo.iconUrl,
gameId = displayInfo.gameId,
appId = libraryItem.appId,
)
}

Expand Down
Loading