Skip to main content
NASA Explorer uses the MVVM (Model-View-ViewModel) architecture pattern with Android Jetpack’s ViewModel component. All ViewModels are annotated with @HiltViewModel for dependency injection and follow reactive state management patterns using Kotlin Flow.

Overview

The app contains 8 ViewModels that handle state management and business logic:
  • SplashViewModel - Authentication state and navigation routing
  • LoginScreenViewModel - User authentication (login)
  • SignUpViewModel - User registration
  • HomeScreenViewModel - Home screen operations and logout
  • DailyImageViewModel - NASA’s Astronomy Picture of the Day
  • RandomImageViewModel - Random NASA images collection
  • RangeImagesViewModel - Date range image queries
  • FavoritesViewModel - Favorite images management with comments
All ViewModels use Kotlin Coroutines with viewModelScope for asynchronous operations and StateFlow for reactive state management.

SplashViewModel

Manages the splash screen logic by checking user authentication status and determining the initial navigation destination.

Dependencies

@HiltViewModel
class SplashViewModel @Inject constructor(
    private val authService: AuthService
) : ViewModel()
fun checkDestination(): ResolveSplashDestination {
    return if (isUserAuthenticated()) {
        ResolveSplashDestination.Home
    } else {
        ResolveSplashDestination.Login
    }
}

private fun isUserAuthenticated(): Boolean {
    return authService.isUserLogged()
}

Sealed Class for Navigation

sealed class ResolveSplashDestination {
    data object Login: ResolveSplashDestination()
    data object Home: ResolveSplashDestination()
}

Use Case

The SplashViewModel determines whether to show the login screen or home screen based on authentication state when the app launches.

LoginScreenViewModel

Handles user login with email/password authentication, including validation and error handling.

State Management

@HiltViewModel
class LoginScreenViewModel @Inject constructor(
    private val authService: AuthService
) : ViewModel() {

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

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

Login Function

fun login(email: String, password: String, onNavigateToHome: () -> Unit) {
    // Validation
    if (email.isBlank() || password.isBlank()) {
        _errorMessage.value = "Los campos no pueden estar vacios"
        return
    }
    if (!isValidEmail(email)) {
        _errorMessage.value = "El correo no es válido"
        return
    }
    if (password.length < 6) {
        _errorMessage.value = "La contraseña debe tener al menos 6 caracteres"
        return
    }

    viewModelScope.launch {
        _isLoading.value = true
        try {
            val result = withContext(Dispatchers.IO) {
                authService.login(email = email, password = password)
            }
            if (result != null) {
                onNavigateToHome()
            } else {
                _errorMessage.value = "Error de autenticación.Por favor, revisa tus credenciales."
            }
        } catch (e: Exception) {
            _errorMessage.value = "Correo o contraseña no válidos."
        } finally {
            _isLoading.value = false
        }
    }
}

Email Validation

private fun isValidEmail(email: String): Boolean {
    return android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
}

fun resetErrorMessage() {
    _errorMessage.value = ""
}
  • Email and password cannot be blank
  • Email must match valid email pattern
  • Password must be at least 6 characters
  • “Los campos no pueden estar vacios” - Empty fields
  • “El correo no es válido” - Invalid email format
  • “La contraseña debe tener al menos 6 caracteres” - Short password
  • “Correo o contraseña no válidos.” - Authentication failure

SignUpViewModel

Manages user registration with Firebase Authentication, including validation and duplicate email handling.

State Management

@HiltViewModel
class SignUpViewModel @Inject constructor(
    private val authService: AuthService
) : ViewModel() {

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

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

Registration Function

fun register(email: String, password: String, onNavigateToHome: () -> Unit) {
    // Validation
    if (email.isBlank() || password.isBlank()) {
        _errorMessage.value = "Los campos no pueden estar vacios"
        return
    }
    if (!isValidEmail(email = email)) {
        _errorMessage.value = "El correo no es válido"
        return
    }
    if (password.length < 6) {
        _errorMessage.value = "La contraseña debe tener al menos 6 caracteres"
        return
    }

    viewModelScope.launch {
        _isLoading.value = true
        try {
            val result = withContext(Dispatchers.IO) {
                authService.register(email = email, password = password)
            }
            if (result != null) {
                onNavigateToHome()
            } else {
                _errorMessage.value = "Error de autenticación.Revisa tus credenciales."
            }
        } catch (e: FirebaseAuthUserCollisionException) {
            _errorMessage.value = "El correo ya está en uso."
        } catch (e: Exception) {
            _errorMessage.value = "Se produjo un error durante el registro"
        } finally {
            _isLoading.value = false
        }
    }
}
The SignUpViewModel specifically handles FirebaseAuthUserCollisionException to detect when a user tries to register with an email that’s already in use.

HomeScreenViewModel

Simplest ViewModel that handles user logout operations.

Implementation

@HiltViewModel
class HomeScreenViewModel @Inject constructor(
    private val authService: AuthService
) : ViewModel() {

    fun logOut() {
        viewModelScope.launch(Dispatchers.IO) {
            authService.userLogout()
        }
    }
}
The logout operation runs on Dispatchers.IO to ensure it doesn’t block the main thread.

DailyImageViewModel

Manages NASA’s Astronomy Picture of the Day (APOD) with favorites functionality.

Dependencies

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

State Management

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

Loading Daily Image

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
        }
    }
}

Favorites Management

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}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}

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}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}

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}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}

Data Model

Uses NasaModel with title, url, date, and explanation fields

Firebase Structure

Stores favorites under /favorites/{userId}/{pushId}

RandomImageViewModel

Loads random NASA images (default 5 images) with favorites management.

Dependencies

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

State Management

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

Loading Random Images

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
        }
    }
}

Checking Favorites Status

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}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}
The _favoriteStates map uses image URLs as keys and boolean values to track which images are favorited. This allows the UI to display the correct favorite icon state for multiple images simultaneously.

Favorites Operations

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}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}

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}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}

RangeImagesViewModel

Queries NASA images within a specific date range with favorites management.

Dependencies

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

State Management

private val _dateRangeImages = MutableStateFlow<List<NasaModel>>(emptyList())
val dateRangeImages: StateFlow<List<NasaModel>> = _dateRangeImages

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 favoriteStates: StateFlow<Map<String, Boolean>> = _favoriteStates

Loading Date Range Images

fun loadDateRangeImages(startDate: String, endDate: String) {
    viewModelScope.launch {
        _isLoading.value = true
        try {
            val results = 
                nasaRepository.getImagesInRange(startDate = startDate, endDate = endDate)
            _dateRangeImages.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"
            _dateRangeImages.value = emptyList()
        } finally {
            _isLoading.value = false
        }
    }
}
The RangeImagesViewModel follows the same favorites management pattern as RandomImageViewModel, tracking favorite states for multiple images in a map.

FavoritesViewModel

Manages the user’s favorite images collection with comment functionality.

Dependencies

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

State Management

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
This ViewModel uses FavoriteNasaModel which includes the firebaseImageId (Firebase-generated unique ID) for comment association.

Loading Favorites

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"
    }
}

Comments Management

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}"
            }
        }
    } else {
        _errorMessage.value = "Usuario no autenticado"
    }
}

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"
    }
}

Removing Favorites

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()
                }
                
                // Remove 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"
    }
}

Firebase Structure: Favorites

/favorites/{userId}/{imageId} containing id, title, url

Firebase Structure: Comments

/comments/{userId}/{imageId} containing comment text
When removing a favorite, both the favorite entry and its associated comment are deleted from Firebase.

Data Models

NasaModel

Primary model for NASA image data used across most ViewModels.
data class NasaModel(
    val title: String,
    val url: String,
    val date: String,
    val explanation: String
)

FavoriteNasaModel

Extended model used specifically in FavoritesViewModel to include Firebase-generated IDs.
data class FavoriteNasaModel(
    val firebaseImageId: String,  // Unique ID generated by Firebase
    val title: String,
    val url: String
)
The firebaseImageId field is crucial for linking favorites with their comments in the database.

Common Patterns

State Flow Pattern

All ViewModels follow this pattern for state management:
private val _state = MutableStateFlow<Type>(initialValue)
val state: StateFlow<Type> = _state
This provides:
  • Immutable public state for UI observation
  • Mutable private state for ViewModel updates
  • Reactive updates that automatically propagate to UI

Error Handling

Consistent error handling across all ViewModels:
try {
    // Operation
    _errorMessage.value = null  // Clear on success
} catch (e: Exception) {
    _errorMessage.value = "User-friendly error message"
} finally {
    _isLoading.value = false
}

Loading States

All ViewModels that perform async operations include:
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading

Coroutine Usage

ViewModels use viewModelScope for automatic cancellation:
viewModelScope.launch {
    // Automatically cancelled when ViewModel is cleared
}
For blocking operations, use Dispatchers.IO:
viewModelScope.launch(Dispatchers.IO) {
    // Database or network operations
}
All ViewModels use Hilt for dependency injection with @HiltViewModel annotation and @Inject constructor.
Five ViewModels integrate with Firebase for authentication and real-time database operations using FirebaseAuth and FirebaseDatabase.
Three ViewModels (DailyImage, RandomImage, RangeImages) use NasaRepository to abstract NASA API calls.

Best Practices Demonstrated

Separation of Concerns

ViewModels contain only business logic, no UI code

Reactive State

StateFlow provides reactive, lifecycle-aware state updates

Error Handling

Comprehensive try-catch blocks with user-friendly messages

Loading States

Loading indicators for all async operations

Input Validation

Client-side validation before network calls

Coroutine Safety

Proper use of viewModelScope and Dispatchers

Architecture

Learn about the overall app architecture

Data Layer

Explore repositories and data sources

Firebase Integration

Firebase setup and usage patterns

MVVM Pattern

Deep dive into MVVM and state management

Build docs developers (and LLMs) love