HomeworkTest is an Android application designed to browse and search for photos using the Unsplash API. It serves as a demonstration of modern Android development practices, emphasizing a clean, scalable architecture and a reactive UI built with Jetpack Compose.
- Photo Browsing: Displays an infinitely scrollable list of photos fetched from the Unsplash API.
- Photo Search: Allows users to search for photos based on keywords.
- Dynamic UI Updates: Utilizes Kotlin StateFlow and Jetpack Compose state management for a reactive UI that updates efficiently as data changes.
- Pagination: Implements pagination to load photos incrementally, optimizing performance and data usage.
- Loading & Empty States: Provides visual feedback to the user during data fetching operations and when no results are found.
- Basic Error Handling: Includes mechanisms to catch and log network errors.
The application adheres to a layered architecture inspired by Clean Architecture principles. This approach emphasizes separation of concerns, making the codebase more modular, testable, scalable, and easier to maintain. The primary pattern used for the presentation layer is MVVM (Model-View-ViewModel). This uni-directional data flow (UI -> ViewModel -> UseCase -> Repository -> DataSource, and data flowing back) is central to the design.
This layer is responsible for everything related to the user interface and user interaction. It is kept unaware of the underlying data sources and complex business logic.
-
UI (Composable Screens & Components):
-
Located in
it.datalux.homeworktest.presentation.screen.*(e.g.,PhotoListScreen.kt) andit.datalux.homeworktest.presentation.components.*. -
Built entirely with Jetpack Compose, providing a declarative and reactive way to define UI elements.
-
Screens observe state exposed by ViewModels (typically
StateFlowor ComposeState) and render the UI accordingly. -
User interactions (e.g., button clicks, search input) are captured by Composables and trigger functions in the ViewModel.
-
Navigation: Handled by Navigation Compose, allowing for navigation between different Composable destinations within a single
MainActivity. -
ViewModels (
PhotosViewModel.kt): -
Located in
it.datalux.homeworktest.presentation.screen.photosList.*. -
Follow the MVVM pattern, acting as state holders and intermediaries between the UI and the Domain layer (UseCases).
-
They prepare data fetched from UseCases into a UI-consumable format.
-
Expose UI state (e.g., list of photos, loading status, error messages) using
kotlinx.coroutines.flow.StateFloworandroidx.compose.runtime.mutableStateListOf. -
Contain UI-related logic (e.g., managing search mode, handling back button logic for search).
-
Injected with necessary UseCases via Hilt (
@HiltViewModel). -
Coroutine operations are scoped to
viewModelScopefor proper lifecycle management.
This is the core of the application, containing the business logic and rules. It is independent of both the Presentation and Data layers, meaning it has no knowledge of Android Framework specifics (like Context or UI elements) or how data is stored/fetched.
-
UseCases (
PhotosUseCase.kt): -
Located in
it.datalux.homeworktest.domain.usecase.*. -
Encapsulate specific pieces of business logic or individual user actions (e.g., "get a list of photos," "search for photos").
-
They are simple classes, often with a single public
invokeorexecutefunction. -
Orchestrate data flow by interacting with Repository interfaces. For instance,
PhotosUseCasewill call methods defined in thePhotosRepositoryinterface. -
This abstraction allows business logic to be tested independently and makes it reusable.
-
Domain Models/Entities (
Photo.ktin the domain layer): -
Located in
it.datalux.homeworktest.domain.entity.*. -
Represent the fundamental data structures and business objects of the application (e.g., a
Photoentity containing only the fields relevant to the application's business rules and UI display needs). -
These are plain Kotlin data classes, free from any platform-specific or data source-specific annotations or dependencies.
-
They define the "what" of the data, not the "how" it's obtained or presented.
This layer is responsible for providing data to the application, abstracting away the actual source of the data (network, local database, cache, etc.).
-
Repositories (
PhotosRepository.kt- interface,PhotosRepositoryImpl.kt- implementation): -
The interface (
PhotosRepository.kt) is defined in the Domain Layer (it.datalux.homeworktest.domain.*) following the Dependency Inversion Principle. This allows the Domain layer to define its data requirements without depending on the concrete implementation in the Data layer. -
The implementation (
PhotosRepositoryImpl.kt) resides in the Data Layer (it.datalux.homeworktest.data.photos.repository.*). -
PhotosRepositoryImplis responsible for deciding where to fetch data from (currently, only the network). If caching were implemented, the repository would manage retrieving from cache first, then network. -
It implements the methods defined in the
PhotosRepositoryinterface (e.g.,getPhotosList,search). -
Manages pagination logic (e.g., using the
Paginatorutility) when interacting with the Unsplash API. -
It interacts with data sources (like
PhotosApi) and maps data from DTOs to Domain Entities. -
PhotosMockRepositoryImpl.ktprovides a mock implementation for testing and UI previews. -
Remote Data Sources:
-
Retrofit Service (
PhotosApi.kt): Located init.datalux.homeworktest.data.photos.remote.api.*. * An interface defining the HTTP API endpoints for the Unsplash service using Retrofit annotations (@GET,@Query, etc.). -
Data Transfer Objects (DTOs): Located in
it.datalux.homeworktest.data.photos.remote.dto.*(e.g.,Photo.kt,User.ktin thedtopackage). * These are Kotlin data classes that precisely model the structure of the JSON responses from the Unsplash API. * They are annotated withkotlinx.serialization.Serializable(or Gson's@SerializedNameif Gson is the primary parser) for automatic JSON parsing. * DTOs are specific to the remote data source and can be more detailed or differently structured than Domain Models. -
Network Client (OkHttp & Retrofit instance): Configured in
NetworkModule.kt(using Hilt) to provide instances of Retrofit and OkHttp, including features like logging interceptors. -
Mappers (e.g.,
toPhotoEntity()extension function): -
Located usually within the data layer (e.g.,
it.datalux.homeworktest.data.common.mapper.*). -
Responsible for converting data between different layers, specifically from Data Layer DTOs to Domain Layer Entities (e.g.,
data.dto.Phototodomain.entity.Photo) and vice-versa if needed. This decoupling ensures that changes in the API response structure don't directly impact the Domain or Presentation layers, as long as the mapping logic is updated. -
Local Data Sources (Potential Future Implementation):
-
This would involve components like a Room Database for caching data locally.
-
The Repository would then be extended to first check the local database for data before making a network request.
- Hilt is used for compile-time dependency injection, simplifying the process of providing and managing dependencies throughout the application.
@HiltViewModelannotates ViewModels.@Moduleand@InstallInare used to define Hilt modules (e.g.,AppModule.kt,NetworkModule.kt) that instruct Hilt on how to provide instances of interfaces (likePhotosRepository), Retrofit services, or other classes.@Injectis used in constructors to request dependencies.
- Testability: Each layer can be tested independently. ViewModels can be unit tested by mocking UseCases, UseCases by mocking Repositories, and Repositories by mocking data sources.
- Maintainability & Scalability: Clear separation of concerns makes it easier to understand, modify, and add new features without unintended side effects.
- Flexibility: The Data layer can be swapped out (e.g., from network-only to network-with-cache) with minimal impact on the Domain or Presentation layers, as long as the Repository interface contract is maintained.
- Reusability: Domain layer logic (UseCases, Entities) can be potentially reused across different presentation layers (e.g., mobile, web, if the core logic is shared).
Unit testing is crucial for ensuring the correctness and reliability of individual components in isolation. Given the layered architecture, each layer can be unit tested by mocking its dependencies. This project includes unit tests for the PhotosViewModel.
The primary example of ViewModel testing in this project can be found in:
app/src/test/java/it/datalux/homeworktest/PhotosViewModelUnitTest.kt.
-
Goal: Verify that the
PhotosViewModelcorrectly processes inputs, manages its state (e.g.,photosList,loading), and interacts with its dependencies (e.g.,PhotosUseCase). -
Approach in
PhotosViewModelUnitTest.kt: -
Test Doubles: Instead of using a mocking library like Mockito, this test suite utilizes a real
PhotosUseCaseinstance which is, in turn, initialized withPhotosMockRepositoryImpl. This approach tests the ViewModel's integration with a controlled, predictable version of its direct dependency and the repository. This is a valid strategy, especially for simpler interactions or when the mock repository accurately simulates different scenarios. -
Coroutines Testing: Uses
kotlinx.coroutines.test.runTestto execute suspend functions anddelayto allow asynchronous operations to complete for assertion. -
State Verification: Directly accesses and asserts the state of
photosViewModel.photosListand other relevant properties. -
Key Aspects Demonstrated in
PhotosViewModelUnitTest.kt: -
Setup (
@Before): Initializes thePhotosMockRepositoryImpl,PhotosUseCase, andPhotosViewModelbefore each test.
Add Your Unsplash API Key to the Project
The application requires an API key from Unsplash to fetch photos. This key is configured as a BuildConfig field, which is defined in the module-level Gradle file.
- Open
app/build.gradle.kts:- In Android Studio, locate and open the file:
HomeworkTest/app/build.gradle.kts
- In Android Studio, locate and open the file:
- Locate
buildConfigField:- Inside the
android { ... }block, find thedefaultConfig { ... }section. - You will see the following line:
buildConfigField("String", "API_KEY", "\"<YOUR-API-KEY-HERE>\"") - Add your API key here
- Inside the