Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fabd33a
Added hilt test modules
xavierpellvidal Apr 21, 2025
7bb66d3
Added hilt activity debug to tests
xavierpellvidal Apr 21, 2025
d77ccfd
Modified screenshot test to add end to end integration test
xavierpellvidal Apr 21, 2025
ff5253d
Removed test api singletons
xavierpellvidal Apr 21, 2025
1f09ff3
New approach to handle error events
xavierpellvidal Apr 21, 2025
2d52a91
Bumped app version
xavierpellvidal Apr 21, 2025
a82e024
Injected dispatchers into hilt data module
xavierpellvidal Apr 22, 2025
083543b
Moved main dispatcher rule
xavierpellvidal Apr 22, 2025
d190a5e
Added VM integration test
xavierpellvidal Apr 22, 2025
d6c05bd
Added error state to let retry loading users
xavierpellvidal Apr 22, 2025
6ad3abe
Refactored previews
xavierpellvidal Apr 22, 2025
791a57e
Merge pull request #12 from xavierpellvidal/feature/user-refactor-and…
xavierpellvidal Apr 22, 2025
731b48e
Merge branch 'develop' into feature/integration-tests
xavierpellvidal Apr 22, 2025
b2bf0ea
Changed one time events to Channel
xavierpellvidal Apr 22, 2025
b722d83
Fixed screenshot test
xavierpellvidal Apr 22, 2025
a97252b
Clean dependencies
xavierpellvidal Apr 22, 2025
3a15908
Merge pull request #11 from xavierpellvidal/feature/integration-tests
xavierpellvidal Apr 22, 2025
cf0e0b7
Improved tests
xavierpellvidal Apr 22, 2025
4b9b89c
Changed README.md
xavierpellvidal Apr 22, 2025
3c8c1c6
Bump project version
xavierpellvidal Apr 22, 2025
48eae12
First action version
xavierpellvidal Apr 22, 2025
ad394c7
Added cache to action
xavierpellvidal Apr 22, 2025
1c42d3c
Test screenshot tests in CI
xavierpellvidal Apr 22, 2025
073e924
Final workflow version
xavierpellvidal Apr 22, 2025
6cf9124
Copilot typo comment
xavierpellvidal Apr 22, 2025
21258bd
Merge pull request #13 from xavierpellvidal/feature/final-version
xavierpellvidal Apr 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/check-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Check tests

on:
workflow_dispatch:
push:
branches:
- develop
- main
pull_request:

jobs:
build:
name: 📲 Build Android Project
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive

- name: Setup build cache
uses: actions/cache@v4
with:
key: $gradle-build-cache
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.gradle/gradle-build-cache

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Run tests
id: run_tests
run: ./gradlew test
continue-on-error: false
68 changes: 40 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,62 +1,75 @@

## 📱 Overview

An Android application that displays a list of random users fetched from an external API. Features a modern and intuitive UI built with Jetpack Compose, allowing users to filter, delete, and manage profiles.
An Android application that displays a list of random users fetched from an external API. Features a modern and intuitive UI built with Jetpack Compose, allowing users to filter and delete profiles.


## ✨ Features

- **User List**: Browse through random user profiles with detailed information
- **Filtering**: Filter users by various criteria
- **Local Storage**: Access previously loaded users while offline
- **Reactive UI**: Real-time updates when data changes
- **User list**: Browse through random user profiles with detailed information. Infinite scroll implemented
- **User details**: View detailed information about each user
- **Filtering**: Filter users by name, surname and email
- **Delete**: Remove users from the list


## 🛠️ Tech Stack

| Category | Technologies |
|----------|--------------|
| **Core** | Kotlin, Coroutines, Flow |
| **UI** | Jetpack Compose |
| **Architecture** | Clean Architecture, MVI Pattern |
| **Dependency Injection** | Hilt |
| **Networking** | Retrofit |
| **Local Storage** | Room |
| **Functional Programming** | Arrow |
| **Testing** | Turbine, Mockk, Roborazzi, Robolectric |
| **Planned** | MockWebServer |
| Category | Technologies |
|----------|-------------------------------------------------------|
| **Core** | Kotlin, Coroutines, Flow |
| **UI** | Jetpack Compose |
| **Architecture** | Clean Architecture, MVI Pattern |
| **Dependency Injection** | Hilt |
| **Networking** | Retrofit |
| **Local Storage** | Room, SharedPreferences |
| **Functional Programming** | Arrow |
| **Testing** | Turbine, Mockk, Roborazzi, Robolectric, MockWebServer |


## 🏗️ Architecture

The project follows **Clean Architecture** principles with an MVI (Model-View-Intent) pattern:

- **Presentation Layer**: Compose UI components and ViewModels
- **Domain Layer**: Business logic encapsulated in UseCases
- **Presentation Layer**: Compose UI components and ViewModels modularized by feature
- **Domain Layer**: Business logic encapsulated in UseCases and repositories
- **Data Layer**: Repository implementations and data sources
- **Core Module**: Shared utilities and reusable components
- **Core Module**: Shared utilities and reusable components like api, database and preferences

```
app/
├── core/ # Core utilities and extensions
├── data/ # Data layer implementations
├── domain/ # Business logic and use cases
└── presentation/ # UI components and ViewModels
├── core/ # Core modules
├── data/ # Data layer
├── domain/ # Domain layer
└── presentation/ # Feature modules
└── users/ # User list feature
```


## 🧪 Testing

- **Unit Tests**: Cover ViewModels, data and parts of the core layers
- **Unit Tests**: Cover ViewModels, usecases and data layer
- **UI Tests**: Screenshot testing with Roborazzi implemented
- **Integration Tests**: Planned for UseCase to DataSource layers using MockWebServer and Room
- **Integration Tests**: VM integration tests with mockwebserver


## 🚀 Future Improvements

- Add integration tests for UseCase to DataSource layers using MockWebServer
- Manage strings in core-presentation module
- Add ui-components to core-presentation module or to a new ds module
- Expand test coverage across the application
- Integration tests from roborazzi to data layer
- Compose navigation tests
- Kover for coverage reports
- Konsists for code quality
- Ktlint for code style
- Add screenshot results in PR to see the differences


## 🔧 Getting Started

1. Clone the repository
```bash
git clone https://github.com/yourusername/random-users.git
git clone https://github.com/xavierpellvidal/random-users.git
```
2. Open the project in Android Studio
3. Sync the project with Gradle
Expand All @@ -66,4 +79,3 @@ app/
## 📝 License

This project is licensed under the MIT License - see the LICENSE file for details
```
4 changes: 2 additions & 2 deletions buildSrc/src/main/kotlin/AppVersions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ object AppVersions {
const val APPLICATION_ID = "com.random.user"
const val COMPILE_SDK = 35
const val MIN_SDK = 26
const val APP_VERSION_CODE = 2
const val APP_VERSION_NAME = "1.1.0"
const val APP_VERSION_CODE = 3
const val APP_VERSION_NAME = "1.2.0"
const val JVM_TARGET = "17"
val javaVersion = JavaVersion.VERSION_17
}
4 changes: 2 additions & 2 deletions core/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ dependencies {
implementation(libs.retrofit.gson)
implementation(libs.okhttp)
implementation(libs.okhttp.logging.interceptor)
implementation(libs.hilt.testing)
implementation(libs.mockwebserver)

ksp(libs.com.google.dagger.hilt.compiler)

testImplementation(libs.bundles.test.unit)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface UsersApi {
): Either<CallError, RandomUserResponse>

companion object {
const val TIMEOUT_SECONDS = 30L
const val BASE_URL = "https://api.randomuser.me/"
}
}
5 changes: 5 additions & 0 deletions core/api/src/main/kotlin/com/random/users/api/di/ApiModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.random.users.api.di

import arrow.retrofit.adapter.either.EitherCallAdapterFactory
import com.random.users.api.api.UsersApi
import com.random.users.api.api.UsersApi.Companion.TIMEOUT_SECONDS
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
Expand All @@ -11,6 +12,7 @@ import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.logging.HttpLoggingInterceptor.Level
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton

@Module
Expand Down Expand Up @@ -38,6 +40,9 @@ object ApiModule {
return OkHttpClient
.Builder()
.addInterceptor(logging)
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
}
56 changes: 56 additions & 0 deletions core/api/src/main/kotlin/com/random/users/api/di/TestApiModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.random.users.api.di

import arrow.retrofit.adapter.either.EitherCallAdapterFactory
import com.random.users.api.api.UsersApi
import dagger.Module
import dagger.Provides
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.mockwebserver.MockWebServer
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import javax.inject.Singleton

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [ApiModule::class],
)
object TestApiModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.setLevel(HttpLoggingInterceptor.Level.BODY)
return OkHttpClient
.Builder()
.addInterceptor(logging)
.build()
}

@Provides
@Singleton
fun provideUsersApi(retrofit: Retrofit): UsersApi = retrofit.create(UsersApi::class.java)

@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient,
mockWebServer: MockWebServer,
): Retrofit {
mockWebServer.start()
return Retrofit
.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(EitherCallAdapterFactory.create())
.client(okHttpClient)
.build()
}

@Provides
@Singleton
fun provideMockWebServer(): MockWebServer = MockWebServer()
}
4 changes: 2 additions & 2 deletions core/database/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ android {
dependencies {
implementation(libs.bundles.layer.data)
implementation(libs.room.ktx)
implementation(libs.room.testing)
implementation(libs.hilt.testing)

ksp(libs.com.google.dagger.hilt.compiler)
ksp(libs.room.compiler)

testImplementation(libs.bundles.test.unit)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.random.users.database.di

import android.content.Context
import androidx.room.Room
import com.random.users.database.RandomUsersDatabase
import com.random.users.database.dao.UserDao
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DatabaseModule::class],
)
object TestDatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext appContext: Context,
) = Room
.inMemoryDatabaseBuilder(appContext, RandomUsersDatabase::class.java)
.allowMainThreadQueries()
.build()

@Provides
@Singleton
fun provideUserDao(db: RandomUsersDatabase): UserDao = db.userDao()
}
3 changes: 1 addition & 2 deletions core/preferences/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ android {

dependencies {
implementation(libs.bundles.layer.data)
implementation(libs.hilt.testing)

ksp(libs.com.google.dagger.hilt.compiler)

testImplementation(libs.bundles.test.unit)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.random.users.preferences.di

import android.content.Context
import android.content.SharedPreferences
import com.random.users.preferences.manager.PreferencesManager
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton

@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [PreferencesModule::class],
)
object TestPreferencesModule {
private const val TEST_PREFERENCES_NAME = "test_preferences"

@Provides
@Singleton
fun provideFakeSharedPreferences(
@ApplicationContext context: Context,
): SharedPreferences = context.getSharedPreferences(TEST_PREFERENCES_NAME, Context.MODE_PRIVATE)

@Provides
@Singleton
fun providePreferencesManager(sharedPreferences: SharedPreferences): PreferencesManager =
PreferencesManager(sharedPreferences)
}
3 changes: 0 additions & 3 deletions core/presentation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,4 @@ dependencies {
implementation(libs.androidx.activity.compose)

ksp(libs.com.google.dagger.hilt.compiler)

testImplementation(libs.bundles.test.unit)
testImplementation(libs.bundles.test.compose)
}
6 changes: 6 additions & 0 deletions core/test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,17 @@ android {
kotlinOptions {
jvmTarget = AppVersions.JVM_TARGET
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}

dependencies {
implementation(libs.bundles.layer.data)
ksp(libs.com.google.dagger.hilt.compiler)
implementation(libs.bundles.test.compose)

testImplementation(libs.bundles.test.unit)
}
Loading