Skip to main content

Overview

The Daily Image Screen fetches and displays NASA’s Astronomy Picture of the Day (APOD) from the NASA API. Users can view the image, read its description, and save it to their favorites collection in Firebase.

Key Features

APOD Integration

Fetches NASA’s featured astronomy picture of the day

Favorites System

Save and remove images from Firebase favorites collection

Expandable Text

Collapsible description with “ver más” / “ver menos” toggle

Image Loading

Async image loading with Coil and crossfade animations

Screen Components

Main UI Elements

  • Image Title: Large headline displaying the image title
  • Astronomy Image: 300dp height image with rounded corners
  • Favorite Button: Toggle button to save/remove from favorites
  • Description Card: Expandable card showing the image explanation
  • Loading Indicator: Circular progress indicator during API calls
Images are loaded asynchronously using Coil with a 1000ms crossfade animation.

Architecture

DailyImageScreen Composable

@Composable
fun DailyImageScreen(dailyImageViewModel: DailyImageViewModel = hiltViewModel()) {
    LaunchedEffect(Unit) {
        dailyImageViewModel.loadDailyImage()
    }

    val dailyImage by dailyImageViewModel.dailyImage.collectAsState()
    val errorMessage by dailyImageViewModel.errorMessage.collectAsState()
    val isLoading by dailyImageViewModel.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)
                )
            }
            dailyImage != null -> {
                LazyColumn {
                    item {
                        dailyImage?.let { nasaModel ->
                            dailyImageViewModel.checkIsFavorite(nasaModel.url)
                            ImageItem(
                                nasaModel = nasaModel,
                                dailyImageViewModel = dailyImageViewModel
                            )
                        }
                    }
                }
            }
        }
    }
}

ViewModel Implementation

DailyImageViewModel

@HiltViewModel
class DailyImageViewModel @Inject constructor(
    private val nasaRepository: NasaRepository,
    private val firebaseAuth: FirebaseAuth,
    private val firebaseDatabase: FirebaseDatabase
) : ViewModel() {

    private val _dailyImage = MutableStateFlow<NasaModel?>(null)
    val dailyImage: StateFlow<NasaModel?> = _dailyImage

    private val _errorMessage = MutableStateFlow<String?>(null)
    val errorMessage: StateFlow<String?> = _errorMessage

    private val _isLoading = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading

    private val _isFavorite = MutableStateFlow<Boolean>(false)
    val isFavorite: StateFlow<Boolean> = _isFavorite
}

State Management

StateTypePurpose
dailyImageStateFlow<NasaModel?>Stores the current daily image data
errorMessageStateFlow<String?>Error messages for network or loading failures
isLoadingStateFlow<Boolean>Loading state indicator
isFavoriteStateFlow<Boolean>Whether the current image is favorited

Loading Daily Image

loadDailyImage Function

fun loadDailyImage(date: String? = null) {
    viewModelScope.launch {
        _isLoading.value = true
        
        try {
            val result = nasaRepository.getImageOfTheDay(date = date)
            _dailyImage.value = result
            _errorMessage.value = null
        } 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"
            _dailyImage.value = null
        } finally {
            _isLoading.value = false
        }
    }
}
The function accepts an optional date parameter to load images from specific dates (format: “yyyy-MM-dd”).

Image Display Component

ImageItem Composable

@Composable
fun ImageItem(nasaModel: NasaModel, dailyImageViewModel: DailyImageViewModel) {
    val isFavorite by dailyImageViewModel.isFavorite.collectAsState()
    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 card
    }
}

Image Loading with Coil

1

Image Request Builder

Creates an ImageRequest with the NASA image URL
ImageRequest.Builder(LocalContext.current)
    .data(nasaModel.url)
    .crossfade(1000).build()
2

AsyncImage Component

Displays the image with loading and error states
placeholder = painterResource(id = R.drawable.placeholder)
error = painterResource(id = R.drawable.placeholder)
3

Crossfade Animation

Smooth 1000ms fade-in transition when image loads

Favorites System

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()
                _isFavorite.value = true
            } 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()
                }
                
                _isFavorite.value = false
            } catch (e: Exception) {
                _errorMessage.value = "Error al buscar favorito ${e.message}"
            }
        }
    }
}

Check Favorite Status

fun checkIsFavorite(url: String) {
    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(url)
                    .get()
                    .await()
                
                _isFavorite.value = snapshot.exists()
            } catch (e: Exception) {
                _errorMessage.value = "Error al cargar estado de favoritos ${e.message}"
            }
        }
    }
}
All Firebase operations use .await() to convert callbacks to suspending functions for use with coroutines.

Favorite Button

Toggle Implementation

IconButton(onClick = {
    if (isFavorite) {
        dailyImageViewModel.removeFromFavorites(nasaModel = nasaModel)
    } else {
        dailyImageViewModel.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
    )
}

Description Card

Expandable Text Card

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)
        )
    }
}
The description is limited to 3 lines when collapsed and expands to show full text when clicked.

Error Handling

Network Error

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"
    _dailyImage.value = null
}

Authentication Error

if (userId == null) {
    _errorMessage.value = "Usuario no autenticado"
}

Dependencies

Firebase

import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.database.FirebaseDatabase
import kotlinx.coroutines.tasks.await

Coil Image Loading

import coil.compose.AsyncImage
import coil.request.ImageRequest

Hilt Injection

import androidx.hilt.navigation.compose.hiltViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

File Location

app/src/main/java/com/ccandeladev/nasaexplorer/ui/dailyimagescreen/
├── DailyImageScreen.kt
└── DailyImageViewModel.kt
Best Practice: The screen uses LaunchedEffect to load data only once when the composable enters the composition.

Build docs developers (and LLMs) love