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
State Type Purpose 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.
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 } "
}
}
}
}
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
@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
}
}
}
Comments are limited to 100 characters: val maxComment = 100
if (it.length <= maxComment) {
tempComment = it
}
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"
}
}
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.