diff --git a/.gitignore b/.gitignore
index aa724b7..10cfdbf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,7 @@
*.iml
.gradle
/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
+/.idea
.DS_Store
/build
/captures
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 26d3352..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index b589d56..0000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index a9f4e52..0000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index e1eea1d..0000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index 0ad17cb..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 94a25f7..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/benchmark-rules.pro b/app/benchmark-rules.pro
new file mode 100644
index 0000000..0674e77
--- /dev/null
+++ b/app/benchmark-rules.pro
@@ -0,0 +1 @@
+-dontobfuscate
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 7586834..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,67 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'org.jetbrains.kotlin.android'
-}
-
-android {
- namespace 'com.example.composecodechallenge'
- compileSdk 33
-
- defaultConfig {
- applicationId "com.example.composecodechallenge"
- minSdk 24
- targetSdk 33
- versionCode 1
- versionName "1.0"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables {
- useSupportLibrary true
- }
- }
-
- buildTypes {
- release {
- minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = '1.8'
- }
- buildFeatures {
- compose true
- }
- composeOptions {
- kotlinCompilerExtensionVersion '1.3.2'
- }
- packagingOptions {
- resources {
- excludes += '/META-INF/{AL2.0,LGPL2.1}'
- }
- }
-}
-
-dependencies {
-
- implementation 'androidx.core:core-ktx:1.8.0'
- implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0')
- implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
- implementation 'androidx.activity:activity-compose:1.5.1'
- implementation platform('androidx.compose:compose-bom:2022.10.00')
- implementation 'androidx.compose.ui:ui'
- implementation 'androidx.compose.ui:ui-graphics'
- implementation 'androidx.compose.ui:ui-tooling-preview'
- implementation 'androidx.compose.material3:material3'
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
- androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00')
- androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
- debugImplementation 'androidx.compose.ui:ui-tooling'
- debugImplementation 'androidx.compose.ui:ui-test-manifest'
-}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..1cabc9a
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,108 @@
+import com.example.buildsrc.Android
+import com.example.buildsrc.Libs
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("kotlin-kapt")
+ id ("dagger.hilt.android.plugin")
+}
+
+android {
+ namespace = "com.example.composecodechallenge"
+ compileSdk = Android.compileSdkVersion
+
+ defaultConfig {
+ applicationId = Android.applicationId
+ minSdk = Android.minSdkVersion
+ targetSdk = Android.targetSdkVersion
+ versionCode = Android.versionCode
+ versionName = Android.versionName
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ buildConfigField("String", "BASE_URL", "\"https://api.github.com\"")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ create("benchmark") {
+ signingConfig = signingConfigs.getByName("debug")
+ matchingFallbacks += listOf("release")
+ isDebuggable = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.4.6"
+ }
+ packaging {
+ resources {
+ excludes.add("/META-INF/{AL2.0,LGPL2.1}")
+ }
+ }
+}
+
+dependencies {
+ implementation(project(Libs.Modules.data))
+ implementation(project(Libs.Modules.domain))
+ //---------------------------------------------------------------------------------------------
+ implementation(Libs.Jetpack.androidxCore)
+ implementation (platform(Libs.Common.kotlinBom))
+ implementation(Libs.Jetpack.lifecycleRuntime)
+ implementation(Libs.Jetpack.activityCompose)
+ implementation (platform(Libs.Jetpack.composeBom))
+ implementation(Libs.Jetpack.composeUi)
+ implementation(Libs.Jetpack.composeUiGraphics)
+ implementation(Libs.Jetpack.composeUiToolingPreview)
+ implementation(Libs.Jetpack.composeMaterial3)
+ implementation(Libs.Jetpack.composeMaterial3WindowSzeClass)
+ implementation(Libs.Jetpack.lifecycleRuntimeCompose)
+ implementation(Libs.Jetpack.navigationCompose)
+ implementation(Libs.Jetpack.trace)
+
+ testImplementation(Libs.Testing.junit)
+ testImplementation(Libs.Testing.mockitoKotlin)
+ testImplementation(Libs.Testing.coroutinesTest)
+
+ androidTestImplementation(Libs.Testing.junitEx)
+ androidTestImplementation(Libs.Testing.espresso)
+ androidTestImplementation (platform(Libs.Jetpack.composeBom))
+ androidTestImplementation(Libs.Testing.composeUiTestJunit4)
+ debugImplementation(Libs.Jetpack.composeTooling)
+ debugImplementation(Libs.Testing.composeUiTestManifest)
+ implementation (Libs.Jetpack.workRuntime)
+ //----------------------------------------------------------------------------------------------
+ implementation(Libs.Jetpack.hiltAndroid)
+ kapt(Libs.Jetpack.hiltAndroidCompiler)
+ implementation(Libs.Jetpack.hiltWork)
+ kapt(Libs.Jetpack.hiltCompiler)
+ implementation(Libs.Jetpack.hiltNavCompose)
+ //----------------------------------------------------------------------------------------------
+ implementation(Libs.Common.stetho)
+ implementation(Libs.Common.stetho_OkHttp)
+ implementation(Libs.Common.okHttpInterceptor)
+ //----------------------------------------------------------------------------------------------
+ implementation(Libs.Common.retrofit)
+ implementation(Libs.Common.retrofitGson)
+ //----------------------------------------------------------------------------------------------
+ implementation(Libs.Common.arrowCore)
+ //----------------------------------------------------------------------------------------------
+ implementation(Libs.Common.coil)
+ //----------------------------------------------------------------------------------------------
+ implementation(Libs.Common.material)
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb43..ff59496 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
+# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0c9a0fa..1725c93 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,10 @@
+
+
+
+
diff --git a/app/src/main/java/com/example/composecodechallenge/MainActivity.kt b/app/src/main/java/com/example/composecodechallenge/MainActivity.kt
deleted file mode 100644
index bfde89d..0000000
--- a/app/src/main/java/com/example/composecodechallenge/MainActivity.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.example.composecodechallenge
-
-import android.os.Bundle
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import com.example.composecodechallenge.ui.theme.ComposeCodeChallengeTheme
-
-class MainActivity : ComponentActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContent {
- ComposeCodeChallengeTheme {
-
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/app/App.kt b/app/src/main/java/com/example/composecodechallenge/app/App.kt
new file mode 100644
index 0000000..83afb84
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/app/App.kt
@@ -0,0 +1,7 @@
+package com.example.composecodechallenge.app
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class App : Application()
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/di/NetworkModule.kt b/app/src/main/java/com/example/composecodechallenge/di/NetworkModule.kt
new file mode 100644
index 0000000..d2810b0
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/di/NetworkModule.kt
@@ -0,0 +1,77 @@
+package com.example.composecodechallenge.di
+
+import com.example.composecodechallenge.BuildConfig
+import com.example.data.di.Cloud
+import com.example.data.di.Mock
+import com.example.data.restful.API
+import com.example.data.source.cloud.BaseCloudRepository
+import com.example.data.source.cloud.CloudMockRepository
+import com.example.data.source.cloud.CloudRepository
+import com.facebook.stetho.okhttp3.StethoInterceptor
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Reusable
+ @Provides
+ fun provideGson(): Gson {
+ return GsonBuilder().create()
+ }
+
+ @Reusable
+ @Provides
+ fun provideOkHttpClient(): OkHttpClient {
+ val builder = OkHttpClient.Builder()
+ if (BuildConfig.DEBUG) {
+ val loggingInterceptor = HttpLoggingInterceptor()
+ loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
+ builder.addInterceptor(loggingInterceptor)
+ builder.addNetworkInterceptor(StethoInterceptor())
+ }
+ builder.connectTimeout(20L, TimeUnit.SECONDS)
+ builder.readTimeout(20L, TimeUnit.SECONDS)
+ builder.writeTimeout(20L, TimeUnit.SECONDS)
+ return builder.build()
+ }
+
+ @Reusable
+ @Provides
+ fun provideRetrofit(okHttpClient: OkHttpClient, gson: Gson): Retrofit {
+ return Retrofit.Builder().client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .baseUrl(BuildConfig.BASE_URL)
+ .build()
+ }
+
+ @Reusable
+ @Provides
+ fun provideService(retrofit: Retrofit): API {
+ return retrofit.create(API::class.java)
+ }
+
+ @Cloud
+ @Provides
+ fun provideCloudRepository(apIs: API): BaseCloudRepository {
+ return CloudRepository(apIs)
+ }
+
+ @Mock
+ @Provides
+ fun provideCloudMockRepository(): BaseCloudRepository {
+ return CloudMockRepository()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/di/RepoBuilder.kt b/app/src/main/java/com/example/composecodechallenge/di/RepoBuilder.kt
new file mode 100644
index 0000000..515c0e0
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/di/RepoBuilder.kt
@@ -0,0 +1,22 @@
+package com.example.composecodechallenge.di
+
+import com.example.data.repository.GetUserDetailRepoImpl
+import com.example.data.repository.GetUsersRepoImpl
+import com.example.domain.repository.GetUserDetailRepository
+import com.example.domain.repository.GetUsersRepository
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryBuilder {
+
+ @Binds
+ abstract fun bindUserRepo(userRepoImpl: GetUsersRepoImpl): GetUsersRepository
+
+ @Binds
+ abstract fun bindUserDetailRepo(userDetailRepoImpl: GetUserDetailRepoImpl): GetUserDetailRepository
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/model/UserDetailItem.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/model/UserDetailItem.kt
new file mode 100644
index 0000000..0474927
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/model/UserDetailItem.kt
@@ -0,0 +1,5 @@
+package com.example.composecodechallenge.features.feature_userdetail.model
+
+data class UserDetailItem(
+ val avatarUrl: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/model/mapper/UserDetailMapper.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/model/mapper/UserDetailMapper.kt
new file mode 100644
index 0000000..9e897e0
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/model/mapper/UserDetailMapper.kt
@@ -0,0 +1,10 @@
+package com.example.composecodechallenge.features.feature_userdetail.model.mapper
+
+import com.example.composecodechallenge.features.feature_userdetail.model.UserDetailItem
+import com.example.domain.model.userdetail.UserDetailModel
+
+internal fun UserDetailModel.toUserDetailItem(): UserDetailItem {
+ return UserDetailItem(
+ avatarUrl.orEmpty()
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/navigation/UserDetailGraph.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/navigation/UserDetailGraph.kt
new file mode 100644
index 0000000..1722f98
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/navigation/UserDetailGraph.kt
@@ -0,0 +1,19 @@
+package com.example.composecodechallenge.features.feature_userdetail.navigation
+
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.navigation
+import com.example.composecodechallenge.features.feature_userlist.navigation.USER_ROUTE_PREFIX
+
+const val USER_GRAPH_ROUTE_PATTERN = "user_graph"
+
+fun NavGraphBuilder.userDetailGraph(
+ navController: NavHostController
+) {
+ navigation(
+ route = USER_GRAPH_ROUTE_PATTERN,
+ startDestination = USER_ROUTE_PREFIX,
+ ) {
+ userDetailsScreen(navController)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/navigation/UserDetailNavigation.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/navigation/UserDetailNavigation.kt
new file mode 100644
index 0000000..69bfab8
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/navigation/UserDetailNavigation.kt
@@ -0,0 +1,37 @@
+package com.example.composecodechallenge.features.feature_userdetail.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavOptions
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.example.composecodechallenge.features.feature_userdetail.ui.UserListRoute
+import com.example.composecodechallenge.features.feature_userlist.navigation.USER_ROUTE_PREFIX
+
+private const val USER_DETAILS_ROUTE = "$USER_ROUTE_PREFIX/detail"
+const val USER_NAME = "userName"
+const val USER_NAME_ARG_SUFFIX = "/{$USER_NAME}"
+const val DEFAULT_VALUE = "Koorosh"
+
+internal fun NavController.navigateToUserDetails(
+ searchQuery: String,
+ navOptions: NavOptions? = null
+) {
+ navigate(route = "$USER_DETAILS_ROUTE/$searchQuery", navOptions = navOptions)
+}
+
+fun NavGraphBuilder.userDetailsScreen(
+ navController: NavController,
+) {
+ composable(route = USER_DETAILS_ROUTE + USER_NAME_ARG_SUFFIX,
+ arguments = listOf(navArgument(USER_NAME) {
+ type = NavType.StringType
+ defaultValue = DEFAULT_VALUE
+ })
+ ) {
+ UserListRoute(
+ onBackClick = navController::popBackStack,
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/ui/UserDetailRoute.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/ui/UserDetailRoute.kt
new file mode 100644
index 0000000..8410999
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/ui/UserDetailRoute.kt
@@ -0,0 +1,20 @@
+package com.example.composecodechallenge.features.feature_userdetail.ui
+
+import androidx.compose.runtime.Composable
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.composecodechallenge.features.feature_userdetail.viewmodel.UserDetailViewModel
+
+@Composable
+internal fun UserListRoute(
+ viewModel: UserDetailViewModel = hiltViewModel(),
+ onBackClick: () -> Unit,
+) {
+
+ val userDetail = viewModel.userDetail.collectAsStateWithLifecycle()
+
+ UserDetailsScreen(
+ userDetail = userDetail,
+ onBackClick = onBackClick
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/ui/UserDetailScreen.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/ui/UserDetailScreen.kt
new file mode 100644
index 0000000..e3dd869
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/ui/UserDetailScreen.kt
@@ -0,0 +1,60 @@
+package com.example.composecodechallenge.features.feature_userdetail.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import coil.compose.rememberAsyncImagePainter
+import com.example.composecodechallenge.features.feature_userdetail.model.UserDetailItem
+import com.example.composecodechallenge.main.ui.common.SimpleTopAppBar
+import com.example.composecodechallenge.main.ui.theme.ThemePreview
+
+@Composable
+internal fun UserDetailsScreen(
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ userDetail: State,
+) {
+
+ Scaffold(
+ topBar = {
+ SimpleTopAppBar(title = "", onBackClick = onBackClick)
+ },
+ content = {
+ UserDetailScreenContent(
+ imageUrl = userDetail.value.avatarUrl,
+ modifier = modifier.padding(it),
+ )
+ }
+ )
+}
+
+@Composable
+internal fun UserDetailScreenContent(
+ imageUrl: String,
+ modifier: Modifier = Modifier
+) {
+ Image(
+ painter = rememberAsyncImagePainter(model = imageUrl),
+ contentDescription = "",
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(CircleShape)
+ .then(modifier)
+ )
+}
+
+@ThemePreview
+@Composable
+internal fun UserDetailsScreenPreview() {
+ UserDetailsScreen(
+ onBackClick = {},
+ userDetail = mutableStateOf(UserDetailItem(""))
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/viewmodel/UserDetailViewModel.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/viewmodel/UserDetailViewModel.kt
new file mode 100644
index 0000000..f3e1fb3
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userdetail/viewmodel/UserDetailViewModel.kt
@@ -0,0 +1,57 @@
+package com.example.composecodechallenge.features.feature_userdetail.viewmodel
+
+import android.util.Log
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.composecodechallenge.features.feature_userdetail.model.UserDetailItem
+import com.example.composecodechallenge.features.feature_userdetail.model.mapper.toUserDetailItem
+import com.example.domain.model.error.Error
+import com.example.domain.model.userdetail.UserDetailModel
+import com.example.domain.usecase.GetUserDetailUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@HiltViewModel
+class UserDetailViewModel @Inject constructor(
+ private val getUserDetailUseCase: GetUserDetailUseCase,
+ savedStateHandle: SavedStateHandle,
+) :
+ ViewModel() {
+
+ private val userName: String = checkNotNull(savedStateHandle["userName"])
+
+ private val _userDetail = MutableStateFlow(UserDetailItem(""))
+ val userDetail = _userDetail.asStateFlow()
+
+ init {
+ getUserDetail()
+ }
+
+ private fun getUserDetail() {
+ viewModelScope.launch {
+ getUserDetailUseCase.getUserDetail(userName).fold(
+ ifRight = ::onSuccessResponse,
+ ifLeft = ::onErrorResponse
+ )
+ }
+ }
+
+ private fun onErrorResponse(error: Error) {
+ Log.i("error", error.toString())
+ }
+
+ private fun onSuccessResponse(questionList: UserDetailModel) {
+ viewModelScope.launch {
+ withContext(Dispatchers.Default) {
+ _userDetail.value = questionList.toUserDetailItem()
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/model/UserItem.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/model/UserItem.kt
new file mode 100644
index 0000000..abcdf07
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/model/UserItem.kt
@@ -0,0 +1,7 @@
+package com.example.composecodechallenge.features.feature_userlist.model
+
+data class UserItem(
+ val id: Int,
+ val avatarUrl: String,
+ val userName: String,
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/model/mapper/UserListMapper.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/model/mapper/UserListMapper.kt
new file mode 100644
index 0000000..5e2367f
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/model/mapper/UserListMapper.kt
@@ -0,0 +1,12 @@
+package com.example.composecodechallenge.features.feature_userlist.model.mapper
+
+import com.example.composecodechallenge.features.feature_userlist.model.UserItem
+import com.example.domain.model.userlist.UserModel
+
+internal fun UserModel.toUserItem(): UserItem {
+ return UserItem(
+ id,
+ avatarUrl.orEmpty(),
+ login.orEmpty(),
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/navigation/UserListNavigation.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/navigation/UserListNavigation.kt
new file mode 100644
index 0000000..2dd222f
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/navigation/UserListNavigation.kt
@@ -0,0 +1,17 @@
+package com.example.composecodechallenge.features.feature_userlist.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.compose.composable
+import com.example.composecodechallenge.features.feature_userdetail.navigation.USER_NAME_ARG_SUFFIX
+import com.example.composecodechallenge.features.feature_userdetail.navigation.navigateToUserDetails
+import com.example.composecodechallenge.features.feature_userlist.ui.UserListRoute
+
+internal const val USER_ROUTE_PREFIX = "user"
+internal const val USER_LIST_ROUTE = "${USER_ROUTE_PREFIX}_list_route$USER_NAME_ARG_SUFFIX"
+
+fun NavGraphBuilder.userListScreen(navController: NavController) {
+ composable(route = USER_LIST_ROUTE) {
+ UserListRoute(navigateToUserDetails = navController::navigateToUserDetails)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/ui/UserListScreen.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/ui/UserListScreen.kt
new file mode 100644
index 0000000..45ecc51
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/ui/UserListScreen.kt
@@ -0,0 +1,160 @@
+package com.example.composecodechallenge.features.feature_userlist.ui
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import coil.compose.rememberAsyncImagePainter
+import com.example.composecodechallenge.R
+import com.example.composecodechallenge.features.feature_userlist.model.UserItem
+import com.example.composecodechallenge.main.ui.common.SimpleTopAppBar
+import com.example.composecodechallenge.main.ui.theme.ThemePreview
+import com.example.composecodechallenge.main.ui.theme.space
+
+@Composable
+internal fun UserListScreen(
+ modifier: Modifier = Modifier,
+ searchQueryTextState: State,
+ onSearchQueryChange: (String) -> Unit,
+ users: State>,
+ navigateToUserDetails: (String) -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ SimpleTopAppBar(title = stringResource(id = R.string.top_bar_title))
+ },
+ content = {
+ UserListScreenContent(
+ modifier = modifier.padding(it),
+ searchQueryTextState = searchQueryTextState,
+ onSearchQueryChange = onSearchQueryChange,
+ users = users,
+ navigateToUserDetails,
+ )
+ }
+ )
+}
+
+@Composable
+fun UserListScreenContent(
+ modifier: Modifier = Modifier,
+ searchQueryTextState: State,
+ onSearchQueryChange: (String) -> Unit,
+ users: State>,
+ navigateToUserDetails: (String) -> Unit,
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = MaterialTheme.space.small),
+ ) {
+ TextField(searchQueryTextState, onSearchQueryChange)
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .weight(1f)
+ .testTag("list_item"),
+ verticalArrangement = Arrangement.spacedBy(MaterialTheme.space.sMedium),
+
+ ) {
+ items(users.value, key = {
+ it.id
+ }) {
+ Card(
+ modifier = Modifier
+ .fillMaxSize()
+ .clickable {
+ navigateToUserDetails.invoke(it.userName)
+ },
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+
+ Spacer(Modifier.width(MaterialTheme.space.small))
+
+ Image(
+ painter = rememberAsyncImagePainter(model = it.avatarUrl),
+ contentDescription = it.userName,
+ modifier = Modifier
+ .size(MaterialTheme.space.xLarge)
+ .clip(CircleShape)
+ )
+
+ Spacer(Modifier.width(MaterialTheme.space.small))
+
+ Text(
+ text = it.userName,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = MaterialTheme.space.medium)
+ )
+ }
+ }
+
+ }
+ }
+ }
+
+}
+
+@Composable
+internal fun TextField(
+ searchQueryTextState: State,
+ onSearchQueryChange: (String) -> Unit
+) {
+ OutlinedTextField(
+ value = searchQueryTextState.value.orEmpty(),
+ onValueChange = {
+ onSearchQueryChange(it)
+ },
+ placeholder = { Text(text = stringResource(id = R.string.hint_search)) },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Text,
+ imeAction = ImeAction.Done
+ ),
+ modifier = Modifier
+ .padding(MaterialTheme.space.small)
+ .fillMaxWidth()
+ .testTag("search_users")
+ )
+}
+
+@ThemePreview
+@Composable
+internal fun UserListPreview() {
+ UserListScreen(
+ searchQueryTextState = mutableStateOf("Search..."),
+ onSearchQueryChange = {},
+ users = mutableStateOf(emptyList()),
+ navigateToUserDetails = {}
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/ui/UserListScreenRoute.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/ui/UserListScreenRoute.kt
new file mode 100644
index 0000000..1179b33
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/ui/UserListScreenRoute.kt
@@ -0,0 +1,22 @@
+package com.example.composecodechallenge.features.feature_userlist.ui
+
+import androidx.compose.runtime.Composable
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.example.composecodechallenge.features.feature_userlist.viewmodel.UserListViewModel
+
+@Composable
+internal fun UserListRoute(
+ viewModel: UserListViewModel = hiltViewModel(),
+ navigateToUserDetails: (String) -> Unit,
+) {
+ val searchQueryTextState = viewModel.searchQueryText.collectAsStateWithLifecycle()
+ val users = viewModel.usersState.collectAsStateWithLifecycle()
+
+ UserListScreen(
+ searchQueryTextState = searchQueryTextState,
+ onSearchQueryChange = viewModel::onSearchQueryChange,
+ users = users,
+ navigateToUserDetails = navigateToUserDetails,
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/viewmodel/UserListViewModel.kt b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/viewmodel/UserListViewModel.kt
new file mode 100644
index 0000000..60b7c7f
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/features/feature_userlist/viewmodel/UserListViewModel.kt
@@ -0,0 +1,86 @@
+package com.example.composecodechallenge.features.feature_userlist.viewmodel
+
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.composecodechallenge.features.feature_userlist.model.UserItem
+import com.example.composecodechallenge.features.feature_userlist.model.mapper.toUserItem
+import com.example.domain.model.error.Error
+import com.example.domain.model.userlist.UserModel
+import com.example.domain.usecase.GetUsersUseCase
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+
+@HiltViewModel
+class UserListViewModel @Inject constructor(
+ private val getUsersUseCase: GetUsersUseCase
+) : ViewModel(){
+
+ private val _usersState = MutableStateFlow(emptyList())
+ val usersState = _usersState.asStateFlow()
+
+ private val _searchQueryText = MutableStateFlow(null)
+ val searchQueryText = _searchQueryText.asStateFlow()
+
+ private var searchJob: Job? = null
+
+ init {
+ startToCollectSearchQueries()
+ }
+
+ private fun startToCollectSearchQueries() {
+ searchQueryText
+ .filterNotNull()
+ .debounce(QUERY_DEBOUNCE_IN_MILLIS)
+ .onEach(::getUsers)
+ .flowOn(Dispatchers.IO)
+ .launchIn(viewModelScope)
+ }
+
+ fun onSearchQueryChange(query: String) {
+ _searchQueryText.value = query
+ }
+
+ private fun getUsers(query: String) {
+ searchJob?.cancel()
+
+ if (query.isBlank()) {
+ _usersState.value = emptyList()
+ return
+ }
+
+ searchJob = viewModelScope.launch {
+ getUsersUseCase.getUsers(query).fold(
+ ifRight = ::onSuccessResponse,
+ ifLeft = ::onErrorResponse
+ )
+ }
+ }
+
+ private fun onSuccessResponse(users: List) {
+ viewModelScope.launch {
+ withContext(Dispatchers.Default) {
+ _usersState.value = users.map { it.toUserItem() }
+ }
+ }
+ }
+
+ private fun onErrorResponse(error: Error) {
+ Log.i("error", error.toString())
+ }
+
+ private companion object {
+ private const val QUERY_DEBOUNCE_IN_MILLIS = 500L
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/navigation/ItollNavHost.kt b/app/src/main/java/com/example/composecodechallenge/main/navigation/ItollNavHost.kt
new file mode 100644
index 0000000..8a75efb
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/navigation/ItollNavHost.kt
@@ -0,0 +1,39 @@
+package com.example.composecodechallenge.main.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.testTagsAsResourceId
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import com.example.composecodechallenge.features.feature_userdetail.navigation.userDetailGraph
+import com.example.composecodechallenge.features.feature_userlist.navigation.USER_LIST_ROUTE
+import com.example.composecodechallenge.features.feature_userlist.navigation.userListScreen
+
+/**
+ * Top-level navigation graph. Navigation is organized as explained at
+ * https://d.android.com/jetpack/compose/nav-adaptive
+ *
+ * The navigation graph defined in this file defines the different top level routes. Navigation
+ * within each route is handled using state and Back Handlers.
+ */
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun ItollNavHost(
+ navController: NavHostController,
+ onBackClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ startDestination: String = USER_LIST_ROUTE,
+) {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination,
+ modifier = Modifier.semantics {
+ testTagsAsResourceId = true
+ }.then(modifier)
+ ) {
+ userListScreen(navController)
+ userDetailGraph(navController)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/ItollApp.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/ItollApp.kt
new file mode 100644
index 0000000..b203256
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/ItollApp.kt
@@ -0,0 +1,18 @@
+package com.example.composecodechallenge.main.ui
+
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.runtime.Composable
+import com.example.composecodechallenge.main.navigation.ItollNavHost
+
+@Composable
+fun ItollApp(
+ windowSizeClass: WindowSizeClass,
+ appState: ItollAppState = rememberItollAppState(
+ windowSizeClass = windowSizeClass
+ ),
+) {
+ ItollNavHost(
+ navController = appState.navController,
+ onBackClick = appState::onBackClick
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/ItollAppState.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/ItollAppState.kt
new file mode 100644
index 0000000..d033b48
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/ItollAppState.kt
@@ -0,0 +1,29 @@
+package com.example.composecodechallenge.main.ui
+
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.rememberNavController
+import kotlinx.coroutines.CoroutineScope
+
+@Composable
+fun rememberItollAppState(
+ windowSizeClass: WindowSizeClass,
+ coroutineScope: CoroutineScope = rememberCoroutineScope(),
+ navController: NavHostController = rememberNavController(),
+): ItollAppState {
+ return remember(navController, coroutineScope, windowSizeClass) {
+ ItollAppState(navController)
+ }
+}
+
+@Stable
+class ItollAppState(val navController: NavHostController) {
+
+ fun onBackClick() {
+ navController.navigateUp()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/MainActivity.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/MainActivity.kt
new file mode 100644
index 0000000..c4fba4b
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/MainActivity.kt
@@ -0,0 +1,26 @@
+package com.example.composecodechallenge.main.ui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
+import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
+import com.example.composecodechallenge.main.ui.theme.ItollTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ ItollTheme {
+ @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
+ ItollApp(
+ windowSizeClass = calculateWindowSizeClass(
+ this
+ )
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/common/SimpleTobBar.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/common/SimpleTobBar.kt
new file mode 100644
index 0000000..07c60ec
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/common/SimpleTobBar.kt
@@ -0,0 +1,79 @@
+package com.example.composecodechallenge.main.ui.common
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import com.example.composecodechallenge.R
+import com.example.composecodechallenge.main.ui.theme.ThemePreview
+import com.example.composecodechallenge.main.ui.theme.space
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SimpleTopAppBar(
+ title: String,
+ onBackClick: () -> Unit
+) {
+ TopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ navigationIconContentColor = MaterialTheme.colorScheme.onBackground
+ ),
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ modifier = Modifier.size(MaterialTheme.space.xMedium).testTag("back"),
+ painter = painterResource(id = R.drawable.ic_back),
+ contentDescription = "back",
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
+ )
+}
+
+/**
+ * SimpleTopAppBar for our first page. We do not need navigation icon in first page
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SimpleTopAppBar(
+ title: String,
+) {
+ CenterAlignedTopAppBar(
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ navigationIconContentColor = MaterialTheme.colorScheme.onBackground
+ ),
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onBackground
+ )
+ },
+ )
+}
+
+@ThemePreview
+@Composable
+fun PreviewSimpleTopBar() {
+ SimpleTopAppBar(title = "Simple toolbar") {
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Color.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Color.kt
new file mode 100644
index 0000000..357c5ea
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Color.kt
@@ -0,0 +1,38 @@
+package com.example.composecodechallenge.main.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+internal val Blue10 = Color(0xFF001F28)
+internal val Blue20 = Color(0xFF003544)
+internal val Blue30 = Color(0xFF004D61)
+internal val Blue40 = Color(0xFF006780)
+internal val Blue80 = Color(0xFF5DD5FC)
+internal val Blue90 = Color(0xFFB8EAFF)
+internal val DarkPurpleGray10 = Color(0xFF201A1B)
+internal val DarkPurpleGray20 = Color(0xFF362F30)
+internal val DarkPurpleGray90 = Color(0xFFECDFE0)
+internal val DarkPurpleGray95 = Color(0xFFFAEEEF)
+internal val DarkPurpleGray99 = Color(0xFFFCFCFC)
+internal val Orange10 = Color(0xFF380D00)
+internal val Orange20 = Color(0xFF5B1A00)
+internal val Orange30 = Color(0xFF812800)
+internal val Orange40 = Color(0xFFA23F16)
+internal val Orange80 = Color(0xFFFFB59B)
+internal val Orange90 = Color(0xFFFFDBCF)
+internal val Purple10 = Color(0xFF36003C)
+internal val Purple20 = Color(0xFF560A5D)
+internal val Purple30 = Color(0xFF702776)
+internal val Purple40 = Color(0xFF8B418F)
+internal val Purple80 = Color(0xFFFFA9FE)
+internal val Purple90 = Color(0xFFFFD6FA)
+internal val PurpleGray30 = Color(0xFF4D444C)
+internal val PurpleGray50 = Color(0xFF7F747C)
+internal val PurpleGray60 = Color(0xFF998D96)
+internal val PurpleGray80 = Color(0xFFD0C3CC)
+internal val PurpleGray90 = Color(0xFFEDDEE8)
+internal val Red10 = Color(0xFF410002)
+internal val Red20 = Color(0xFF690005)
+internal val Red30 = Color(0xFF93000A)
+internal val Red40 = Color(0xFFBA1A1A)
+internal val Red80 = Color(0xFFFFB4AB)
+internal val Red90 = Color(0xFFFFDAD6)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Space.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Space.kt
new file mode 100644
index 0000000..336d83c
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Space.kt
@@ -0,0 +1,30 @@
+package com.example.composecodechallenge.main.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+@Immutable
+class Space {
+
+ val tiny: Dp = 2.dp
+ val xSmall: Dp = 4.dp
+ val small: Dp = 8.dp
+ val sMedium = 12.dp
+ val medium: Dp = 16.dp
+ val xMedium: Dp = 24.dp
+ val large: Dp = 32.dp
+ val xLarge: Dp = 48.dp
+ val xxLarge: Dp = 64.dp
+}
+
+val LocalSpace = staticCompositionLocalOf { Space() }
+
+val MaterialTheme.space: Space
+ @Composable
+ @ReadOnlyComposable
+ get() = LocalSpace.current
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Theme.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Theme.kt
new file mode 100644
index 0000000..6bd4b5c
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Theme.kt
@@ -0,0 +1,79 @@
+package com.example.composecodechallenge.main.ui.theme
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+
+val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ onPrimary = Color.White,
+ primaryContainer = Purple90,
+ onPrimaryContainer = Purple10,
+ secondary = Orange40,
+ onSecondary = Color.White,
+ secondaryContainer = Orange90,
+ onSecondaryContainer = Orange10,
+ tertiary = Blue40,
+ onTertiary = Color.White,
+ tertiaryContainer = Blue90,
+ onTertiaryContainer = Blue10,
+ error = Red40,
+ onError = Color.White,
+ errorContainer = Red90,
+ onErrorContainer = Red10,
+ background = DarkPurpleGray99,
+ onBackground = DarkPurpleGray10,
+ surface = DarkPurpleGray99,
+ onSurface = DarkPurpleGray10,
+ surfaceVariant = PurpleGray90,
+ onSurfaceVariant = PurpleGray30,
+ inverseSurface = DarkPurpleGray20,
+ inverseOnSurface = DarkPurpleGray95,
+ outline = PurpleGray50,
+)
+
+val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ onPrimary = Purple20,
+ primaryContainer = Purple30,
+ onPrimaryContainer = Purple90,
+ secondary = Orange80,
+ onSecondary = Orange20,
+ secondaryContainer = Orange30,
+ onSecondaryContainer = Orange90,
+ tertiary = Blue80,
+ onTertiary = Blue20,
+ tertiaryContainer = Blue30,
+ onTertiaryContainer = Blue90,
+ error = Red80,
+ onError = Red20,
+ errorContainer = Red30,
+ onErrorContainer = Red90,
+ background = DarkPurpleGray10,
+ onBackground = DarkPurpleGray90,
+ surface = DarkPurpleGray10,
+ onSurface = DarkPurpleGray90,
+ surfaceVariant = PurpleGray30,
+ onSurfaceVariant = PurpleGray80,
+ inverseSurface = DarkPurpleGray90,
+ inverseOnSurface = DarkPurpleGray10,
+ outline = PurpleGray60,
+)
+
+@Composable
+fun ItollTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit
+) {
+
+ val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/theme/ThemePreview.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/ThemePreview.kt
new file mode 100644
index 0000000..d582ff5
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/ThemePreview.kt
@@ -0,0 +1,16 @@
+package com.example.composecodechallenge.main.ui.theme
+
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(name = "Day Mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Night Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+annotation class ThemePreview
+
+@Composable
+fun ItollPreviewTheme(content: @Composable () -> Unit) {
+ ItollTheme {
+ content()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Type.kt b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Type.kt
new file mode 100644
index 0000000..1b1f527
--- /dev/null
+++ b/app/src/main/java/com/example/composecodechallenge/main/ui/theme/Type.kt
@@ -0,0 +1,18 @@
+package com.example.composecodechallenge.main.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/ui/theme/Color.kt b/app/src/main/java/com/example/composecodechallenge/ui/theme/Color.kt
deleted file mode 100644
index 34c7f76..0000000
--- a/app/src/main/java/com/example/composecodechallenge/ui/theme/Color.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.example.composecodechallenge.ui.theme
-
-import androidx.compose.ui.graphics.Color
-
-val Purple80 = Color(0xFFD0BCFF)
-val PurpleGrey80 = Color(0xFFCCC2DC)
-val Pink80 = Color(0xFFEFB8C8)
-
-val Purple40 = Color(0xFF6650a4)
-val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/ui/theme/Theme.kt b/app/src/main/java/com/example/composecodechallenge/ui/theme/Theme.kt
deleted file mode 100644
index 5e5dadf..0000000
--- a/app/src/main/java/com/example/composecodechallenge/ui/theme/Theme.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package com.example.composecodechallenge.ui.theme
-
-import android.app.Activity
-import android.os.Build
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.SideEffect
-import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalView
-import androidx.core.view.WindowCompat
-
-private val DarkColorScheme = darkColorScheme(
- primary = Purple80,
- secondary = PurpleGrey80,
- tertiary = Pink80
-)
-
-private val LightColorScheme = lightColorScheme(
- primary = Purple40,
- secondary = PurpleGrey40,
- tertiary = Pink40
-
- /* Other default colors to override
- background = Color(0xFFFFFBFE),
- surface = Color(0xFFFFFBFE),
- onPrimary = Color.White,
- onSecondary = Color.White,
- onTertiary = Color.White,
- onBackground = Color(0xFF1C1B1F),
- onSurface = Color(0xFF1C1B1F),
- */
-)
-
-@Composable
-fun ComposeCodeChallengeTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
- // Dynamic color is available on Android 12+
- dynamicColor: Boolean = true,
- content: @Composable () -> Unit
-) {
- val colorScheme = when {
- dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
- val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
- }
-
- darkTheme -> DarkColorScheme
- else -> LightColorScheme
- }
- val view = LocalView.current
- if (!view.isInEditMode) {
- SideEffect {
- val window = (view.context as Activity).window
- window.statusBarColor = colorScheme.primary.toArgb()
- WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
- }
- }
-
- MaterialTheme(
- colorScheme = colorScheme,
- typography = Typography,
- content = content
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/composecodechallenge/ui/theme/Type.kt b/app/src/main/java/com/example/composecodechallenge/ui/theme/Type.kt
deleted file mode 100644
index f50c2d6..0000000
--- a/app/src/main/java/com/example/composecodechallenge/ui/theme/Type.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.example.composecodechallenge.ui.theme
-
-import androidx.compose.material3.Typography
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.sp
-
-// Set of Material typography styles to start with
-val Typography = Typography(
- bodyLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.5.sp
- )
- /* Other default text styles to override
- titleLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 22.sp,
- lineHeight = 28.sp,
- letterSpacing = 0.sp
- ),
- labelSmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 11.sp,
- lineHeight = 16.sp,
- letterSpacing = 0.5.sp
- )
- */
-)
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml
new file mode 100644
index 0000000..62add91
--- /dev/null
+++ b/app/src/main/res/drawable/ic_back.xml
@@ -0,0 +1,5 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 52e6691..1f2f583 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,5 @@
ComposeCodeChallenge
+ Compose Code Challenge
+ Search...
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 37a3841..92c4ab5 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -1,5 +1,5 @@
-
+
\ No newline at end of file
diff --git a/app/src/test/java/com/example/composecodechallenge/ExampleUnitTest.kt b/app/src/test/java/com/example/composecodechallenge/ExampleUnitTest.kt
deleted file mode 100644
index 3279081..0000000
--- a/app/src/test/java/com/example/composecodechallenge/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.example.composecodechallenge
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/app/src/test/java/com/example/composecodechallenge/features/feature_userdetail/viewmodel/UserDetailViewModelTest.kt b/app/src/test/java/com/example/composecodechallenge/features/feature_userdetail/viewmodel/UserDetailViewModelTest.kt
new file mode 100644
index 0000000..3a7cbd9
--- /dev/null
+++ b/app/src/test/java/com/example/composecodechallenge/features/feature_userdetail/viewmodel/UserDetailViewModelTest.kt
@@ -0,0 +1,50 @@
+package com.example.composecodechallenge.features.feature_userdetail.viewmodel
+
+import androidx.lifecycle.SavedStateHandle
+import arrow.core.Either
+import com.example.composecodechallenge.features.feature_userdetail.model.mapper.toUserDetailItem
+import com.example.domain.model.userdetail.UserDetailModel
+import com.example.domain.usecase.GetUserDetailUseCase
+import com.example.composecodechallenge.util.CoroutinesTestRule
+import kotlinx.coroutines.test.runTest
+import org.hamcrest.CoreMatchers.`is`
+import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.anyString
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@RunWith(MockitoJUnitRunner::class)
+class UserDetailViewModelTest {
+
+ @get:Rule
+ val coroutinesTestRule = CoroutinesTestRule()
+
+ private val getUserDetailUseCase: GetUserDetailUseCase = mock()
+ private val savedStateHandle: SavedStateHandle = mock()
+ private val testUserDetailModel = UserDetailModel()
+ private lateinit var viewModel: UserDetailViewModel
+
+ @Before
+ fun setUp() = runTest {
+ whenever(savedStateHandle.get(anyString())) doReturn "test"
+ whenever(getUserDetailUseCase.getUserDetail(any())) doReturn Either.Right(
+ testUserDetailModel
+ )
+ viewModel = UserDetailViewModel(getUserDetailUseCase, savedStateHandle)
+ }
+
+ @Test
+ fun `user detail should be get successfully right after view model construction`() = runTest {
+ // Given
+ val userDetailItem = testUserDetailModel.toUserDetailItem()
+ // Then
+ assertThat(viewModel.userDetail.value, `is`(userDetailItem))
+ }
+}
diff --git a/app/src/test/java/com/example/composecodechallenge/features/feature_userlist/viewmodel/UserListViewModelTest.kt b/app/src/test/java/com/example/composecodechallenge/features/feature_userlist/viewmodel/UserListViewModelTest.kt
new file mode 100644
index 0000000..65f76e3
--- /dev/null
+++ b/app/src/test/java/com/example/composecodechallenge/features/feature_userlist/viewmodel/UserListViewModelTest.kt
@@ -0,0 +1,78 @@
+package com.example.composecodechallenge.features.feature_userlist.viewmodel
+
+import arrow.core.Either
+import com.example.composecodechallenge.features.feature_userlist.model.mapper.toUserItem
+import com.example.domain.model.userlist.UserModel
+import com.example.domain.usecase.GetUsersUseCase
+import com.example.composecodechallenge.util.CoroutinesTestRule
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.core.Is.`is`
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@RunWith(MockitoJUnitRunner::class)
+class UserListViewModelTest {
+
+ @get:Rule
+ val coroutinesTestRule = CoroutinesTestRule()
+
+ private val getUsersUseCase: GetUsersUseCase = mock()
+ private lateinit var viewModel: UserListViewModel
+
+ @Before
+ fun setUp() {
+ viewModel = UserListViewModel(getUsersUseCase)
+ }
+
+ @Test
+ fun `an empty list should be set when onSearchQueryChange is called with an empty argument`() =
+ runTest {
+ // Given
+ val searchQuery = ""
+
+ // When
+ viewModel.onSearchQueryChange(searchQuery)
+
+ // Then
+ assertThat(viewModel.usersState.value.size, `is`(0))
+ verify(getUsersUseCase, never()).getUsers(searchQuery)
+ }
+
+ @Test
+ fun `users list should be get successfully when onSearchQueryChange is called`() = runTest {
+ // Given
+ val searchQuery = "test"
+ val userModel = UserModel(
+ id = 0,
+ avatarUrl = "test",
+ organizationsUrl = "1",
+ type = "test",
+ login = "test"
+ )
+ val userModelList = listOf(userModel)
+ val userItemList = userModelList.map { it.toUserItem() }
+ whenever(getUsersUseCase.getUsers(searchQuery)) doReturn Either.Right(userModelList)
+
+ runBlocking {
+ // When
+ viewModel.onSearchQueryChange(searchQuery)
+ delay(1000)
+
+ // Then
+ assertThat(viewModel.usersState.first(), `is`(userItemList))
+ verify(getUsersUseCase).getUsers(searchQuery)
+ }
+ }
+}
diff --git a/app/src/test/java/com/example/composecodechallenge/util/CoroutinesTestRule.kt b/app/src/test/java/com/example/composecodechallenge/util/CoroutinesTestRule.kt
new file mode 100644
index 0000000..87fc5a9
--- /dev/null
+++ b/app/src/test/java/com/example/composecodechallenge/util/CoroutinesTestRule.kt
@@ -0,0 +1,26 @@
+package com.example.composecodechallenge.util
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.rules.TestWatcher
+import org.junit.runner.Description
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CoroutinesTestRule(
+ val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
+) : TestWatcher() {
+
+ override fun starting(description: Description) {
+ super.starting(description)
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description) {
+ super.finished(description)
+ Dispatchers.resetMain()
+ }
+}
diff --git a/app/src/test/java/com/example/data/repository/GetUserDetailRepoImplTest.kt b/app/src/test/java/com/example/data/repository/GetUserDetailRepoImplTest.kt
new file mode 100644
index 0000000..7777895
--- /dev/null
+++ b/app/src/test/java/com/example/data/repository/GetUserDetailRepoImplTest.kt
@@ -0,0 +1,48 @@
+package com.example.data.repository
+
+import com.example.composecodechallenge.util.CoroutinesTestRule
+import com.example.data.source.cloud.BaseCloudRepository
+import com.example.domain.model.userlist.UsersModel
+import com.example.domain.repository.GetUsersRepository
+import kotlinx.coroutines.test.runTest
+import org.hamcrest.CoreMatchers.`is`
+import org.hamcrest.MatcherAssert.assertThat
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+@RunWith(MockitoJUnitRunner::class)
+class GetUserDetailRepoImplTest {
+
+ @get:Rule
+ val coroutinesTestRule = CoroutinesTestRule()
+
+ private val baseCloudRepository: BaseCloudRepository = mock()
+ private lateinit var getUsersRepository: GetUsersRepository
+
+ @Before
+ fun setUp() {
+ getUsersRepository = GetUsersRepoImpl(baseCloudRepository)
+ }
+
+ @Test
+ fun `the response should be get successfully when getUsersRepository is called`()= runTest {
+ // Given
+ val userName="test"
+ val response = UsersModel(users = listOf())
+ whenever(baseCloudRepository.getUsers(userName)) doReturn response
+
+ // When
+ val result = getUsersRepository.getUsersRepository(userName)
+
+ // Then
+ assertThat(result, `is`(response))
+ verify(baseCloudRepository).getUsers(userName)
+ }
+}
diff --git a/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000..ca6ee9c
--- /dev/null
+++ b/app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
\ No newline at end of file
diff --git a/benchmark/.gitignore b/benchmark/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/benchmark/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts
new file mode 100644
index 0000000..34cf4bc
--- /dev/null
+++ b/benchmark/build.gradle.kts
@@ -0,0 +1,67 @@
+import com.android.build.api.dsl.ManagedVirtualDevice
+import com.example.buildsrc.Libs
+
+plugins {
+ id("com.android.test")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "com.example.benchmark"
+ compileSdk = 33
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ defaultConfig {
+ minSdk = 24
+ targetSdk = 33
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ // This must point to the app module.
+ targetProjectPath = ":app"
+
+ testOptions.managedDevices.devices {
+ create("pixel6Api30") {
+ device = "Pixel 6"
+ apiLevel = 30
+ systemImageSource = "aosp"
+ }
+ }
+
+ buildTypes {
+ // This benchmark buildType is used for benchmarking, and should function like your
+ // release build (for example, with minification on). It"s signed with a debug key
+ // for easy local/CI testing.
+ create("benchmark") {
+ isDebuggable = true
+ signingConfig = getByName("debug").signingConfig
+ matchingFallbacks += listOf("release")
+ proguardFiles("benchmark-rules.pro")
+ }
+ }
+
+ targetProjectPath = ":app"
+ experimentalProperties["android.experimental.self-instrumenting"] = true
+}
+
+dependencies {
+ implementation(Libs.Testing.junitEx)
+ implementation(Libs.Testing.espresso)
+ implementation(Libs.Testing.uiAnimatorTest)
+ implementation(Libs.Testing.macroBenchMark)
+}
+
+androidComponents {
+ beforeVariants(selector().all()) {
+ it.enabled = it.buildType == "benchmark"
+ }
+}
\ No newline at end of file
diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bf5ce9d
--- /dev/null
+++ b/benchmark/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/benchmark/src/main/java/com/example/benchmark/BaselineProfileGenerator.kt b/benchmark/src/main/java/com/example/benchmark/BaselineProfileGenerator.kt
new file mode 100644
index 0000000..990ffbe
--- /dev/null
+++ b/benchmark/src/main/java/com/example/benchmark/BaselineProfileGenerator.kt
@@ -0,0 +1,27 @@
+package com.example.benchmark
+
+import androidx.benchmark.macro.ExperimentalBaselineProfilesApi
+import androidx.benchmark.macro.junit4.BaselineProfileRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalBaselineProfilesApi::class)
+@RunWith(AndroidJUnit4::class)
+class BaselineProfileGenerator {
+
+ @get:Rule
+ val baseLineRule = BaselineProfileRule()
+
+ @Test
+ fun generateBaselineProfile() = baseLineRule.collectBaselineProfile(
+ packageName = "com.example.composecodechallenge",
+ ){
+ pressHome()
+ startActivityAndWait()
+
+ fillTextFieldAndScrollAndClickOnRandomItem()
+ navigateBackFromDetailScreen()
+ }
+}
\ No newline at end of file
diff --git a/benchmark/src/main/java/com/example/benchmark/ExampleStartupBenchmark.kt b/benchmark/src/main/java/com/example/benchmark/ExampleStartupBenchmark.kt
new file mode 100644
index 0000000..2862cc1
--- /dev/null
+++ b/benchmark/src/main/java/com/example/benchmark/ExampleStartupBenchmark.kt
@@ -0,0 +1,111 @@
+package com.example.benchmark
+
+import androidx.benchmark.macro.CompilationMode
+import androidx.benchmark.macro.FrameTimingMetric
+import androidx.benchmark.macro.MacrobenchmarkScope
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.StartupTimingMetric
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.Direction
+import androidx.test.uiautomator.Until
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * This is an example startup benchmark.
+ *
+ * It navigates to the device's home screen, and launches the default activity.
+ *
+ * Before running this benchmark:
+ * 1) switch your app's active build variant in the Studio (affects Studio runs only)
+ * 2) add `` to your app's manifest, within the `` tag
+ *
+ * Run this benchmark from Studio to see startup measurements, and captured system traces
+ * for investigating your app's performance.
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleStartupBenchmark {
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ @Test
+ fun startUpCompilationModeNon() = startup(CompilationMode.None())
+
+ @Test
+ fun startUpCompilationModePartial() = startup(CompilationMode.Partial())
+
+ @Test
+ fun scrollAndNavigateModeNone() = scrollAndNavigate(CompilationMode.None())
+
+ @Test
+ fun scrollAndNavigateModePartial() = scrollAndNavigate(CompilationMode.Partial())
+
+ private fun startup(mode: CompilationMode) = benchmarkRule.measureRepeated(
+ packageName = "com.example.composecodechallenge",
+ metrics = listOf(StartupTimingMetric()),
+ iterations = 5,
+ startupMode = StartupMode.COLD,
+ compilationMode = mode
+ ) {
+ pressHome()
+ startActivityAndWait()
+
+ fillTextFieldAndScrollAndClickOnRandomItem()
+ }
+
+ private fun scrollAndNavigate(mode: CompilationMode) = benchmarkRule.measureRepeated(
+ packageName = "com.example.composecodechallenge",
+ metrics = listOf(FrameTimingMetric()),
+ iterations = 5,
+ startupMode = StartupMode.COLD,
+ compilationMode = mode
+ ) {
+ pressHome()
+ startActivityAndWait()
+
+ fillTextFieldAndScrollAndClickOnRandomItem()
+ navigateBackFromDetailScreen()
+ }
+}
+
+fun MacrobenchmarkScope.fillTextFieldAndScrollAndClickOnRandomItem() {
+ val textField = device.findObject(By.res("search_users"))
+ val list = device.findObject(By.res("list_item"))
+
+ textField.click()
+
+ device.performActionAndWait({
+ textField.text = "sa"
+ }, Until.newWindow(), 3000L)
+
+ device.performActionAndWait({
+ textField.text = "sar"
+ }, Until.newWindow(), 1000L)
+
+ device.waitForIdle()
+
+ /**
+ * The below margin is considered for preventing wrong clicks on the keyboard instead of
+ * the list.
+ */
+ list.setGestureMargins(device.displayWidth / 5
+ , device.displayWidth / 5,
+ device.displayWidth/5,
+ device.displayHeight/2 + device.displayWidth/2
+ )
+
+ list.fling(Direction.DOWN)
+
+ list.fling(Direction.UP)
+
+ device.findObject(By.textContains("sara")).click()
+}
+
+fun MacrobenchmarkScope.navigateBackFromDetailScreen() {
+ val backIcon = device.findObject(By.res("back"))
+
+ backIcon.click()
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
deleted file mode 100644
index 4314313..0000000
--- a/build.gradle
+++ /dev/null
@@ -1,6 +0,0 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-plugins {
- id 'com.android.application' version '8.0.2' apply false
- id 'com.android.library' version '8.0.2' apply false
- id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..15b7b77
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,19 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.6.0")
+ classpath("com.google.dagger:hilt-android-gradle-plugin:2.47")
+ }
+}
+
+plugins {
+ id("com.android.application") version "8.0.2" apply false
+ id("com.android.library") version "8.0.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.8.20" apply false
+ id("com.android.test") version "8.0.2" apply false
+ id("org.jetbrains.kotlin.jvm") version "1.8.20" apply false
+}
\ No newline at end of file
diff --git a/buildSrc/.gitignore b/buildSrc/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/buildSrc/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
new file mode 100644
index 0000000..b22ed73
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ `kotlin-dsl`
+}
+
+repositories {
+ mavenCentral()
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/java/com/example/buildsrc/Android.kt b/buildSrc/src/main/java/com/example/buildsrc/Android.kt
new file mode 100644
index 0000000..8d1d0a9
--- /dev/null
+++ b/buildSrc/src/main/java/com/example/buildsrc/Android.kt
@@ -0,0 +1,10 @@
+package com.example.buildsrc
+
+object Android {
+ const val minSdkVersion = 24
+ const val targetSdkVersion = 33
+ const val compileSdkVersion = 33
+ const val applicationId = "com.example.composecodechallenge"
+ const val versionCode = 1
+ const val versionName = "1.0"
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/java/com/example/buildsrc/Libs.kt b/buildSrc/src/main/java/com/example/buildsrc/Libs.kt
new file mode 100644
index 0000000..204b3ca
--- /dev/null
+++ b/buildSrc/src/main/java/com/example/buildsrc/Libs.kt
@@ -0,0 +1,62 @@
+package com.example.buildsrc
+
+object Libs {
+
+ object Modules {
+ const val domain = ":core:domain"
+ const val data = ":core:data"
+ }
+
+ object Jetpack {
+ const val hiltAndroid = "com.google.dagger:hilt-android:${Versions.hiltAndroid}"
+ const val hiltAndroidCompiler =
+ "com.google.dagger:hilt-android-compiler:${Versions.hiltAndroid}"
+ const val hiltWork = "androidx.hilt:hilt-work:${Versions.hiltWork}"
+ const val hiltCompiler = "androidx.hilt:hilt-compiler:${Versions.hiltCompiler}"
+ const val hiltNavCompose =
+ "androidx.hilt:hilt-navigation-compose:${Versions.hiltNavigationCompose}"
+ const val lifecycleRuntime = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.arch}"
+ const val androidxCore = "androidx.core:core-ktx:${Versions.androidxCore}"
+ const val trace = "androidx.tracing:tracing-ktx:${Versions.trace}"
+ const val activityCompose = "androidx.activity:activity-compose:${Versions.activityCompose}"
+ //---compose boms--
+ const val composeBom = "androidx.compose:compose-bom:${Versions.composeBom}"
+ const val composeUi = "androidx.compose.ui:ui"
+ const val composeUiGraphics = "androidx.compose.ui:ui-graphics"
+ const val composeUiToolingPreview = "androidx.compose.ui:ui-tooling-preview"
+ const val composeMaterial3 = "androidx.compose.material3:material3"
+ const val composeTooling = "androidx.compose.ui:ui-tooling"
+ const val composeMaterial3WindowSzeClass = "androidx.compose.material3:material3-window-size-class"
+ const val lifecycleRuntimeCompose = "androidx.lifecycle:lifecycle-runtime-compose:${Versions.lifecycleRuntimeCompose}"
+ const val navigationCompose = "androidx.navigation:navigation-compose:${Versions.navigationCompose}"
+ const val workRuntime = "androidx.work:work-runtime-ktx:${Versions.workRuntime}"
+ }
+
+ object Common {
+ const val kotlinBom = "org.jetbrains.kotlin:kotlin-bom:1.9.0"
+ const val gson = "com.google.code.gson:gson:${Versions.gson}"
+ const val retrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofit}"
+ const val retrofitGson = "com.squareup.retrofit2:converter-gson:${Versions.retrofit}"
+ const val okHttpInterceptor = "com.squareup.okhttp3:logging-interceptor:${Versions.okHttp}"
+ const val stetho = "com.facebook.stetho:stetho:${Versions.stetho}"
+ const val stetho_OkHttp = "com.facebook.stetho:stetho-okhttp3:${Versions.stetho}"
+ const val arrowCore = "io.arrow-kt:arrow-core:${Versions.arrow}"
+ const val coil = "io.coil-kt:coil-compose:${Versions.coil}"
+ const val material = "com.google.android.material:material:${Versions.material}"
+ }
+
+ object Testing {
+ const val junit = "junit:junit:${Versions.junit}"
+ const val junitEx = "androidx.test.ext:junit:${Versions.junitEx}"
+ const val espresso = "androidx.test.espresso:espresso-core:${Versions.espresso}"
+ const val mockitoKotlin = "org.mockito.kotlin:mockito-kotlin:${Versions.mockitoKotlin}"
+ const val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.coroutinesTest}"
+
+ //----------
+ const val composeUiTestManifest = "androidx.compose.ui:ui-test-manifest"
+ const val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4"
+ //----------
+ const val uiAnimatorTest = "androidx.test.uiautomator:uiautomator:${Versions.uiAnimatorTest}"
+ const val macroBenchMark = "androidx.benchmark:benchmark-macro-junit4:${Versions.macroBenchmark}"
+ }
+}
\ No newline at end of file
diff --git a/buildSrc/src/main/java/com/example/buildsrc/Versions.kt b/buildSrc/src/main/java/com/example/buildsrc/Versions.kt
new file mode 100644
index 0000000..8054328
--- /dev/null
+++ b/buildSrc/src/main/java/com/example/buildsrc/Versions.kt
@@ -0,0 +1,30 @@
+package com.example.buildsrc
+
+object Versions {
+ const val gson = "2.10.1"
+ const val hiltAndroid = "2.47"
+ const val hiltWork = "1.0.0"
+ const val hiltCompiler = "1.0.0"
+ const val hiltNavigationCompose = "1.0.0"
+ const val okHttp = "4.11.0"
+ const val retrofit = "2.9.0"
+ const val stetho = "1.6.0"
+ const val arch = "2.6.1"
+ const val arrow = "1.2.0"
+ const val mockitoKotlin = "5.0.0"
+ const val androidxCore = "1.10.1"
+ const val trace = "1.1.0"
+ const val activityCompose = "1.7.2"
+ const val composeBom = "2023.06.01"
+ const val coil = "2.4.0"
+ const val workRuntime = "2.8.1"
+ const val lifecycleRuntimeCompose = "2.6.1"
+ const val navigationCompose = "2.6.0"
+ const val junit = "4.13.2"
+ const val junitEx = "1.1.5"
+ const val espresso = "3.5.1"
+ const val material = "1.9.0"
+ const val coroutinesTest = "1.7.3"
+ const val uiAnimatorTest = "2.2.0"
+ const val macroBenchmark = "1.1.1"
+}
\ No newline at end of file
diff --git a/core/data/.gitignore b/core/data/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/data/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
new file mode 100644
index 0000000..fa7e777
--- /dev/null
+++ b/core/data/build.gradle.kts
@@ -0,0 +1,50 @@
+import com.example.buildsrc.Libs
+import com.example.buildsrc.Android
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ id("kotlin-kapt")
+}
+
+android {
+ namespace = "com.example.data"
+ compileSdk = Android.compileSdkVersion
+
+ defaultConfig {
+ minSdk = Android.minSdkVersion
+ targetSdk = Android.targetSdkVersion
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(project(Libs.Modules.domain))
+ implementation(Libs.Jetpack.hiltAndroid)
+ kapt(Libs.Jetpack.hiltAndroidCompiler)
+ implementation(Libs.Common.gson)
+ implementation(Libs.Common.retrofit)
+ implementation(Libs.Common.retrofitGson)
+ testImplementation(Libs.Testing.junit)
+ testImplementation(Libs.Testing.mockitoKotlin)
+ testImplementation(Libs.Testing.coroutinesTest)
+}
diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5918e6
--- /dev/null
+++ b/core/data/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/data/src/main/java/com/example/data/di/Cloud.kt b/core/data/src/main/java/com/example/data/di/Cloud.kt
new file mode 100644
index 0000000..0fea37d
--- /dev/null
+++ b/core/data/src/main/java/com/example/data/di/Cloud.kt
@@ -0,0 +1,7 @@
+package com.example.data.di
+
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Cloud
\ No newline at end of file
diff --git a/core/data/src/main/java/com/example/data/di/Mock.kt b/core/data/src/main/java/com/example/data/di/Mock.kt
new file mode 100644
index 0000000..f75397f
--- /dev/null
+++ b/core/data/src/main/java/com/example/data/di/Mock.kt
@@ -0,0 +1,7 @@
+package com.example.data.di
+
+import javax.inject.Qualifier
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+annotation class Mock
\ No newline at end of file
diff --git a/core/data/src/main/java/com/example/data/repository/GetUserDetailRepoImpl.kt b/core/data/src/main/java/com/example/data/repository/GetUserDetailRepoImpl.kt
new file mode 100644
index 0000000..e60fd07
--- /dev/null
+++ b/core/data/src/main/java/com/example/data/repository/GetUserDetailRepoImpl.kt
@@ -0,0 +1,16 @@
+package com.example.data.repository
+
+import com.example.data.di.Cloud
+import com.example.data.source.cloud.BaseCloudRepository
+import com.example.domain.model.userdetail.UserDetailModel
+import com.example.domain.repository.GetUserDetailRepository
+import javax.inject.Inject
+
+class GetUserDetailRepoImpl @Inject constructor(
+ @Cloud private val baseCloudRepository: BaseCloudRepository
+): GetUserDetailRepository {
+
+ override suspend fun getUserDetail(userName: String): UserDetailModel {
+ return baseCloudRepository.getUserDetail(userName)
+ }
+}
\ No newline at end of file
diff --git a/core/data/src/main/java/com/example/data/repository/GetUsersRepoImpl.kt b/core/data/src/main/java/com/example/data/repository/GetUsersRepoImpl.kt
new file mode 100644
index 0000000..5806669
--- /dev/null
+++ b/core/data/src/main/java/com/example/data/repository/GetUsersRepoImpl.kt
@@ -0,0 +1,15 @@
+package com.example.data.repository
+
+import com.example.data.di.Cloud
+import com.example.data.source.cloud.BaseCloudRepository
+import com.example.domain.model.userlist.UsersModel
+import com.example.domain.repository.GetUsersRepository
+import javax.inject.Inject
+
+class GetUsersRepoImpl @Inject constructor(
+ @Cloud private val baseCloudRepository: BaseCloudRepository): GetUsersRepository {
+
+ override suspend fun getUsersRepository(userName: String): UsersModel {
+ return baseCloudRepository.getUsers(userName)
+ }
+}
\ No newline at end of file
diff --git a/core/data/src/main/java/com/example/data/restful/API.kt b/core/data/src/main/java/com/example/data/restful/API.kt
new file mode 100644
index 0000000..84d7052
--- /dev/null
+++ b/core/data/src/main/java/com/example/data/restful/API.kt
@@ -0,0 +1,17 @@
+package com.example.data.restful
+
+import com.example.domain.model.userdetail.UserDetailModel
+import com.example.domain.model.userlist.UsersModel
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface API {
+
+ @GET("/search/users")
+ suspend fun getUsers(@Query("q") username: String): UsersModel
+
+ @GET("/users/{username}")
+ suspend fun getUserDetails(@Path("username") username: String): UserDetailModel
+
+}
\ No newline at end of file
diff --git a/core/data/src/main/java/com/example/data/source/cloud/BaseCloudRepository.kt b/core/data/src/main/java/com/example/data/source/cloud/BaseCloudRepository.kt
new file mode 100644
index 0000000..7a46c8f
--- /dev/null
+++ b/core/data/src/main/java/com/example/data/source/cloud/BaseCloudRepository.kt
@@ -0,0 +1,12 @@
+package com.example.data.source.cloud
+
+import com.example.domain.model.userdetail.UserDetailModel
+import com.example.domain.model.userlist.UsersModel
+
+interface BaseCloudRepository {
+
+ suspend fun getUsers(userName:String): UsersModel
+
+ suspend fun getUserDetail(userName: String): UserDetailModel
+
+}
\ No newline at end of file
diff --git a/core/data/src/main/java/com/example/data/source/cloud/CloudMockRepository.kt b/core/data/src/main/java/com/example/data/source/cloud/CloudMockRepository.kt
new file mode 100644
index 0000000..d3ceff4
--- /dev/null
+++ b/core/data/src/main/java/com/example/data/source/cloud/CloudMockRepository.kt
@@ -0,0 +1,15 @@
+package com.example.data.source.cloud
+
+import com.example.domain.model.userdetail.UserDetailModel
+import com.example.domain.model.userlist.UsersModel
+
+class CloudMockRepository: BaseCloudRepository {
+ override suspend fun getUsers(userName: String): UsersModel {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun getUserDetail(userName: String): UserDetailModel {
+ TODO("Not yet implemented")
+ }
+
+}
\ No newline at end of file
diff --git a/core/data/src/main/java/com/example/data/source/cloud/CloudRepository.kt b/core/data/src/main/java/com/example/data/source/cloud/CloudRepository.kt
new file mode 100644
index 0000000..3002a7d
--- /dev/null
+++ b/core/data/src/main/java/com/example/data/source/cloud/CloudRepository.kt
@@ -0,0 +1,16 @@
+package com.example.data.source.cloud
+
+import com.example.data.restful.API
+import com.example.domain.model.userdetail.UserDetailModel
+import com.example.domain.model.userlist.UsersModel
+
+class CloudRepository (private val api: API): BaseCloudRepository {
+ override suspend fun getUsers(userName: String): UsersModel {
+ return api.getUsers(userName)
+ }
+
+ override suspend fun getUserDetail(userName: String): UserDetailModel {
+ return api.getUserDetails(userName)
+ }
+
+}
\ No newline at end of file
diff --git a/core/domain/.gitignore b/core/domain/.gitignore
new file mode 100644
index 0000000..42afabf
--- /dev/null
+++ b/core/domain/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
new file mode 100644
index 0000000..5313e51
--- /dev/null
+++ b/core/domain/build.gradle.kts
@@ -0,0 +1,48 @@
+import com.example.buildsrc.Libs
+import com.example.buildsrc.Android
+
+plugins {
+ id("com.android.library")
+ id("org.jetbrains.kotlin.android")
+ id("kotlin-kapt")
+}
+
+android {
+ namespace = "com.example.domain"
+ compileSdk = Android.compileSdkVersion
+
+ defaultConfig {
+ minSdk = Android.minSdkVersion
+ targetSdk = Android.targetSdkVersion
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(Libs.Jetpack.hiltAndroid)
+ kapt(Libs.Jetpack.hiltAndroidCompiler)
+ implementation(Libs.Common.gson)
+ implementation(Libs.Common.arrowCore)
+ implementation(Libs.Common.retrofit)
+ testImplementation(Libs.Testing.junit)
+ testImplementation(Libs.Testing.mockitoKotlin)
+}
\ No newline at end of file
diff --git a/core/domain/src/main/AndroidManifest.xml b/core/domain/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a5918e6
--- /dev/null
+++ b/core/domain/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/mapper/ErrorMapper.kt b/core/domain/src/main/java/com/example/domain/mapper/ErrorMapper.kt
new file mode 100644
index 0000000..eb9ddc7
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/mapper/ErrorMapper.kt
@@ -0,0 +1,26 @@
+package com.example.domain.mapper
+
+import com.example.domain.model.error.Error
+import javax.inject.Inject
+
+class ErrorMapper @Inject constructor(private val httpErrorMapper: HttpErrorMapper) {
+
+ /**
+ * Generate an instance of [Error] from happened [Throwable]
+ * @param t Raised [Throwable]
+ *
+ * @return returns an instance of [Error]
+ */
+ fun getError(t: Throwable): Error {
+ // if connection was successful but no data received
+ if (t is NullPointerException) {
+ return Error.Null
+ }
+ val httpError = httpErrorMapper.mapToErrorModel(t)
+ if (httpError != null) {
+ return httpError
+ }
+ // something happened that we did not make our self ready for it
+ return Error.NotDefined(t)
+ }
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/mapper/HttpErrorMapper.kt b/core/domain/src/main/java/com/example/domain/mapper/HttpErrorMapper.kt
new file mode 100644
index 0000000..50850fd
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/mapper/HttpErrorMapper.kt
@@ -0,0 +1,40 @@
+package com.example.domain.mapper
+
+import com.example.domain.model.error.Error
+import com.example.domain.model.error.HttpError
+import com.google.gson.Gson
+import retrofit2.HttpException
+import java.io.IOException
+import java.net.SocketTimeoutException
+import javax.inject.Inject
+
+class HttpErrorMapper @Inject constructor(private val gson: Gson) {
+
+ /**
+ * Do not forget to manage canceledException for flows.
+ */
+ fun mapToErrorModel(throwable: Throwable): Error? {
+ return when (throwable) {
+ is HttpException -> {
+ getHttpError(throwable)
+ }
+ is SocketTimeoutException -> {
+ HttpError.TimeOut
+ }
+ is IOException -> {
+ HttpError.ConnectionFailed
+ }
+ else -> null
+ }
+ }
+
+ private fun getHttpError(httpException: HttpException): Error {
+ return when (val code = httpException.code()) {
+ 401 -> HttpError.UnAuthorized
+ else -> {
+ val errorBody = httpException.response()?.errorBody()
+ HttpError.InvalidResponse(code, errorBody?.string())
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/model/error/Error.kt b/core/domain/src/main/java/com/example/domain/model/error/Error.kt
new file mode 100644
index 0000000..46d40a3
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/model/error/Error.kt
@@ -0,0 +1,21 @@
+package com.example.domain.model.error
+
+sealed class Error {
+ /**
+ * an unexpected error
+ */
+ data class NotDefined(val throwable: Throwable) : Error()
+
+ object Null : Error()
+}
+
+sealed class HttpError : Error() {
+
+ object ConnectionFailed : HttpError()
+
+ data class InvalidResponse(val code: Int, val message: String?) : HttpError()
+
+ object TimeOut : HttpError()
+
+ object UnAuthorized : HttpError()
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/model/userdetail/UserDetailModel.kt b/core/domain/src/main/java/com/example/domain/model/userdetail/UserDetailModel.kt
new file mode 100644
index 0000000..041faad
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/model/userdetail/UserDetailModel.kt
@@ -0,0 +1,16 @@
+package com.example.domain.model.userdetail
+
+import com.google.gson.annotations.SerializedName
+
+data class UserDetailModel(
+ @SerializedName( "id")
+ val id: Int? = null,
+ @SerializedName( "avatar_url")
+ val avatarUrl: String? = null,
+ @SerializedName( "followers_url")
+ val followersUrl: String? = null,
+ @SerializedName( "following_url")
+ val followingUrl: String? = null,
+ @SerializedName( "gravatar_id")
+ val gravatarId: String? = null,
+)
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/model/userlist/UserModel.kt b/core/domain/src/main/java/com/example/domain/model/userlist/UserModel.kt
new file mode 100644
index 0000000..4b561e6
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/model/userlist/UserModel.kt
@@ -0,0 +1,16 @@
+package com.example.domain.model.userlist
+
+import com.google.gson.annotations.SerializedName
+
+data class UserModel(
+ @SerializedName( "id")
+ val id: Int,
+ @SerializedName( "avatar_url")
+ val avatarUrl: String? = null,
+ @SerializedName( "organizations_url")
+ val organizationsUrl: String? = null,
+ @SerializedName( "type")
+ val type: String? = null,
+ @SerializedName( "login")
+ val login: String? = null,
+)
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/model/userlist/UsersModel.kt b/core/domain/src/main/java/com/example/domain/model/userlist/UsersModel.kt
new file mode 100644
index 0000000..9df3f95
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/model/userlist/UsersModel.kt
@@ -0,0 +1,12 @@
+package com.example.domain.model.userlist
+
+import com.google.gson.annotations.SerializedName
+
+data class UsersModel(
+ @SerializedName("total_count")
+ val totalCount: Int? = null,
+ @SerializedName("incomplete_results")
+ val incompleteResult: Boolean? = null,
+ @SerializedName("items")
+ val users: List,
+)
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/repository/GetUserDetailRepository.kt b/core/domain/src/main/java/com/example/domain/repository/GetUserDetailRepository.kt
new file mode 100644
index 0000000..ea7d013
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/repository/GetUserDetailRepository.kt
@@ -0,0 +1,8 @@
+package com.example.domain.repository
+
+import com.example.domain.model.userdetail.UserDetailModel
+
+interface GetUserDetailRepository {
+
+ suspend fun getUserDetail(userName: String): UserDetailModel
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/repository/GetUsersRepository.kt b/core/domain/src/main/java/com/example/domain/repository/GetUsersRepository.kt
new file mode 100644
index 0000000..1d18e64
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/repository/GetUsersRepository.kt
@@ -0,0 +1,9 @@
+package com.example.domain.repository
+
+import com.example.domain.model.userlist.UsersModel
+
+interface GetUsersRepository {
+
+ suspend fun getUsersRepository(userName: String): UsersModel
+
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/usecase/BaseUseCase.kt b/core/domain/src/main/java/com/example/domain/usecase/BaseUseCase.kt
new file mode 100644
index 0000000..69c4e47
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/usecase/BaseUseCase.kt
@@ -0,0 +1,24 @@
+package com.example.domain.usecase
+
+import arrow.core.Either
+import com.example.domain.mapper.ErrorMapper
+import com.example.domain.model.error.Error
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+abstract class BaseUseCase(private val errorMapper: ErrorMapper) {
+
+ protected suspend fun safeApiCall(call: suspend () -> T): Either {
+ return withContext(Dispatchers.IO) {
+ getResult(call)
+ }
+ }
+
+ private suspend fun getResult(call: suspend () -> T): Either {
+ return try {
+ Either.Right(call.invoke())
+ } catch (t: Throwable) {
+ Either.Left(errorMapper.getError(t))
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/usecase/GetUserDetailUseCase.kt b/core/domain/src/main/java/com/example/domain/usecase/GetUserDetailUseCase.kt
new file mode 100644
index 0000000..8023189
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/usecase/GetUserDetailUseCase.kt
@@ -0,0 +1,20 @@
+package com.example.domain.usecase
+
+import arrow.core.Either
+import com.example.domain.mapper.ErrorMapper
+import com.example.domain.model.error.Error
+import com.example.domain.model.userdetail.UserDetailModel
+import com.example.domain.repository.GetUserDetailRepository
+import javax.inject.Inject
+
+class GetUserDetailUseCase @Inject constructor(
+ private val UserDetailRepository: GetUserDetailRepository,
+ errorMapper: ErrorMapper
+) : BaseUseCase(errorMapper) {
+
+ suspend fun getUserDetail(userName: String): Either {
+ return safeApiCall {
+ UserDetailRepository.getUserDetail(userName)
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/domain/src/main/java/com/example/domain/usecase/GetUsersUseCase.kt b/core/domain/src/main/java/com/example/domain/usecase/GetUsersUseCase.kt
new file mode 100644
index 0000000..51a0b18
--- /dev/null
+++ b/core/domain/src/main/java/com/example/domain/usecase/GetUsersUseCase.kt
@@ -0,0 +1,20 @@
+package com.example.domain.usecase
+
+import arrow.core.Either
+import com.example.domain.mapper.ErrorMapper
+import com.example.domain.model.error.Error
+import com.example.domain.model.userlist.UserModel
+import com.example.domain.repository.GetUsersRepository
+import javax.inject.Inject
+
+class GetUsersUseCase @Inject constructor(
+ private val repository: GetUsersRepository,
+ errorMapper: ErrorMapper
+) : BaseUseCase(errorMapper) {
+
+ suspend fun getUsers(userName: String): Either> {
+ return safeApiCall {
+ repository.getUsersRepository(userName)
+ }.map {it.users }
+ }
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 3c5031e..6920c05 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.buildconfig=true
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle.kts
similarity index 79%
rename from settings.gradle
rename to settings.gradle.kts
index b9746dd..c60ac0f 100644
--- a/settings.gradle
+++ b/settings.gradle.kts
@@ -13,4 +13,7 @@ dependencyResolutionManagement {
}
}
rootProject.name = "ComposeCodeChallenge"
-include ':app'
+include(":app")
+include(":core:domain")
+include(":core:data")
+include(":benchmark")