Skip to main content

Overview

The Favorites Screen displays all NASA images saved by the user to their Firebase favorites collection. Users can view their saved images, add personal comments to each image, and remove items from their favorites with smooth fade-out animations.

Key Features

Firebase Integration

Loads favorites from Firebase Realtime Database

Personal Comments

Add and persist comments for each favorite image

Remove Favorites

Delete favorites with fade-out animation effects

Empty State

Displays helpful message when no favorites exist

Architecture

FavoritesScreen Composable

@Composable
fun FavoritesScreen(favoritesViewModel: FavoritesViewModel = hiltViewModel()) {
    LaunchedEffect(Unit) {
        favoritesViewModel.loadFavoriteImages()
    }

    val favoriteImages by favoritesViewModel.favoriteImages.collectAsState()
    val errorMessage by favoritesViewModel.errorMessage.collectAsState()
    val isLoading by favoritesViewModel.isLoading.collectAsState()

    Box(Modifier.fillMaxSize()) {
        when {
            isLoading -> {
                CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
            }
            errorMessage.isNotEmpty() -> {
                Text(
                    text = errorMessage,
                    color = Color.White,
                    textAlign = TextAlign.Center,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
            favoriteImages.isEmpty() -> {
                Text(
                    text = "No hay favoritos guardados",
                    textAlign = TextAlign.Center,
                    modifier = Modifier.align(Alignment.Center),
                    style = MaterialTheme.typography.bodyLarge
                )
            }
            else -> {
                LazyColumn {
                    items(favoriteImages) { favoriteNasaModel ->
                        ImageItem(
                            favoriteNasaModel = favoriteNasaModel,
                            favoritesViewModel = favoritesViewModel
                        )
                    }
                }
            }
        }
    }
}

Data Model

FavoriteNasaModel

The screen uses a specialized model for favorite images:
data class FavoriteNasaModel(
    val firebaseImageId: String,  // Unique Firebase-generated ID
    val title: String,
    val url: String
)
The firebaseImageId is crucial for managing comments and deletions in Firebase.

ViewModel Implementation

FavoritesViewModel

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

    private val _favoriteImages = MutableStateFlow<List<FavoriteNasaModel>>(emptyList())
    val favoriteImages: StateFlow<List<FavoriteNasaModel>> = _favoriteImages

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

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

    private val _comments = MutableStateFlow<Map<String, String>>(emptyMap())
    val comments: StateFlow<Map<String, String>> = _comments
}

State Management

StateTypePurpose
favoriteImagesStateFlow<List<FavoriteNasaModel>>List of user’s favorite images
errorMessageStateFlow<String>Error messages for loading or operations
isLoadingStateFlow<Boolean>Loading indicator state
commentsStateFlow<Map<String, String>>Map of image ID to comment text

Loading Favorites

loadFavoriteImages Function

fun loadFavoriteImages() {
    val userId = firebaseAuth.currentUser?.uid
    
    if (userId != null) {
        viewModelScope.launch {
            _isLoading.value = true
            
            try {
                val favoriteRef = firebaseDatabase.reference
                    .child("favorites")
                    .child(userId)
                
                val snapshot = favoriteRef.get().await()

                val favoriteList = snapshot.children.mapNotNull { child ->
                    val firebaseImageId = child.key ?: return@mapNotNull null
                    val title = child.child("title").getValue(String::class.java)
                    val url = child.child("url").getValue(String::class.java)

                    if (title != null && url != null) {
                        FavoriteNasaModel(
                            firebaseImageId = firebaseImageId,
                            title = title,
                            url = url
                        )
                    } else null
                }
                
                if (favoriteList.isEmpty()) {
                    _errorMessage.value = "No tienes imágenes favoritas"
                }
                
                _favoriteImages.value = favoriteList
                loadComments()
            } catch (e: Exception) {
                _errorMessage.value = "Error al mostrar la lista de favoritos"
                _favoriteImages.value = emptyList()
            } finally {
                _isLoading.value = false
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}
Favorites are stored under favorites/{userId}/{firebaseImageId} in Firebase Realtime Database.

Comments System

Load Comments

private fun loadComments() {
    val userId = firebaseAuth.currentUser?.uid
    
    if (userId != null) {
        viewModelScope.launch {
            try {
                val commentsRef = firebaseDatabase.reference
                    .child("comments")
                    .child(userId)
                
                val snapshot = commentsRef.get().await()
                
                val commentsMap = snapshot.children.associate { child ->
                    val favoriteImageId = child.key ?: ""
                    val comment = child.getValue(String::class.java) ?: ""
                    favoriteImageId to comment
                }
                
                _comments.value = commentsMap
            } catch (e: Exception) {
                _errorMessage.value = "Error al cargar comentario: ${e.message}"
            }
        }
    }
}

Add Comment

fun addComment(firebaseImageId: String, comment: String) {
    val userId = firebaseAuth.currentUser?.uid
    
    if (userId != null) {
        viewModelScope.launch {
            try {
                val commentsRef = firebaseDatabase.reference
                    .child("comments")
                    .child(userId)
                
                commentsRef.child(firebaseImageId).setValue(comment).await()

                val currentComment = _comments.value.toMutableMap()
                currentComment[firebaseImageId] = comment
                _comments.value = currentComment
            } catch (e: Exception) {
                _errorMessage.value = "Error al guardar el comentario: ${e.message}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}
Comments are stored separately under comments/{userId}/{firebaseImageId} to keep data normalized.

Image Item Component

ImageItem with Comment UI

@Composable
fun ImageItem(favoriteNasaModel: FavoriteNasaModel, favoritesViewModel: FavoritesViewModel) {
    var isVisible by remember { mutableStateOf(true) }
    val comments by favoritesViewModel.comments.collectAsState()
    val commentText = comments[favoriteNasaModel.firebaseImageId]
    var tempComment by rememberSaveable { mutableStateOf("") }

    AnimatedVisibility(
        visible = isVisible,
        enter = fadeIn(animationSpec = tween(durationMillis = 1500, easing = FastOutSlowInEasing)),
        exit = fadeOut(animationSpec = tween(durationMillis = 1500, easing = FastOutSlowInEasing))
    ) {
        Column(
            Modifier
                .fillMaxWidth()
                .padding(8.dp)
        ) {
            // Title and image
            
            // Comment UI
            if (!commentText.isNullOrEmpty()) {
                Text(
                    text = commentText,
                    style = MaterialTheme.typography.bodySmall,
                    textAlign = TextAlign.Justify,
                    modifier = Modifier.padding(top = 4.dp),
                    color = Color.Gray
                )
            } else {
                OutlinedTextField(
                    value = tempComment,
                    onValueChange = {
                        val maxComment = 100
                        if (it.length <= maxComment) {
                            tempComment = it
                        }
                    },
                    label = { Text(text = "Tu Comentario") },
                    modifier = Modifier.fillMaxWidth(),
                    singleLine = false,
                    maxLines = 3,
                    trailingIcon = {
                        if (tempComment.isNotEmpty()) {
                            IconButton(onClick = {
                                favoritesViewModel.addComment(
                                    favoriteNasaModel.firebaseImageId,
                                    tempComment.trim()
                                )
                                tempComment = ""
                            }) {
                                Icon(
                                    imageVector = Icons.AutoMirrored.Filled.Send,
                                    contentDescription = "Enviar comentario",
                                    tint = MaterialTheme.colorScheme.primary
                                )
                            }
                        }
                    }
                )
            }
            
            // Remove favorite button
        }
    }
}

Comment TextField Features

Comments are limited to 100 characters:
val maxComment = 100
if (it.length <= maxComment) {
    tempComment = it
}
TextField supports up to 3 lines:
singleLine = false,
maxLines = 3
Trailing icon appears when text is entered:
if (tempComment.isNotEmpty()) {
    IconButton(onClick = { /* Send comment */ }) {
        Icon(imageVector = Icons.AutoMirrored.Filled.Send)
    }
}

Remove Favorite

removeFromFavorites Function

fun removeFromFavorites(favoriteNasaModel: FavoriteNasaModel, onRemoved: () -> Unit) {
    val userId = firebaseAuth.currentUser?.uid
    
    if (userId != null) {
        viewModelScope.launch {
            try {
                val favoriteRef = firebaseDatabase.reference
                    .child("favorites")
                    .child(userId)
                
                val snapshot = favoriteRef
                    .orderByChild("id")
                    .equalTo(favoriteNasaModel.firebaseImageId)
                    .get()
                    .await()

                for (child in snapshot.children) {
                    child.ref.removeValue().await()
                }

                // Delete associated comment
                val commentRef = firebaseDatabase.reference
                    .child("comments")
                    .child(userId)
                
                commentRef.child(favoriteNasaModel.firebaseImageId).removeValue().await()

                onRemoved()
            } catch (e: Exception) {
                _errorMessage.value = "Error al eliminar favoritos ${e.message}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}

Remove Button with Animation

var isVisible by remember { mutableStateOf(true) }

IconButton(
    onClick = {
        isVisible = false  // Trigger fade-out animation
        
        favoritesViewModel.removeFromFavorites(favoriteNasaModel = favoriteNasaModel) {
            favoritesViewModel.loadFavoriteImages()
        }
    }
) {
    Icon(
        imageVector = Icons.Default.Favorite,
        contentDescription = "Favorites",
        tint = MaterialTheme.colorScheme.primary
    )
}
Smooth UX: Setting isVisible = false triggers the fade-out animation before removal.

Animations

AnimatedVisibility Implementation

AnimatedVisibility(
    visible = isVisible,
    enter = fadeIn(
        animationSpec = tween(
            durationMillis = 1500,
            easing = FastOutSlowInEasing
        )
    ),
    exit = fadeOut(
        animationSpec = tween(
            durationMillis = 1500,
            easing = FastOutSlowInEasing
        )
    )
) {
    // Image content
}

Animation Timing

  • Duration: 1500ms for both enter and exit
  • Easing: FastOutSlowInEasing for smooth transitions
  • Trigger: isVisible state change

Image Display

AsyncImage with Coil

AsyncImage(
    model = ImageRequest.Builder(LocalContext.current)
        .data(favoriteNasaModel.url)
        .crossfade(1000).build(),
    contentDescription = favoriteNasaModel.title,
    modifier = Modifier
        .fillMaxWidth()
        .padding(top = 24.dp)
        .clip(RoundedCornerShape(16.dp))
        .height(300.dp)
        .width(300.dp),
    contentScale = ContentScale.Crop,
    placeholder = painterResource(id = R.drawable.placeholder),
    error = painterResource(id = R.drawable.placeholder)
)

Firebase Database Structure

Data Organization

{
  "favorites": {
    "{userId}": {
      "{firebaseImageId}": {
        "id": "{firebaseImageId}",
        "title": "Image Title",
        "url": "https://..."
      }
    }
  },
  "comments": {
    "{userId}": {
      "{firebaseImageId}": "User's comment text"
    }
  }
}
Separating comments from favorites allows for independent querying and updates.

Dependencies

Firebase

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

Animation

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut

State Preservation

import androidx.compose.runtime.saveable.rememberSaveable

File Location

app/src/main/java/com/ccandeladev/nasaexplorer/ui/favoritesscreen/
├── FavoritesScreen.kt
└── FavoritesViewModel.kt
Best Practice: Comments are loaded after favorites to ensure all image IDs are available for mapping.

Build docs developers (and LLMs) love