Overview
The Random Image Screen fetches and displays 5 random NASA astronomy images from the APOD API. Each image includes its title, explanation, and the ability to save or remove it from favorites with individual favorite state tracking.
Key Features
Random Images Fetches 5 random astronomy pictures from NASA’s archive
Individual Favorites Track favorite status for each image independently
Lazy Loading Scrollable list with efficient LazyColumn rendering
Expandable Descriptions Collapsible text with toggle for full explanations
Architecture
RandomImageScreen Composable
@Composable
fun RandomImageScreen (randomImageViewModel: RandomImageViewModel = hiltViewModel ()) {
LaunchedEffect (Unit) {
randomImageViewModel. loadRandomImages ()
}
val randomImages by randomImageViewModel.randomImages. collectAsState ()
val errorMessage by randomImageViewModel.errorMessage. collectAsState ()
val isLoading by randomImageViewModel.isLoading. collectAsState ()
Box (Modifier. fillMaxSize ()) {
when {
isLoading -> {
CircularProgressIndicator (modifier = Modifier. align (Alignment.Center))
}
errorMessage != null -> {
Text (
text = " $errorMessage " ,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier. align (Alignment.Center). padding ( 24 .dp)
)
}
randomImages. isNotEmpty () -> {
LazyColumn {
items (randomImages) { nasaModel ->
ImageItem (
nasaModel = nasaModel,
randomImageViewModel = randomImageViewModel
)
}
}
}
}
}
}
ViewModel Implementation
RandomImageViewModel
@HiltViewModel
class RandomImageViewModel @Inject constructor (
private val nasaRepository: NasaRepository ,
private val firebaseAuth: FirebaseAuth ,
private val firebaseDatabase: FirebaseDatabase
) : ViewModel () {
private val _randomImages = MutableStateFlow < List < NasaModel >>( emptyList ())
val randomImages: StateFlow < List < NasaModel >> = _randomImages
private val _errorMessage = MutableStateFlow < String ?>( null )
val errorMessage: StateFlow < String ?> = _errorMessage
private val _isLoading = MutableStateFlow ( false )
val isLoading: StateFlow < Boolean > = _isLoading
private val _favoriteStates = MutableStateFlow < Map < String , Boolean >>( emptyMap ())
val favoriteState: StateFlow < Map < String , Boolean >> = _favoriteStates
}
State Management
State Type Purpose randomImagesStateFlow<List<NasaModel>>List of random NASA images errorMessageStateFlow<String?>Network or loading error messages isLoadingStateFlow<Boolean>Loading indicator state favoriteStatesStateFlow<Map<String, Boolean>>Map of URL to favorite status
Unlike the Daily Image Screen, this screen uses a Map to track favorite states for multiple images simultaneously.
Loading Random Images
loadRandomImages Function
fun loadRandomImages (count: Int = 5 ) {
viewModelScope. launch {
_isLoading. value = true
try {
val results = nasaRepository. getRandomImages (count = count)
_randomImages. value = results
_errorMessage. value = null
checkFavorites (results. map { it.url })
} catch (e: Exception ) {
_errorMessage. value = "Sin conexión a internet. Conéctate a una red Wi-Fi o habilita datos móviles para ver las imágenes"
_randomImages. value = emptyList ()
} finally {
_isLoading. value = false
}
}
}
The function accepts a count parameter (default: 5) to specify how many random images to fetch.
Image Item Component
ImageItem Composable
@Composable
fun ImageItem (nasaModel: NasaModel , randomImageViewModel: RandomImageViewModel ) {
val favoriteState by randomImageViewModel.favoriteState. collectAsState ()
val isFavorite = favoriteState[nasaModel.url] ?: false
var isExpanded by remember { mutableStateOf ( false ) }
Column (
Modifier
. fillMaxWidth ()
. padding ( 8 .dp)
) {
Text (
text = nasaModel.title,
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
modifier = Modifier. fillMaxWidth ()
)
AsyncImage (
model = ImageRequest. Builder (LocalContext.current)
. data (nasaModel.url)
. crossfade ( 1000 ). build (),
contentDescription = nasaModel.title,
modifier = Modifier
. fillMaxWidth ()
. height ( 300 .dp)
. padding (top = 24 .dp)
. clip ( RoundedCornerShape ( 16 .dp)),
contentScale = ContentScale.Crop,
placeholder = painterResource (id = R.drawable.placeholder),
error = painterResource (id = R.drawable.placeholder)
)
// Favorite button and description
}
HorizontalDivider (
modifier = Modifier. padding (top = 16 .dp, bottom = 16 .dp),
color = MaterialTheme.colorScheme.onBackground. copy (alpha = 0.4f )
)
}
Favorites Management
Save to Favorites
fun saveToFavorites (nasaModel: NasaModel ) {
val userId = firebaseAuth.currentUser?.uid
if (userId != null ) {
viewModelScope. launch {
try {
val favoriteRef = firebaseDatabase.reference
. child ( "favorites" )
. child (userId)
. push ()
val favoriteImage = mapOf (
"id" to (favoriteRef.key ?: "" ),
"title" to nasaModel.title,
"url" to nasaModel.url
)
favoriteRef. setValue (favoriteImage). await ()
val currentState = _favoriteStates. value . toMutableMap ()
currentState[nasaModel.url] = true
_favoriteStates. value = currentState
} catch (e: Exception ) {
_errorMessage. value = "Error al guardar favorito ${ e.message } "
}
}
}
}
Remove from Favorites
fun removeFromFavorites (nasaModel: NasaModel ) {
val userId = firebaseAuth.currentUser?.uid
if (userId != null ) {
viewModelScope. launch {
try {
val favoriteRef = firebaseDatabase.reference
. child ( "favorites" )
. child (userId)
val snapshot = favoriteRef
. orderByChild ( "url" )
. equalTo (nasaModel.url)
. get ()
. await ()
for (child in snapshot.children) {
child.ref. removeValue (). await ()
}
val currentState = _favoriteStates. value . toMutableMap ()
currentState[nasaModel.url] = false
_favoriteStates. value = currentState
} catch (e: Exception ) {
_errorMessage. value = "Error al borrar favorito ${ e.message } "
}
}
}
}
Check Multiple Favorites
private fun checkFavorites (urls: List < String >) {
val userId = firebaseAuth.currentUser?.uid
if (userId != null ) {
viewModelScope. launch {
try {
val favoriteRef = firebaseDatabase.reference
. child ( "favorites" )
. child (userId)
val snapshot = favoriteRef. get (). await ()
val currentState = _favoriteStates. value . toMutableMap ()
for (url in urls) {
currentState[url] = snapshot.children. any {
it. child ( "url" ). value == url
}
}
_favoriteStates. value = currentState
} catch (e: Exception ) {
_errorMessage. value = "Error al cargar estado de favorito ${ e.message } "
}
}
}
}
The checkFavorites function iterates through all URLs to build a complete state map for efficient UI updates.
Toggle Logic
IconButton (onClick = {
if (isFavorite) {
randomImageViewModel. removeFromFavorites (nasaModel = nasaModel)
} else {
randomImageViewModel. saveToFavorites (nasaModel = nasaModel)
}
}) {
Icon (
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = "Favorites border" ,
tint = if (isFavorite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground
)
}
Favorite State Lookup
val favoriteState by randomImageViewModel.favoriteState. collectAsState ()
val isFavorite = favoriteState[nasaModel.url] ?: false
The ?: false provides a default value when the URL isn’t found in the favorites map.
Expandable Description
Implementation
var isExpanded by remember { mutableStateOf ( false ) }
Card (
modifier = Modifier
. fillMaxWidth ()
. padding (top = 8 .dp)
. clickable { isExpanded = ! isExpanded },
shape = MaterialTheme.shapes.medium,
colors = CardDefaults. cardColors (containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults. cardElevation ( 4 .dp),
border = BorderStroke ( 1 .dp, MaterialTheme.colorScheme.onSurface. copy (alpha = 0.2f ))
) {
Column (Modifier. padding ( 8 .dp)) {
Text (
text = nasaModel.explanation,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = if (isExpanded) Int.MAX_VALUE else 3 ,
textAlign = TextAlign.Justify,
modifier = Modifier. fillMaxWidth ()
)
Text (
text = if (isExpanded) "ver menos" else "ver más" ,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier. padding (top = 4 .dp)
)
}
}
Item Separator
Each image item is separated by a horizontal divider:
HorizontalDivider (
modifier = Modifier. padding (top = 16 .dp, bottom = 16 .dp),
color = MaterialTheme.colorScheme.onBackground. copy (alpha = 0.4f )
)
LazyColumn Implementation
Efficient List Rendering
LazyColumn (
Modifier
. fillMaxSize ()
. background (MaterialTheme.colorScheme.background)
. padding (top = 16 .dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
items (randomImages) { nasaModel ->
ImageItem (
nasaModel = nasaModel,
randomImageViewModel = randomImageViewModel
)
}
}
Performance : LazyColumn only composes visible items, making it efficient for scrolling through multiple images.
Error Handling
Network Errors
catch (e: Exception ) {
_errorMessage. value = "Sin conexión a internet. Conéctate a una red Wi-Fi o habilita datos móviles para ver las imágenes"
_randomImages. value = emptyList ()
}
Empty State
else -> {
Text (
text = "No hay imágenes disponibles" ,
textAlign = TextAlign.Center,
modifier = Modifier. align (Alignment.Center)
)
}
Dependencies
Firebase
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.database.FirebaseDatabase
import kotlinx.coroutines.tasks.await
Lazy Lists
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
Coil
import coil.compose.AsyncImage
import coil.request.ImageRequest
File Location
app/src/main/java/com/ccandeladev/nasaexplorer/ui/randomimagescreen/
├── RandomImageScreen.kt
└── RandomImageViewModel.kt
Best Practice : The screen uses a Map-based state approach for tracking multiple favorite states efficiently.