├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── jaydev │ │ └── github │ │ ├── App.kt │ │ ├── common │ │ └── extension.kt │ │ ├── di │ │ ├── AppModuleProvider.kt │ │ ├── DataSourceModule.kt │ │ ├── DomainModule.kt │ │ ├── NetworkModule.kt │ │ ├── RemoteModule.kt │ │ └── loader │ │ │ ├── CoroutineQualifiers.kt │ │ │ ├── CoroutineScopeModule.kt │ │ │ └── ResourcesProvider.kt │ │ ├── model │ │ ├── AlertUIModel.kt │ │ ├── PopupMessage.kt │ │ ├── RepoData.kt │ │ └── SearchListItem.kt │ │ ├── network │ │ └── NetworkConnectionInterceptor.kt │ │ ├── ui │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── view │ │ ├── MainActivity.kt │ │ ├── base │ │ ├── BaseActivity.kt │ │ ├── BaseViewModel.kt │ │ └── ContentLoadingProgress.kt │ │ ├── repo │ │ ├── RepoDetailActivity.kt │ │ ├── RepoDetailScreen.kt │ │ └── RepoDetailViewModel.kt │ │ ├── result │ │ ├── SearchResultActivity.kt │ │ ├── SearchResultScreen.kt │ │ └── SearchResultViewModel.kt │ │ └── search │ │ ├── SearchActivity.kt │ │ └── SearchScreen.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ └── ic_launcher_background.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ └── values │ ├── colors.xml │ ├── strings.xml │ └── themes.xml ├── build.gradle.kts ├── data ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── com │ └── jaydev │ └── github │ └── model │ ├── interactor │ ├── GithubDataRepository.kt │ └── NetErrorHandlerImpl.kt │ └── source │ └── GithubRemote.kt ├── domain ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── com │ └── jaydev │ └── github │ └── domain │ ├── NetResult.kt │ ├── entity │ ├── Fork.kt │ ├── NetError.kt │ ├── Repo.kt │ └── User.kt │ ├── interactor │ ├── ErrorHandler.kt │ ├── NetworkConnectException.kt │ └── usecase │ │ ├── BaseUseCase.kt │ │ └── GithubUseCase.kt │ └── repository │ └── GithubRepository.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── remote ├── .gitignore ├── build.gradle.kts └── src │ └── main │ └── java │ └── com │ └── jaydev │ └── github │ └── remote │ ├── GithubRemoteImpl.kt │ ├── GithubService.kt │ ├── mapper │ ├── EntityMapper.kt │ ├── ForkEntityMapper.kt │ ├── RepoEntityMapper.kt │ └── UserEntityMapper.kt │ └── model │ ├── ForkModel.kt │ ├── RepoModel.kt │ └── UserModel.kt └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/android,androidstudio,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=android,androidstudio,macos 3 | 4 | ### Android ### 5 | # Built application files 6 | *.apk 7 | *.aar 8 | *.ap_ 9 | *.aab 10 | 11 | # Files for the ART/Dalvik VM 12 | *.dex 13 | 14 | # Java class files 15 | *.class 16 | 17 | # Generated files 18 | bin/ 19 | gen/ 20 | out/ 21 | # Uncomment the following line in case you need and you don't have the release build type files in your app 22 | # release/ 23 | 24 | # Gradle files 25 | .gradle/ 26 | build/ 27 | 28 | # Local configuration file (sdk path, etc) 29 | local.properties 30 | 31 | # Proguard folder generated by Eclipse 32 | proguard/ 33 | 34 | # Log Files 35 | *.log 36 | 37 | # Android Studio Navigation editor temp files 38 | .navigation/ 39 | 40 | # Android Studio captures folder 41 | captures/ 42 | 43 | # IntelliJ 44 | *.iml 45 | .idea/workspace.xml 46 | .idea/tasks.xml 47 | .idea/gradle.xml 48 | .idea/assetWizardSettings.xml 49 | .idea/dictionaries 50 | .idea/libraries 51 | .idea/jarRepositories.xml 52 | # Android Studio 3 in .gitignore file. 53 | .idea/caches 54 | .idea/modules.xml 55 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 56 | .idea/navEditor.xml 57 | 58 | # Keystore files 59 | # Uncomment the following lines if you do not want to check your keystore files in. 60 | #*.jks 61 | #*.keystore 62 | 63 | # External native build folder generated in Android Studio 2.2 and later 64 | .externalNativeBuild 65 | .cxx/ 66 | 67 | # Google Services (e.g. APIs or Firebase) 68 | # google-services.json 69 | 70 | # Freeline 71 | freeline.py 72 | freeline/ 73 | freeline_project_description.json 74 | 75 | # fastlane 76 | fastlane/report.xml 77 | fastlane/Preview.html 78 | fastlane/screenshots 79 | fastlane/test_output 80 | fastlane/readme.md 81 | 82 | # Version control 83 | vcs.xml 84 | 85 | # lint 86 | lint/intermediates/ 87 | lint/generated/ 88 | lint/outputs/ 89 | lint/tmp/ 90 | # lint/reports/ 91 | 92 | # Android Profiling 93 | *.hprof 94 | 95 | ### Android Patch ### 96 | gen-external-apklibs 97 | output.json 98 | 99 | # Replacement of .externalNativeBuild directories introduced 100 | # with Android Studio 3.5. 101 | 102 | ### macOS ### 103 | # General 104 | .DS_Store 105 | .AppleDouble 106 | .LSOverride 107 | 108 | # Icon must end with two \r 109 | Icon 110 | 111 | 112 | # Thumbnails 113 | ._* 114 | 115 | # Files that might appear in the root of a volume 116 | .DocumentRevisions-V100 117 | .fseventsd 118 | .Spotlight-V100 119 | .TemporaryItems 120 | .Trashes 121 | .VolumeIcon.icns 122 | .com.apple.timemachine.donotpresent 123 | 124 | # Directories potentially created on remote AFP share 125 | .AppleDB 126 | .AppleDesktop 127 | Network Trash Folder 128 | Temporary Items 129 | .apdisk 130 | 131 | ### AndroidStudio ### 132 | # Covers files to be ignored for android development using Android Studio. 133 | 134 | # Built application files 135 | 136 | # Files for the ART/Dalvik VM 137 | 138 | # Java class files 139 | 140 | # Generated files 141 | 142 | # Gradle files 143 | .gradle 144 | 145 | # Signing files 146 | .signing/ 147 | 148 | # Local configuration file (sdk path, etc) 149 | 150 | # Proguard folder generated by Eclipse 151 | 152 | # Log Files 153 | 154 | # Android Studio 155 | /*/build/ 156 | /*/local.properties 157 | /*/out 158 | /*/*/build 159 | /*/*/production 160 | *.ipr 161 | *~ 162 | *.swp 163 | 164 | # Keystore files 165 | *.jks 166 | *.keystore 167 | 168 | # Google Services (e.g. APIs or Firebase) 169 | # google-services.json 170 | 171 | # Android Patch 172 | 173 | # External native build folder generated in Android Studio 2.2 and later 174 | 175 | # NDK 176 | obj/ 177 | 178 | # IntelliJ IDEA 179 | *.iws 180 | /out/ 181 | 182 | # User-specific configurations 183 | .idea/caches/ 184 | .idea/libraries/ 185 | .idea/shelf/ 186 | .idea/.name 187 | .idea/compiler.xml 188 | .idea/copyright/profiles_settings.xml 189 | .idea/encodings.xml 190 | .idea/misc.xml 191 | .idea/scopes/scope_settings.xml 192 | .idea/vcs.xml 193 | .idea/jsLibraryMappings.xml 194 | .idea/datasources.xml 195 | .idea/dataSources.ids 196 | .idea/sqlDataSources.xml 197 | .idea/dynamic.xml 198 | .idea/uiDesigner.xml 199 | 200 | # OS-specific files 201 | .DS_Store? 202 | ehthumbs.db 203 | Thumbs.db 204 | 205 | # Legacy Eclipse project files 206 | .classpath 207 | .project 208 | .cproject 209 | .settings/ 210 | 211 | # Mobile Tools for Java (J2ME) 212 | .mtj.tmp/ 213 | 214 | # Package Files # 215 | *.war 216 | *.ear 217 | 218 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) 219 | hs_err_pid* 220 | 221 | ## Plugin-specific files: 222 | 223 | # mpeltonen/sbt-idea plugin 224 | .idea_modules/ 225 | 226 | # JIRA plugin 227 | atlassian-ide-plugin.xml 228 | 229 | # Mongo Explorer plugin 230 | .idea/mongoSettings.xml 231 | 232 | # Crashlytics plugin (for Android Studio and IntelliJ) 233 | com_crashlytics_export_strings.xml 234 | crashlytics.properties 235 | crashlytics-build.properties 236 | fabric.properties 237 | 238 | ### AndroidStudio Patch ### 239 | 240 | !/gradle/wrapper/gradle-wrapper.jar 241 | 242 | # End of https://www.toptal.com/developers/gitignore/api/android,androidstudio,macos -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GithubBrowser Clean Architecture Sample 2 | ### Modern Clean Architecture + Retrofit(Kotlin FLow) 3 | 4 | 5 | * 본 샘플 프로젝트는 https://github.com/omjoonkim/GitHubBrowserApp 을 인용하였습니다. 6 | 7 | * 정석적인 Clean Architecture의 구조가 아님을 밝힙니다. 8 | 9 | * MVVM 브랜치 -> AAC + XML + MVVM + Clean Architecture 형태를 띄고 있습니다. 10 | * MVI 브랜치 -> AAC + Compose + Orbit MVI + Clean Architecture 형태를 띄고 있습니다. 11 | 12 | * Flow의 메소드 체이닝을 이용한 Retrofit Error Handling을 담고 있습니다. 13 | 14 | 15 | # 개발 환경 16 | 17 | * Android Studio Hedgehog | 2023.1.1 Canary 13 18 | 19 | * Android Gradle Plugin Version 8.2.0-alpha15 20 | 21 | * minSdk 24 22 | 23 | * targetSdk 33 24 | 25 | * Kotlin Version 1.8.10 26 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidApplication) 4 | alias(libs.plugins.kotlinAndroid) 5 | alias(libs.plugins.kotlinKapt) 6 | alias(libs.plugins.kotlinSerialization) 7 | alias(libs.plugins.hilt) 8 | alias(libs.plugins.kotlinParcelize) 9 | } 10 | 11 | android { 12 | namespace = "com.jaydev.github" 13 | compileSdk = 33 14 | 15 | defaultConfig { 16 | applicationId = "com.jaydev.github" 17 | minSdk = 24 18 | targetSdk = 33 19 | versionCode = 1 20 | versionName = "1.0" 21 | 22 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 23 | vectorDrawables { 24 | useSupportLibrary = true 25 | } 26 | } 27 | 28 | buildTypes { 29 | release { 30 | isMinifyEnabled = false 31 | proguardFiles( 32 | getDefaultProguardFile("proguard-android-optimize.txt"), 33 | "proguard-rules.pro" 34 | ) 35 | } 36 | } 37 | compileOptions { 38 | sourceCompatibility = JavaVersion.VERSION_17 39 | targetCompatibility = JavaVersion.VERSION_17 40 | } 41 | kotlinOptions { 42 | jvmTarget = "17" 43 | } 44 | 45 | buildFeatures { 46 | compose = true 47 | } 48 | composeOptions { 49 | kotlinCompilerExtensionVersion = "1.4.3" 50 | } 51 | packaging { 52 | resources { 53 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 54 | } 55 | } 56 | } 57 | 58 | dependencies { 59 | implementation(project(":domain")) 60 | implementation(project(":remote")) 61 | 62 | implementation(libs.core.ktx) 63 | implementation(libs.lifecycle.runtime.ktx) 64 | implementation(libs.activity.compose) 65 | implementation(platform(libs.compose.bom)) 66 | implementation(libs.ui) 67 | implementation(libs.ui.graphics) 68 | implementation(libs.ui.tooling.preview) 69 | implementation(libs.material3) 70 | implementation(libs.navigation.compose) 71 | implementation(libs.paging.compose) 72 | implementation(libs.constraintlayout.compose) 73 | testImplementation(libs.junit) 74 | androidTestImplementation(libs.androidx.test.ext.junit) 75 | androidTestImplementation(libs.espresso.core) 76 | androidTestImplementation(platform(libs.compose.bom)) 77 | androidTestImplementation(libs.ui.test.junit4) 78 | debugImplementation(libs.ui.tooling) 79 | debugImplementation(libs.ui.test.manifest) 80 | 81 | implementation(libs.lifecycle.viewmodel.ktx) 82 | implementation(libs.lifecycle.viewmodel.compose) 83 | implementation(libs.activity.ktx) 84 | 85 | implementation(libs.orbit.compose) 86 | implementation(libs.orbit.viewmodel) 87 | 88 | implementation(libs.hilt.android) 89 | implementation(libs.hilt.navigation.compose) 90 | 91 | implementation(libs.retrofit) 92 | implementation(libs.retrofit.serialization) 93 | 94 | implementation(platform(libs.okhttp.bom)) 95 | implementation(libs.okhttp) 96 | implementation(libs.okhttp.logging.interceptor) 97 | 98 | implementation(libs.kotlin.stdlib) 99 | implementation(libs.kotlin.reflect) 100 | implementation(libs.kotlinx.coroutines.android) 101 | implementation(libs.kotlinx.serialization.json) 102 | implementation(libs.kotlinx.datetime) 103 | 104 | implementation(libs.coil.compose) 105 | 106 | implementation(libs.logger) 107 | 108 | kapt(libs.hilt.compiler) 109 | kapt(libs.hilt.androidx.compiler) 110 | 111 | implementation("com.squareup:javapoet:1.13.0") 112 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/App.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github 2 | 3 | import android.app.Application 4 | import com.orhanobut.logger.AndroidLogAdapter 5 | import com.orhanobut.logger.Logger 6 | import com.orhanobut.logger.PrettyFormatStrategy 7 | import dagger.hilt.android.HiltAndroidApp 8 | 9 | @HiltAndroidApp 10 | class App : Application() { 11 | 12 | override fun onCreate() { 13 | super.onCreate() 14 | initLogger() 15 | } 16 | 17 | private fun initLogger() { 18 | PrettyFormatStrategy.newBuilder() 19 | .tag("JayDev") 20 | .build() 21 | .let { AndroidLogAdapter(it) } 22 | .also { 23 | Logger.addLogAdapter(it) 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/common/extension.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.common 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.res.Resources 6 | import android.os.Looper 7 | import android.view.View 8 | import android.view.inputmethod.InputMethodManager 9 | import androidx.annotation.CheckResult 10 | import androidx.annotation.Dimension 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.Job 13 | import kotlinx.coroutines.channels.awaitClose 14 | import kotlinx.coroutines.flow.Flow 15 | import kotlinx.coroutines.flow.callbackFlow 16 | import kotlinx.coroutines.flow.conflate 17 | 18 | @Dimension(unit = Dimension.PX) 19 | fun Int.toPx(): Int = (Resources.getSystem().displayMetrics.density * this).toInt() 20 | 21 | @Dimension(unit = Dimension.PX) 22 | fun Float.toPx() = (Resources.getSystem().displayMetrics.density * this).toInt() 23 | 24 | @Dimension(unit = Dimension.DP) 25 | fun Int.toDp(): Float = this / Resources.getSystem().displayMetrics.density 26 | 27 | 28 | @Dimension(unit = Dimension.PX) 29 | fun Context.dpToPx(@Dimension(unit = Dimension.DP) dp: Int): Int { 30 | return (resources.displayMetrics.density * dp).toInt() 31 | } 32 | 33 | @Dimension(unit = Dimension.PX) 34 | fun Context.dpToPx(@Dimension(unit = Dimension.DP) dp: Float): Int { 35 | return (resources.displayMetrics.density * dp).toInt() 36 | } 37 | 38 | @Dimension(unit = Dimension.DP) 39 | fun Context.pxToDp(@Dimension px: Int): Float { 40 | return px.toFloat() / resources.displayMetrics.density 41 | } 42 | 43 | inline fun T.ordinal(): Int { 44 | if (T::class.isSealed) { 45 | return T::class.java.classes.indexOfFirst { sub -> sub == javaClass } 46 | } 47 | 48 | val klass = if (T::class.isCompanion) { 49 | javaClass.declaringClass 50 | } else { 51 | javaClass 52 | } 53 | 54 | return klass.superclass?.classes?.indexOfFirst { it == klass } ?: -1 55 | } 56 | 57 | fun Job?.cancelIfActive() { 58 | if (this?.isActive == true) { 59 | cancel() 60 | } 61 | } 62 | 63 | fun View.showKeyboard(isForced: Boolean = false) { 64 | val inputMethodManager = 65 | context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager 66 | inputMethodManager.showSoftInput( 67 | this, 68 | if (isForced) InputMethodManager.SHOW_FORCED else InputMethodManager.SHOW_IMPLICIT 69 | ) 70 | } 71 | 72 | fun View.hideKeyboard() { 73 | val inputMethodManager = 74 | context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager 75 | inputMethodManager.hideSoftInputFromWindow(windowToken, 0) 76 | } 77 | 78 | @CheckResult 79 | @OptIn(ExperimentalCoroutinesApi::class) 80 | fun View.clicks(): Flow = callbackFlow { 81 | checkMainThread() 82 | val listener = View.OnClickListener { 83 | trySend(Unit) 84 | } 85 | setOnClickListener(listener) 86 | awaitClose { setOnClickListener(null) } 87 | }.conflate() 88 | 89 | fun checkMainThread() { 90 | check(Looper.myLooper() == Looper.getMainLooper()) { 91 | "Expected to be called on the main thread but was " + Thread.currentThread().name 92 | } 93 | } 94 | 95 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/di/AppModuleProvider.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.di 2 | 3 | import android.content.ContentResolver 4 | import android.content.Context 5 | import com.jaydev.github.di.loader.ResourcesProvider 6 | import com.jaydev.github.di.loader.ResourcesProviderImpl 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import javax.inject.Singleton 13 | 14 | @InstallIn(SingletonComponent::class) 15 | @Module 16 | object AppModuleProvider { 17 | 18 | @Provides 19 | @Singleton 20 | fun provideContentResolver( 21 | @ApplicationContext context: Context 22 | ): ContentResolver { 23 | return context.contentResolver 24 | } 25 | 26 | @Provides 27 | @Singleton 28 | fun provideResourceProvider( 29 | @ApplicationContext context: Context 30 | ): ResourcesProvider { 31 | return ResourcesProviderImpl(context) 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/di/DataSourceModule.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.di 2 | 3 | import com.jaydev.github.model.source.GithubRemote 4 | import com.jaydev.github.remote.GithubRemoteImpl 5 | import com.jaydev.github.remote.GithubService 6 | import com.jaydev.github.remote.mapper.ForkEntityMapper 7 | import com.jaydev.github.remote.mapper.RepoEntityMapper 8 | import com.jaydev.github.remote.mapper.UserEntityMapper 9 | import dagger.Module 10 | import dagger.Provides 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.components.SingletonComponent 13 | import javax.inject.Singleton 14 | 15 | @InstallIn(SingletonComponent::class) 16 | @Module 17 | object DataSourceModule { 18 | @Provides 19 | @Singleton 20 | fun provideGithubRemote( 21 | githubService: GithubService, 22 | userEntityMapper: UserEntityMapper, 23 | repoEntityMapper: RepoEntityMapper, 24 | forkEntityMapper: ForkEntityMapper 25 | ): GithubRemote { 26 | return GithubRemoteImpl( 27 | githubService, 28 | userEntityMapper, 29 | repoEntityMapper, 30 | forkEntityMapper 31 | ) 32 | } 33 | 34 | @Provides 35 | @Singleton 36 | fun provideRepoEntityMapper() = RepoEntityMapper() 37 | 38 | @Provides 39 | @Singleton 40 | fun provideUserEntityMapper() = UserEntityMapper() 41 | 42 | @Provides 43 | @Singleton 44 | fun provideForkEntityMapper(userEntityMapper: UserEntityMapper) = 45 | ForkEntityMapper(userEntityMapper) 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/di/DomainModule.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.di 2 | 3 | import com.jaydev.github.domain.interactor.ErrorHandler 4 | import com.jaydev.github.domain.repository.GithubRepository 5 | import com.jaydev.github.model.interactor.GithubDataRepository 6 | import com.jaydev.github.model.interactor.NetErrorHandlerImpl 7 | import com.jaydev.github.model.source.GithubRemote 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.InstallIn 11 | import dagger.hilt.components.SingletonComponent 12 | import retrofit2.Retrofit 13 | import javax.inject.Singleton 14 | 15 | @InstallIn(SingletonComponent::class) 16 | @Module 17 | object DomainModule { 18 | 19 | @Provides 20 | @Singleton 21 | fun provideGithubRepository(githubRemote: GithubRemote): GithubRepository { 22 | return GithubDataRepository(githubRemote) 23 | } 24 | 25 | @Provides 26 | @Singleton 27 | fun provideErrorHandler(retrofit: Retrofit): ErrorHandler { 28 | return NetErrorHandlerImpl(retrofit) 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.di 2 | 3 | import android.content.Context 4 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 5 | import com.jaydev.github.network.NetworkConnectionInterceptor 6 | import com.orhanobut.logger.Logger 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import kotlinx.serialization.SerializationException 13 | import kotlinx.serialization.json.Json 14 | import kotlinx.serialization.json.JsonElement 15 | import okhttp3.MediaType.Companion.toMediaType 16 | import okhttp3.OkHttpClient 17 | import okhttp3.logging.HttpLoggingInterceptor 18 | import retrofit2.Retrofit 19 | import java.util.concurrent.TimeUnit 20 | import javax.inject.Singleton 21 | 22 | @InstallIn(SingletonComponent::class) 23 | @Module 24 | object NetworkModule { 25 | private const val TAG = "OkHttp" 26 | private const val connectTimeout = 20L 27 | private const val writeTimeout = 20L 28 | private const val readTimeout = 20L 29 | 30 | @Provides 31 | @Singleton 32 | fun provideNetworkConnectionInterceptor(@ApplicationContext context: Context): NetworkConnectionInterceptor { 33 | return NetworkConnectionInterceptor(context) 34 | } 35 | 36 | @Provides 37 | @Singleton 38 | fun provideLoggingInterceptor(): HttpLoggingInterceptor { 39 | return HttpLoggingInterceptor(PrettyPrintLogger()).apply { 40 | level = HttpLoggingInterceptor.Level.BODY 41 | } 42 | } 43 | 44 | @Provides 45 | @Singleton 46 | fun provideOkHttpClient( 47 | networkConnectionInterceptor: NetworkConnectionInterceptor, 48 | loggingInterceptor: HttpLoggingInterceptor, 49 | ): OkHttpClient { 50 | return OkHttpClient.Builder() 51 | .addInterceptor(networkConnectionInterceptor) 52 | .addInterceptor(loggingInterceptor) 53 | .connectTimeout(connectTimeout, TimeUnit.SECONDS) 54 | .writeTimeout(writeTimeout, TimeUnit.SECONDS) 55 | .readTimeout(readTimeout, TimeUnit.SECONDS) 56 | .build() 57 | } 58 | 59 | @Provides 60 | @Singleton 61 | fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { 62 | val contentType = "application/json".toMediaType() 63 | val json = Json { 64 | ignoreUnknownKeys = true 65 | prettyPrint = true 66 | isLenient = true 67 | } 68 | 69 | return Retrofit.Builder() 70 | .client(okHttpClient) 71 | .baseUrl("https://api.github.com") 72 | .addConverterFactory(json.asConverterFactory(contentType)) 73 | .callFactory { okHttpClient.newCall(it) } 74 | .build() 75 | } 76 | 77 | private class PrettyPrintLogger : HttpLoggingInterceptor.Logger { 78 | override fun log(message: String) { 79 | Logger.t(TAG).run { 80 | try { 81 | val json = Json { 82 | prettyPrint = true 83 | ignoreUnknownKeys = true 84 | isLenient = true 85 | } 86 | json.decodeFromString(message) 87 | json(message) 88 | } catch (ignore: SerializationException) { 89 | i(message) 90 | } catch (ignore: IllegalArgumentException) { 91 | i(message) 92 | } catch (e: Exception) { 93 | e.printStackTrace() 94 | } 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/di/RemoteModule.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.di 2 | 3 | import com.jaydev.github.remote.GithubService 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import retrofit2.Retrofit 9 | import javax.inject.Singleton 10 | 11 | @InstallIn(SingletonComponent::class) 12 | @Module 13 | object RemoteModule { 14 | @Provides 15 | @Singleton 16 | fun provideGithubService(retrofit: Retrofit): GithubService { 17 | return retrofit.create(GithubService::class.java) 18 | } 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/di/loader/CoroutineQualifiers.kt: -------------------------------------------------------------------------------- 1 | package kr.co.plat.carplat.biz.di 2 | 3 | import javax.inject.Qualifier 4 | 5 | @Retention(AnnotationRetention.RUNTIME) 6 | @Qualifier 7 | annotation class DefaultDispatcher 8 | 9 | @Retention(AnnotationRetention.RUNTIME) 10 | @Qualifier 11 | annotation class IoDispatcher 12 | 13 | @Retention(AnnotationRetention.RUNTIME) 14 | @Qualifier 15 | annotation class MainDispatcher 16 | 17 | @Retention(AnnotationRetention.BINARY) 18 | @Qualifier 19 | annotation class MainImmediateDispatcher 20 | 21 | @Retention(AnnotationRetention.RUNTIME) 22 | @Qualifier 23 | annotation class ApplicationScope 24 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/di/loader/CoroutineScopeModule.kt: -------------------------------------------------------------------------------- 1 | package kr.co.plat.carplat.biz.di 2 | 3 | import android.app.Activity 4 | import androidx.activity.ComponentActivity 5 | import androidx.lifecycle.LifecycleCoroutineScope 6 | import androidx.lifecycle.lifecycleScope 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.components.ActivityComponent 11 | import dagger.hilt.components.SingletonComponent 12 | import kotlinx.coroutines.CoroutineDispatcher 13 | import kotlinx.coroutines.CoroutineScope 14 | import kotlinx.coroutines.SupervisorJob 15 | import javax.inject.Singleton 16 | 17 | @Module 18 | @InstallIn(ActivityComponent::class) 19 | object ActivityScopeModule { 20 | @Provides 21 | fun provideLifecycleCoroutineScope(activity: Activity): LifecycleCoroutineScope = 22 | (activity as ComponentActivity).lifecycleScope 23 | } 24 | 25 | @Module 26 | @InstallIn(SingletonComponent::class) 27 | object CoroutinesScopesModule { 28 | @Singleton 29 | @ApplicationScope 30 | @Provides 31 | fun providesCoroutineScope( 32 | @DefaultDispatcher defaultDispatcher: CoroutineDispatcher 33 | ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher) 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/di/loader/ResourcesProvider.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.di.loader 2 | 3 | import android.content.Context 4 | import android.graphics.drawable.Drawable 5 | import androidx.annotation.ColorInt 6 | import androidx.annotation.ColorRes 7 | import androidx.annotation.DrawableRes 8 | import androidx.annotation.StringRes 9 | 10 | interface ResourcesProvider { 11 | fun getDrawable(@DrawableRes res: Int): Drawable? 12 | 13 | @ColorInt 14 | fun getColor(@ColorRes res: Int): Int 15 | fun getString(@StringRes res: Int): String 16 | fun getString(@StringRes resId: Int, vararg formatArgs: Any): String 17 | 18 | fun loadImage(url: String) 19 | suspend fun getDrawable(url: String?): Drawable? 20 | } 21 | 22 | class ResourcesProviderImpl(private val context: Context) : ResourcesProvider { 23 | override fun getDrawable(res: Int) = context.getDrawable(res) 24 | 25 | override fun getColor(res: Int) = context.resources.getColor(res) 26 | 27 | override fun getString(res: Int) = context.getString(res) 28 | 29 | override fun getString(@StringRes resId: Int, vararg formatArgs: Any) = 30 | context.getString(resId, *formatArgs) 31 | 32 | override fun loadImage(url: String) { 33 | TODO("Not yet implemented") 34 | } 35 | 36 | override suspend fun getDrawable(url: String?): Drawable? { 37 | TODO("Not yet implemented") 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/model/AlertUIModel.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.model 2 | 3 | sealed class AlertUIModel { 4 | data class Dialog( 5 | val title: String? = null, 6 | val message: CharSequence, 7 | val positiveButton: String? = null, 8 | val negativeButton: String? = null, 9 | val isButtonRed: Boolean = false 10 | ) : AlertUIModel() 11 | 12 | data class Toast( 13 | val message: String 14 | ) : AlertUIModel() 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/model/PopupMessage.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.model 2 | 3 | data class PopupMessage( 4 | val title: String, 5 | val message: String 6 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/model/RepoData.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.model 2 | 3 | data class RepoData( 4 | val userName: String, 5 | val repoName: String 6 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/model/SearchListItem.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.model 2 | 3 | import com.jaydev.github.domain.entity.Repo 4 | import com.jaydev.github.domain.entity.User 5 | 6 | sealed class SearchListItem { 7 | data class Header(val user: User) : SearchListItem() { 8 | companion object 9 | } 10 | 11 | data class RepoItem(val repo: Repo) : SearchListItem() { 12 | companion object 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/network/NetworkConnectionInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.network 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | import android.net.NetworkCapabilities 6 | import com.jaydev.github.domain.interactor.NetworkConnectException 7 | import okhttp3.Interceptor 8 | import okhttp3.Response 9 | import java.io.IOException 10 | import javax.inject.Inject 11 | 12 | class NetworkConnectionInterceptor @Inject constructor(context: Context) : Interceptor { 13 | private val applicationContext = context.applicationContext 14 | 15 | private val isConnected: Boolean 16 | get() { 17 | val result: Boolean 18 | val connectivityManager = 19 | applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 20 | val activeNetwork = connectivityManager.activeNetwork ?: return false 21 | val capabilities = 22 | connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false 23 | result = when { 24 | capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true 25 | capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true 26 | capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true 27 | else -> false 28 | } 29 | 30 | return result 31 | } 32 | 33 | @Throws(IOException::class) 34 | override fun intercept(chain: Interceptor.Chain): Response { 35 | if (isConnected) { 36 | val newRequest = chain.request().newBuilder() 37 | return chain.proceed(newRequest.build()) 38 | } else { 39 | throw NetworkConnectException() 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.SideEffect 13 | import androidx.compose.ui.graphics.toArgb 14 | import androidx.compose.ui.platform.LocalContext 15 | import androidx.compose.ui.platform.LocalView 16 | import androidx.core.view.WindowCompat 17 | 18 | private val DarkColorScheme = darkColorScheme( 19 | primary = Purple80, 20 | secondary = PurpleGrey80, 21 | tertiary = Pink80 22 | ) 23 | 24 | private val LightColorScheme = lightColorScheme( 25 | primary = Purple40, 26 | secondary = PurpleGrey40, 27 | tertiary = Pink40 28 | 29 | /* Other default colors to override 30 | background = Color(0xFFFFFBFE), 31 | surface = Color(0xFFFFFBFE), 32 | onPrimary = Color.White, 33 | onSecondary = Color.White, 34 | onTertiary = Color.White, 35 | onBackground = Color(0xFF1C1B1F), 36 | onSurface = Color(0xFF1C1B1F), 37 | */ 38 | ) 39 | 40 | @Composable 41 | fun GithubBrowserTheme( 42 | darkTheme: Boolean = isSystemInDarkTheme(), 43 | // Dynamic color is available on Android 12+ 44 | dynamicColor: Boolean = true, 45 | content: @Composable () -> Unit 46 | ) { 47 | val colorScheme = when { 48 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 49 | val context = LocalContext.current 50 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 51 | } 52 | 53 | darkTheme -> DarkColorScheme 54 | else -> LightColorScheme 55 | } 56 | val view = LocalView.current 57 | if (!view.isInEditMode) { 58 | SideEffect { 59 | val window = (view.context as Activity).window 60 | window.statusBarColor = colorScheme.primary.toArgb() 61 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme 62 | } 63 | } 64 | 65 | MaterialTheme( 66 | colorScheme = colorScheme, 67 | typography = Typography, 68 | content = content 69 | ) 70 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view 2 | 3 | import android.os.Bundle 4 | import android.os.PersistableBundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.compose.setContent 7 | import com.jaydev.github.view.search.SearchScreen 8 | 9 | class MainActivity : ComponentActivity() { 10 | 11 | override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { 12 | super.onCreate(savedInstanceState, persistentState) 13 | setContent { 14 | SearchScreen() 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/base/BaseActivity.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.base 2 | 3 | import android.app.AlertDialog 4 | import android.os.Bundle 5 | import android.os.PersistableBundle 6 | import android.widget.Toast 7 | import androidx.activity.ComponentActivity 8 | import androidx.activity.OnBackPressedCallback 9 | 10 | open class BaseActivity : ComponentActivity() { 11 | 12 | override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { 13 | super.onCreate(savedInstanceState, persistentState) 14 | onBackPressedDispatcher.addCallback(this, onBackPressedCallback) 15 | } 16 | 17 | private val onBackPressedCallback = object : OnBackPressedCallback(true) { 18 | override fun handleOnBackPressed() { 19 | finishAfterTransition() 20 | } 21 | } 22 | 23 | protected open fun showActionDialog( 24 | title: String?, 25 | message: CharSequence?, 26 | action: InvokableAction 27 | ) { 28 | AlertDialog.Builder(this) 29 | .setTitle(title) 30 | .setMessage(message) 31 | .setPositiveButton( 32 | android.R.string.ok 33 | ) { dialog, _ -> 34 | action.invoke() 35 | dialog.dismiss() 36 | }.show() 37 | } 38 | 39 | protected open fun showAlertDialog(title: String?, message: CharSequence?) { 40 | AlertDialog.Builder(this) 41 | .setTitle(title) 42 | .setMessage(message) 43 | .setPositiveButton( 44 | android.R.string.ok 45 | ) { dialog, _ -> 46 | dialog.dismiss() 47 | }.show() 48 | } 49 | 50 | protected fun showToast(message: String) { 51 | Toast.makeText(this, message, Toast.LENGTH_SHORT).show() 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/base/BaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.base 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.jaydev.github.domain.NetResult 6 | import com.jaydev.github.domain.entity.NetError 7 | import com.jaydev.github.model.AlertUIModel 8 | import com.orhanobut.logger.Logger 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.MutableSharedFlow 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.asSharedFlow 13 | import kotlinx.coroutines.flow.asStateFlow 14 | import kotlinx.coroutines.flow.cancellable 15 | import kotlinx.coroutines.flow.launchIn 16 | import kotlinx.coroutines.flow.onCompletion 17 | import kotlinx.coroutines.flow.onEach 18 | import kotlinx.coroutines.flow.onStart 19 | import kotlinx.coroutines.launch 20 | 21 | typealias InvokableAction = () -> Unit 22 | 23 | abstract class BaseViewModel : ViewModel() { 24 | private val loadingProgress = ContentLoadingProgress(viewModelScope) 25 | 26 | private val _loading = MutableStateFlow(false) 27 | val loading = _loading.asStateFlow() 28 | 29 | private val _showToast = MutableSharedFlow() 30 | val showToast = _showToast.asSharedFlow() 31 | 32 | private val _showAlertDialog = MutableSharedFlow() 33 | val showAlertDialog = _showAlertDialog.asSharedFlow() 34 | 35 | private val _showActionDialog = MutableSharedFlow>() 36 | val showActionDialog = _showActionDialog.asSharedFlow() 37 | 38 | private val _navigateToBack = MutableSharedFlow() 39 | val navigateToBack = _navigateToBack.asSharedFlow() 40 | 41 | protected fun navigateToBack() { 42 | viewModelScope.launch { 43 | _navigateToBack.emit(Unit) 44 | } 45 | } 46 | 47 | protected fun showAlert(alert: AlertUIModel, action: InvokableAction? = null) { 48 | when (alert) { 49 | is AlertUIModel.Dialog -> { 50 | if (action != null) showActionDialog(alert, action) 51 | else showAlertDialog(alert) 52 | } 53 | 54 | is AlertUIModel.Toast -> { 55 | showToast(alert.message) 56 | } 57 | } 58 | } 59 | 60 | protected fun showToast(message: String) { 61 | viewModelScope.launch { 62 | _showToast.emit(message) 63 | } 64 | } 65 | 66 | protected fun showActionDialog(alertDialog: AlertUIModel.Dialog, action: InvokableAction) { 67 | viewModelScope.launch { 68 | _showActionDialog.emit(Pair(alertDialog, action)) 69 | } 70 | } 71 | 72 | protected fun showAlertDialog(alertDialog: AlertUIModel.Dialog) { 73 | viewModelScope.launch { 74 | _showAlertDialog.emit(alertDialog) 75 | } 76 | } 77 | 78 | protected fun showAlertDialog(title: String, message: CharSequence) { 79 | viewModelScope.launch { 80 | _showAlertDialog.emit(AlertUIModel.Dialog(title, message)) 81 | } 82 | } 83 | 84 | fun progress(isLoading: Boolean) { 85 | _loading.value = isLoading 86 | } 87 | 88 | protected fun Flow>.onSuccess( 89 | success: (suspend (T) -> Unit)? = null 90 | ) = onEach { 91 | if (it is NetResult.Success) success?.invoke(it.response) 92 | } 93 | 94 | protected fun Flow>.onFailure( 95 | failure: (suspend (NetError.BadRequest) -> Unit)? = null 96 | ) = onEach { 97 | if (it is NetResult.Error && it.error is NetError.BadRequest) { 98 | failure?.invoke(it.error as NetError.BadRequest) 99 | } 100 | } 101 | 102 | protected fun Flow>.commonErrorHandler( 103 | action: InvokableAction = this@BaseViewModel::navigateToBack 104 | ) = onEach { 105 | handleError(it, action) 106 | } 107 | 108 | private fun handleError(result: NetResult<*>, action: InvokableAction) { 109 | if (result !is NetResult.Error) return 110 | viewModelScope.launch { 111 | when (result.error) { 112 | NetError.Network -> { 113 | val title = "네트워크 에러" 114 | val message = "인터넷 연결 안됨." 115 | 116 | _showActionDialog.emit(Pair(AlertUIModel.Dialog(title, message), action)) 117 | } 118 | 119 | is NetError.InternalServer -> { 120 | val (errorCode, errorMessage) = result.error as NetError.InternalServer 121 | 122 | val title = "네트워크 에러" 123 | val message = "서버 에러\n" + 124 | "code: $errorCode\n" + 125 | "message: $errorMessage" 126 | _showActionDialog.emit(Pair(AlertUIModel.Dialog(title, message), action)) 127 | } 128 | 129 | is NetError.Timeout -> { 130 | val title = "네트워크 에러" 131 | val message = "타임 아웃." 132 | _showActionDialog.emit(Pair(AlertUIModel.Dialog(title, message), action)) 133 | } 134 | 135 | is NetError.Unknown -> { 136 | val title = "네트워크 에러" 137 | val message = "알 수 없는 에러." 138 | _showAlertDialog.emit(AlertUIModel.Dialog(title, message)) 139 | 140 | val throwable = (result.error as NetError.Unknown).throwable 141 | Logger.e(throwable, "Unknown Error") 142 | } 143 | 144 | is NetError.BadRequest -> { 145 | // no-op 146 | } 147 | } 148 | } 149 | } 150 | 151 | protected fun Flow>.call() = cancellable().launchIn(viewModelScope) 152 | 153 | protected fun Flow>.load( 154 | loading: (Boolean) -> Unit 155 | ) = onStart { 156 | loading.invoke(true) 157 | }.onCompletion { 158 | loading.invoke(false) 159 | }.cancellable() 160 | .launchIn(viewModelScope) 161 | 162 | protected fun Flow>.load( 163 | loading: MutableStateFlow = _loading 164 | ) = onStart { 165 | loadingProgress.handleContentLoading(loading, true) 166 | }.onCompletion { 167 | loadingProgress.handleContentLoading(loading, false) 168 | }.cancellable() 169 | .launchIn(viewModelScope) 170 | } 171 | -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/base/ContentLoadingProgress.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.base 2 | 3 | import com.jaydev.github.common.cancelIfActive 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.delay 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.launch 9 | 10 | class ContentLoadingProgress( 11 | val coroutineScope: CoroutineScope 12 | ) { 13 | companion object { 14 | private const val MIN_LOADING_TIME = 1000L 15 | private const val MIN_LOADING_DELAY = 500L 16 | } 17 | 18 | private var postedLoadingHide = false 19 | private var postedLoadingShow = false 20 | private var loadingDismissed = false 21 | private var startTime: Long = -1 22 | 23 | private var delayedHideJob: Job? = null 24 | private var delayedShowJob: Job? = null 25 | 26 | suspend fun handleContentLoading( 27 | loading: MutableStateFlow, 28 | isLoading: Boolean 29 | ) { 30 | if (isLoading) showProgress(loading) else hideProgress(loading) 31 | } 32 | 33 | suspend fun showProgress(loading: MutableStateFlow) { 34 | startTime = -1 35 | loadingDismissed = false 36 | delayedHideJob?.cancelIfActive() 37 | postedLoadingHide = false 38 | if (!postedLoadingShow) { 39 | delayedShowJob?.cancelIfActive() 40 | delayedShowJob = coroutineScope.launch { 41 | delay(MIN_LOADING_DELAY) 42 | postedLoadingShow = false 43 | if (!loadingDismissed) { 44 | startTime = System.currentTimeMillis() 45 | loading.value = true 46 | } 47 | } 48 | postedLoadingShow = true 49 | } 50 | } 51 | 52 | suspend fun hideProgress(loading: MutableStateFlow) { 53 | loadingDismissed = true 54 | delayedShowJob?.cancelIfActive() 55 | postedLoadingShow = false 56 | val diff: Long = System.currentTimeMillis() - startTime 57 | if (diff >= MIN_LOADING_TIME || startTime == -1L) { 58 | loading.value = false 59 | } else { 60 | if (!postedLoadingHide) { 61 | delayedHideJob?.cancelIfActive() 62 | delayedHideJob = coroutineScope.launch { 63 | delay(MIN_LOADING_TIME - diff) 64 | postedLoadingHide = false 65 | startTime = -1 66 | loading.value = false 67 | } 68 | postedLoadingHide = true 69 | } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/repo/RepoDetailActivity.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.repo 2 | 3 | import android.content.Context 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.viewModels 8 | import androidx.lifecycle.lifecycleScope 9 | import com.jaydev.github.view.base.BaseActivity 10 | import dagger.hilt.android.AndroidEntryPoint 11 | import kotlinx.coroutines.flow.launchIn 12 | import kotlinx.coroutines.flow.onEach 13 | 14 | @AndroidEntryPoint 15 | class RepoDetailActivity : BaseActivity() { 16 | private val viewModel by viewModels() 17 | 18 | override fun onCreate(savedInstanceState: Bundle?) { 19 | super.onCreate(savedInstanceState) 20 | setContent { 21 | RepoDetailScreen(viewModel = viewModel) 22 | } 23 | 24 | subscribeUI(viewModel) 25 | } 26 | 27 | private fun subscribeUI(viewModel: RepoDetailViewModel) { 28 | with(viewModel) { 29 | 30 | showToast.onEach { 31 | showToast(it) 32 | }.launchIn(lifecycleScope) 33 | 34 | showAlertDialog.onEach { 35 | showAlertDialog(it.title, it.message) 36 | }.launchIn(lifecycleScope) 37 | 38 | showActionDialog.onEach { 39 | showActionDialog(it.first.title, it.first.message, it.second) 40 | }.launchIn(lifecycleScope) 41 | 42 | navigateToBack.onEach { 43 | onBackPressedDispatcher.onBackPressed() 44 | }.launchIn(lifecycleScope) 45 | } 46 | } 47 | 48 | 49 | companion object { 50 | private const val EXTRA_USER_NAME = "userName" 51 | private const val EXTRA_REPO_NAME = "repoName" 52 | 53 | fun start(context: Context, userName: String, repoName: String) { 54 | context.startActivity( 55 | Intent(context, RepoDetailActivity::class.java).apply { 56 | putExtra(EXTRA_USER_NAME, userName) 57 | putExtra(EXTRA_REPO_NAME, repoName) 58 | } 59 | ) 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/repo/RepoDetailScreen.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.repo 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 5 | import androidx.compose.foundation.layout.WindowInsets 6 | import androidx.compose.foundation.layout.WindowInsetsSides 7 | import androidx.compose.foundation.layout.consumeWindowInsets 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.only 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.safeDrawing 12 | import androidx.compose.foundation.layout.size 13 | import androidx.compose.foundation.layout.windowInsetsPadding 14 | import androidx.compose.foundation.layout.wrapContentHeight 15 | import androidx.compose.foundation.lazy.LazyColumn 16 | import androidx.compose.foundation.lazy.items 17 | import androidx.compose.material3.Card 18 | import androidx.compose.material3.ExperimentalMaterial3Api 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Scaffold 21 | import androidx.compose.material3.Text 22 | import androidx.compose.material3.TopAppBar 23 | import androidx.compose.material3.TopAppBarDefaults 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.collectAsState 26 | import androidx.compose.ui.Modifier 27 | import androidx.compose.ui.text.font.FontWeight 28 | import androidx.compose.ui.unit.dp 29 | import androidx.constraintlayout.compose.ConstraintLayout 30 | import coil.compose.AsyncImage 31 | import com.jaydev.github.domain.entity.Fork 32 | import com.jaydev.github.ui.theme.GithubBrowserTheme 33 | 34 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) 35 | @Composable 36 | fun RepoDetailScreen(viewModel: RepoDetailViewModel) { 37 | GithubBrowserTheme { 38 | Scaffold( 39 | topBar = { 40 | 41 | val title = viewModel.title.collectAsState().value 42 | TopAppBar( 43 | title = { 44 | Text(text = title) 45 | }, 46 | colors = TopAppBarDefaults 47 | .topAppBarColors(containerColor = MaterialTheme.colorScheme.primary) 48 | ) 49 | } 50 | ) { padding -> 51 | Card( 52 | modifier = Modifier 53 | .padding(padding) 54 | .consumeWindowInsets(padding) 55 | .windowInsetsPadding( 56 | WindowInsets.safeDrawing.only( 57 | WindowInsetsSides.Horizontal, 58 | ), 59 | ) 60 | .padding(16.dp) 61 | ) { 62 | ConstraintLayout( 63 | modifier = Modifier 64 | .padding(16.dp) 65 | .fillMaxWidth() 66 | .wrapContentHeight() 67 | ) { 68 | val (repoName, description, starCount, forks) = createRefs() 69 | 70 | Text( 71 | text = viewModel.repoName.collectAsState().value, 72 | fontWeight = FontWeight.Bold, 73 | modifier = Modifier.constrainAs(repoName) { 74 | top.linkTo(parent.top) 75 | start.linkTo(parent.start) 76 | } 77 | ) 78 | 79 | Text( 80 | text = viewModel.description.collectAsState().value, 81 | modifier = Modifier.constrainAs(description) { 82 | top.linkTo(repoName.bottom, 8.dp) 83 | start.linkTo(parent.start) 84 | } 85 | ) 86 | 87 | Text( 88 | text = viewModel.starCount.collectAsState().value, 89 | color = MaterialTheme.colorScheme.secondary, 90 | modifier = Modifier.constrainAs(starCount) { 91 | top.linkTo(parent.top) 92 | end.linkTo(parent.end) 93 | } 94 | ) 95 | 96 | val lists = viewModel.refreshForks.collectAsState().value 97 | LazyColumn( 98 | modifier = Modifier.constrainAs(forks) { 99 | top.linkTo(description.bottom, 8.dp) 100 | start.linkTo(parent.start) 101 | end.linkTo(parent.end) 102 | bottom.linkTo(parent.bottom) 103 | }, 104 | verticalArrangement = Arrangement.spacedBy(8.dp) 105 | ) { 106 | items(lists) { item -> 107 | ForkItem(item) 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | @Composable 117 | private fun ForkItem(item: Fork) { 118 | ConstraintLayout(modifier = Modifier.padding(16.dp)) { 119 | val (forkName, userName, profileImage) = createRefs() 120 | 121 | Text( 122 | text = item.fullName, 123 | modifier = Modifier 124 | .constrainAs(forkName) { 125 | top.linkTo(parent.top) 126 | start.linkTo(parent.start) 127 | end.linkTo(parent.end) 128 | } 129 | ) 130 | AsyncImage( 131 | model = item.owner.profileImageUrl, 132 | contentDescription = "User Profile Image", 133 | modifier = Modifier 134 | .size(32.dp) 135 | .constrainAs(profileImage) { 136 | top.linkTo(forkName.bottom) 137 | start.linkTo(parent.start) 138 | } 139 | ) 140 | Text( 141 | text = item.name, 142 | modifier = Modifier 143 | .constrainAs(userName) { 144 | top.linkTo(forkName.bottom) 145 | start.linkTo(profileImage.end) 146 | } 147 | ) 148 | } 149 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/repo/RepoDetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.repo 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import com.jaydev.github.domain.entity.Fork 5 | import com.jaydev.github.domain.interactor.usecase.GetRepoDetailUseCase 6 | import com.jaydev.github.model.AlertUIModel 7 | import com.jaydev.github.view.base.BaseViewModel 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import javax.inject.Inject 12 | 13 | @HiltViewModel 14 | class RepoDetailViewModel @Inject constructor( 15 | handle: SavedStateHandle, 16 | getRepoDetail: GetRepoDetailUseCase 17 | ) : BaseViewModel() { 18 | 19 | 20 | private val _title = MutableStateFlow("") 21 | val title = _title.asStateFlow() 22 | 23 | private val _description = MutableStateFlow("") 24 | val description = _description.asStateFlow() 25 | 26 | private val _starCount = MutableStateFlow("") 27 | val starCount = _starCount.asStateFlow() 28 | 29 | private val _repoName = MutableStateFlow("") 30 | val repoName = _repoName.asStateFlow() 31 | 32 | private val _refreshForks = MutableStateFlow>(emptyList()) 33 | val refreshForks = _refreshForks.asStateFlow() 34 | 35 | init { 36 | val userName = handle.get("userName") ?: "" 37 | val repoName = handle.get("repoName") ?: "" 38 | 39 | getRepoDetail(GetRepoDetailUseCase.Params(userName, repoName)) 40 | .onSuccess { 41 | _title.value = userName 42 | _repoName.value = it.first.name 43 | _description.value = it.first.description 44 | _starCount.value = it.first.starCount 45 | _refreshForks.value = it.second 46 | }.onFailure { 47 | showAlertDialog(AlertUIModel.Dialog("통신 실패", it.message)) 48 | }.commonErrorHandler() 49 | .call() 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/result/SearchResultActivity.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.result 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import androidx.activity.compose.setContent 7 | import androidx.activity.viewModels 8 | import androidx.core.os.bundleOf 9 | import androidx.lifecycle.DEFAULT_ARGS_KEY 10 | import androidx.lifecycle.lifecycleScope 11 | import androidx.lifecycle.viewmodel.MutableCreationExtras 12 | import com.jaydev.github.view.base.BaseActivity 13 | import com.jaydev.github.view.repo.RepoDetailActivity 14 | import dagger.hilt.android.AndroidEntryPoint 15 | import kotlinx.coroutines.flow.launchIn 16 | import kotlinx.coroutines.flow.onEach 17 | 18 | @AndroidEntryPoint 19 | class SearchResultActivity : BaseActivity() { 20 | private val viewModel by viewModels( 21 | extrasProducer = { 22 | val extras = MutableCreationExtras(defaultViewModelCreationExtras) 23 | intent?.data?.path?.substring(1)?.let { queryParamId -> 24 | extras[DEFAULT_ARGS_KEY] = bundleOf("userName" to queryParamId) 25 | } 26 | extras 27 | } 28 | ) 29 | 30 | override fun onCreate(savedInstanceState: Bundle?) { 31 | super.onCreate(savedInstanceState) 32 | setContent { 33 | SearchResultScreen(viewModel = viewModel) 34 | } 35 | 36 | subscribeUI(viewModel) 37 | } 38 | 39 | private fun subscribeUI(viewModel: SearchResultViewModel) { 40 | with(viewModel) { 41 | navigateProfile.onEach { 42 | val uri = Uri.parse("githubbrowser://repos/$it") 43 | startActivity(Intent(Intent.ACTION_VIEW, uri)) 44 | }.launchIn(lifecycleScope) 45 | 46 | navigateRepoDetail.onEach { 47 | RepoDetailActivity.start(this@SearchResultActivity, it.userName, it.repoName) 48 | }.launchIn(lifecycleScope) 49 | 50 | showToast.onEach { 51 | showToast(it) 52 | }.launchIn(lifecycleScope) 53 | 54 | showAlertDialog.onEach { 55 | showAlertDialog(it.title, it.message) 56 | }.launchIn(lifecycleScope) 57 | 58 | showActionDialog.onEach { 59 | showActionDialog(it.first.title, it.first.message, it.second) 60 | }.launchIn(lifecycleScope) 61 | 62 | navigateToBack.onEach { 63 | onBackPressedDispatcher.onBackPressed() 64 | }.launchIn(lifecycleScope) 65 | } 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/result/SearchResultScreen.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.result 2 | 3 | import androidx.compose.animation.Crossfade 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.Spacer 9 | import androidx.compose.foundation.layout.WindowInsets 10 | import androidx.compose.foundation.layout.WindowInsetsSides 11 | import androidx.compose.foundation.layout.consumeWindowInsets 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.only 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.safeDrawing 16 | import androidx.compose.foundation.layout.size 17 | import androidx.compose.foundation.layout.windowInsetsPadding 18 | import androidx.compose.foundation.layout.wrapContentHeight 19 | import androidx.compose.foundation.lazy.LazyColumn 20 | import androidx.compose.foundation.lazy.items 21 | import androidx.compose.foundation.shape.RoundedCornerShape 22 | import androidx.compose.material3.Card 23 | import androidx.compose.material3.CircularProgressIndicator 24 | import androidx.compose.material3.ExperimentalMaterial3Api 25 | import androidx.compose.material3.MaterialTheme 26 | import androidx.compose.material3.Scaffold 27 | import androidx.compose.material3.Text 28 | import androidx.compose.material3.TopAppBar 29 | import androidx.compose.material3.TopAppBarDefaults 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.collectAsState 32 | import androidx.compose.ui.Modifier 33 | import androidx.compose.ui.draw.clip 34 | import androidx.compose.ui.text.font.FontWeight 35 | import androidx.compose.ui.unit.dp 36 | import androidx.constraintlayout.compose.ConstraintLayout 37 | import coil.compose.AsyncImage 38 | import com.jaydev.github.domain.entity.Repo 39 | import com.jaydev.github.domain.entity.User 40 | import com.jaydev.github.model.SearchListItem 41 | import com.jaydev.github.ui.theme.GithubBrowserTheme 42 | 43 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) 44 | @Composable 45 | fun SearchResultScreen(viewModel: SearchResultViewModel) { 46 | GithubBrowserTheme { 47 | Scaffold( 48 | topBar = { 49 | TopAppBar( 50 | title = { 51 | Text(text = "SearchList") 52 | }, 53 | colors = TopAppBarDefaults 54 | .topAppBarColors(containerColor = MaterialTheme.colorScheme.primary) 55 | ) 56 | }, 57 | 58 | ) { padding -> 59 | val lists = viewModel.refreshListData.collectAsState().value 60 | val isLoading = viewModel.loading.collectAsState().value 61 | 62 | Crossfade(targetState = isLoading, label = "loading") { 63 | if (it) { 64 | CircularProgressIndicator() 65 | } else { 66 | LazyColumn( 67 | modifier = Modifier 68 | .padding(padding) 69 | .consumeWindowInsets(padding) 70 | .windowInsetsPadding( 71 | WindowInsets.safeDrawing.only( 72 | WindowInsetsSides.Horizontal, 73 | ), 74 | ) 75 | .padding(16.dp), 76 | verticalArrangement = Arrangement.spacedBy(8.dp) 77 | ) { 78 | items(lists) { item -> 79 | when (item) { 80 | is SearchListItem.Header -> UserInfoItem( 81 | item, 82 | viewModel::onClickUser 83 | ) 84 | 85 | is SearchListItem.RepoItem -> UserRepoItem( 86 | item, 87 | viewModel::onClickRepo 88 | ) 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | 98 | @Composable 99 | private fun UserInfoItem( 100 | item: SearchListItem.Header, 101 | onClick: (User) -> Unit 102 | ) { 103 | Row(Modifier.clickable { 104 | onClick.invoke(item.user) 105 | }) { 106 | AsyncImage( 107 | model = item.user.profileImageUrl, 108 | contentDescription = "User Profile Image", 109 | modifier = Modifier 110 | .clip(RoundedCornerShape(8.dp)) 111 | ) 112 | Spacer(modifier = Modifier.size(20.dp)) 113 | Text(text = item.user.name) 114 | } 115 | } 116 | 117 | @OptIn(ExperimentalMaterial3Api::class) 118 | @Composable 119 | private fun UserRepoItem( 120 | item: SearchListItem.RepoItem, 121 | onClick: (Repo) -> Unit 122 | ) { 123 | Card( 124 | onClick = { 125 | onClick.invoke(item.repo) 126 | }, 127 | modifier = Modifier 128 | .fillMaxWidth() 129 | .wrapContentHeight() 130 | ) { 131 | ConstraintLayout( 132 | modifier = Modifier 133 | .padding(16.dp) 134 | .fillMaxWidth() 135 | .wrapContentHeight() 136 | ) { 137 | val (repoName, description, starCount) = createRefs() 138 | 139 | Text( 140 | text = item.repo.name, 141 | fontWeight = FontWeight.Bold, 142 | modifier = Modifier.constrainAs(repoName) { 143 | top.linkTo(parent.top) 144 | start.linkTo(parent.start) 145 | } 146 | ) 147 | 148 | Text( 149 | text = item.repo.description, 150 | modifier = Modifier.constrainAs(description) { 151 | top.linkTo(repoName.bottom) 152 | start.linkTo(parent.start) 153 | } 154 | ) 155 | 156 | Text( 157 | text = item.repo.starCount, 158 | color = MaterialTheme.colorScheme.secondary, 159 | modifier = Modifier.constrainAs(starCount) { 160 | top.linkTo(parent.top) 161 | end.linkTo(parent.end) 162 | } 163 | ) 164 | } 165 | } 166 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/result/SearchResultViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.result 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.viewModelScope 5 | import com.jaydev.github.domain.entity.Repo 6 | import com.jaydev.github.domain.entity.User 7 | import com.jaydev.github.domain.interactor.usecase.GetUserDataUseCase 8 | import com.jaydev.github.model.AlertUIModel 9 | import com.jaydev.github.model.RepoData 10 | import com.jaydev.github.model.SearchListItem 11 | import com.jaydev.github.view.base.BaseViewModel 12 | import dagger.hilt.android.lifecycle.HiltViewModel 13 | import kotlinx.coroutines.flow.MutableSharedFlow 14 | import kotlinx.coroutines.flow.MutableStateFlow 15 | import kotlinx.coroutines.flow.asSharedFlow 16 | import kotlinx.coroutines.flow.asStateFlow 17 | import kotlinx.coroutines.launch 18 | import javax.inject.Inject 19 | 20 | @HiltViewModel 21 | class SearchResultViewModel @Inject constructor( 22 | handle: SavedStateHandle, 23 | getUserData: GetUserDataUseCase 24 | ) : BaseViewModel() { 25 | private val _refreshListData = MutableStateFlow>(emptyList()) 26 | val refreshListData = _refreshListData.asStateFlow() 27 | 28 | private val _navigateProfile = MutableSharedFlow() 29 | val navigateProfile = _navigateProfile.asSharedFlow() 30 | 31 | private val _navigateRepoDetail = MutableSharedFlow() 32 | val navigateRepoDetail = _navigateRepoDetail.asSharedFlow() 33 | 34 | init { 35 | val userName = handle.get("userName") ?: "" 36 | 37 | getUserData(GetUserDataUseCase.Params(userName)) 38 | .onSuccess { 39 | val list = mutableListOf() 40 | list.add(SearchListItem.Header(it.first)) 41 | list.addAll( 42 | it.second.map { repo -> 43 | SearchListItem.RepoItem(repo) 44 | } 45 | ) 46 | _refreshListData.value = list 47 | }.onFailure { 48 | showAlertDialog(AlertUIModel.Dialog("통신 실패", it.message)) 49 | }.commonErrorHandler() 50 | .load() 51 | } 52 | 53 | 54 | fun onClickUser(user: User) { 55 | viewModelScope.launch { 56 | _navigateProfile.emit(user.name) 57 | } 58 | } 59 | 60 | fun onClickRepo(repo: Repo) { 61 | viewModelScope.launch { 62 | val header = 63 | refreshListData.value.firstOrNull() as? SearchListItem.Header ?: return@launch 64 | _navigateRepoDetail.emit(RepoData(header.user.name, repo.name)) 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/search/SearchActivity.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.search 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import com.jaydev.github.view.base.BaseActivity 6 | import dagger.hilt.android.AndroidEntryPoint 7 | 8 | @AndroidEntryPoint 9 | class SearchActivity : BaseActivity() { 10 | override fun onCreate(savedInstanceState: Bundle?) { 11 | super.onCreate(savedInstanceState) 12 | 13 | setContent { 14 | SearchScreen() 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/jaydev/github/view/search/SearchScreen.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.view.search 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.ExperimentalLayoutApi 7 | import androidx.compose.foundation.layout.WindowInsets 8 | import androidx.compose.foundation.layout.WindowInsetsSides 9 | import androidx.compose.foundation.layout.consumeWindowInsets 10 | import androidx.compose.foundation.layout.fillMaxWidth 11 | import androidx.compose.foundation.layout.only 12 | import androidx.compose.foundation.layout.padding 13 | import androidx.compose.foundation.layout.safeDrawing 14 | import androidx.compose.foundation.layout.windowInsetsPadding 15 | import androidx.compose.material3.Button 16 | import androidx.compose.material3.ExperimentalMaterial3Api 17 | import androidx.compose.material3.MaterialTheme 18 | import androidx.compose.material3.Scaffold 19 | import androidx.compose.material3.Text 20 | import androidx.compose.material3.TextField 21 | import androidx.compose.material3.TopAppBar 22 | import androidx.compose.material3.TopAppBarDefaults 23 | import androidx.compose.runtime.Composable 24 | import androidx.compose.runtime.getValue 25 | import androidx.compose.runtime.mutableStateOf 26 | import androidx.compose.runtime.remember 27 | import androidx.compose.runtime.setValue 28 | import androidx.compose.ui.Modifier 29 | import androidx.compose.ui.platform.LocalContext 30 | import androidx.compose.ui.unit.dp 31 | import com.jaydev.github.ui.theme.GithubBrowserTheme 32 | 33 | @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) 34 | @Composable 35 | fun SearchScreen() { 36 | GithubBrowserTheme { 37 | Scaffold( 38 | topBar = { 39 | TopAppBar( 40 | title = { 41 | Text(text = "Search") 42 | }, 43 | colors = TopAppBarDefaults 44 | .topAppBarColors(containerColor = MaterialTheme.colorScheme.primary), 45 | ) 46 | } 47 | ) { padding -> 48 | val context = LocalContext.current 49 | var text by remember { mutableStateOf("") } 50 | 51 | Column( 52 | modifier = Modifier 53 | .padding(padding) 54 | .consumeWindowInsets(padding) 55 | .windowInsetsPadding( 56 | WindowInsets.safeDrawing.only( 57 | WindowInsetsSides.Horizontal, 58 | ), 59 | ) 60 | ) { 61 | TextField( 62 | value = text, 63 | onValueChange = { text = it }, 64 | label = { 65 | Text(text = "User Name") 66 | }, 67 | placeholder = { 68 | Text(text = "Input User Name") 69 | }, 70 | modifier = Modifier 71 | .fillMaxWidth() 72 | .padding(16.dp) 73 | ) 74 | Button( 75 | onClick = { 76 | val uri = Uri.parse("githubbrowser://repos/$text") 77 | // TODO navigate to SearchListScreen by using navController 78 | context.startActivity(Intent(Intent.ACTION_VIEW, uri)) 79 | }, 80 | modifier = Modifier 81 | .fillMaxWidth() 82 | .padding(16.dp) 83 | ) { 84 | Text(text = "Search") 85 | } 86 | } 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | GithubBrowser 3 | 4 | 알림 5 | 네트워크가 연결되지 않았습니다. 연결 상태 확인 후 다시 시도해주세요. 6 | 서버의 응답이 늦어져 연결에 실패했습니다. 잠시 후 다시 시도해주세요. 7 | 서버에서 데이터를 가져오지 못했습니다. 잠시 후 다시 시도해주세요. 8 | 예상치 못한 문제가 발생했습니다. 잠시 후 다시 시도해주세요. 9 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 3 | plugins { 4 | alias(libs.plugins.androidApplication) apply false 5 | alias(libs.plugins.kotlinAndroid) apply false 6 | alias(libs.plugins.kotlinKapt) apply false 7 | alias(libs.plugins.kotlinSerialization) apply false 8 | alias(libs.plugins.hilt) apply false 9 | alias(libs.plugins.kotlinParcelize) apply false 10 | } 11 | true // Needed to make the Suppress annotation work for the plugins block -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /data/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("kotlin") 3 | alias(libs.plugins.kotlinSerialization) 4 | } 5 | 6 | dependencies { 7 | api(project(":domain")) 8 | 9 | implementation(libs.kotlin.stdlib) 10 | implementation(libs.kotlinx.coroutines.android) 11 | implementation(libs.kotlinx.serialization.json) 12 | 13 | implementation(libs.retrofit) 14 | } -------------------------------------------------------------------------------- /data/src/main/java/com/jaydev/github/model/interactor/GithubDataRepository.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.model.interactor 2 | 3 | import com.jaydev.github.domain.repository.GithubRepository 4 | import com.jaydev.github.model.source.GithubRemote 5 | 6 | class GithubDataRepository( 7 | private val remote: GithubRemote 8 | ) : GithubRepository { 9 | override fun getForks(userName: String, id: String) = remote.getForks( 10 | userName = userName, 11 | id = id 12 | ) 13 | 14 | override fun getRepositories(userName: String) = remote.getRepositories(userName) 15 | 16 | 17 | override fun getRepository(userName: String, id: String) = remote.getRepository( 18 | userName = userName, 19 | id = id 20 | ) 21 | 22 | override fun getUser(userName: String) = remote.getUser(userName) 23 | } -------------------------------------------------------------------------------- /data/src/main/java/com/jaydev/github/model/interactor/NetErrorHandlerImpl.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.model.interactor 2 | 3 | import com.jaydev.github.domain.entity.NetError 4 | import com.jaydev.github.domain.interactor.ErrorHandler 5 | import com.jaydev.github.domain.interactor.NetworkConnectException 6 | import kotlinx.serialization.Serializable 7 | import retrofit2.HttpException 8 | import retrofit2.Response 9 | import retrofit2.Retrofit 10 | import java.io.IOException 11 | import java.net.SocketTimeoutException 12 | 13 | class NetErrorHandlerImpl(private val retrofit: Retrofit) : ErrorHandler { 14 | override fun getError(cause: Throwable): NetError { 15 | return when (cause) { 16 | is SocketTimeoutException -> NetError.Timeout 17 | is HttpException -> { 18 | when (cause.code()) { 19 | in 500..599 -> { 20 | val response = cause.response() 21 | val (code, message) = extractErrorMessage(response) 22 | NetError.InternalServer(code, message) 23 | } 24 | 25 | in 400..499 -> { 26 | val response = cause.response() 27 | val (code, message) = extractErrorMessage(response) 28 | NetError.BadRequest(code, message) 29 | } 30 | 31 | else -> NetError.Unknown(cause) 32 | } 33 | } 34 | 35 | is NetworkConnectException, is IOException -> NetError.Network 36 | else -> NetError.Unknown(cause) 37 | } 38 | } 39 | 40 | private fun extractErrorMessage(response: Response<*>?): ErrorResponse { 41 | return try { 42 | val converter = retrofit.responseBodyConverter( 43 | ErrorResponse::class.java, 44 | arrayOfNulls(0) 45 | ) 46 | val netResponse = converter.convert(response?.errorBody()!!) 47 | return ErrorResponse( 48 | response.code(), 49 | netResponse?.message ?: response.errorBody()?.string().orEmpty(), 50 | netResponse?.documentation_url.orEmpty() 51 | ) 52 | } catch (e: Exception) { 53 | ErrorResponse( 54 | response?.code()!!, 55 | response.errorBody()?.string().orEmpty(), 56 | "" 57 | ) 58 | } 59 | } 60 | 61 | @Serializable 62 | private data class ErrorResponse( 63 | val code: Int, 64 | val message: String, 65 | val documentation_url: String 66 | ) 67 | } 68 | 69 | 70 | -------------------------------------------------------------------------------- /data/src/main/java/com/jaydev/github/model/source/GithubRemote.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.model.source 2 | 3 | import com.jaydev.github.domain.entity.Fork 4 | import com.jaydev.github.domain.entity.Repo 5 | import com.jaydev.github.domain.entity.User 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | interface GithubRemote { 9 | fun getForks(userName: String, id: String): Flow> 10 | fun getRepositories(userName: String): Flow> 11 | fun getRepository(userName: String, id: String): Flow 12 | fun getUser(userName: String): Flow 13 | } -------------------------------------------------------------------------------- /domain/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /domain/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("kotlin") 3 | } 4 | 5 | dependencies { 6 | implementation(libs.kotlin.stdlib) 7 | implementation(libs.kotlinx.coroutines.android) 8 | implementation(libs.javax.inject) 9 | } -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/NetResult.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain 2 | 3 | import com.jaydev.github.domain.entity.NetError 4 | 5 | 6 | sealed class NetResult { 7 | data class Success(val response: MODEL) : NetResult() 8 | data class Error(val error: NetError) : NetResult() 9 | } 10 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/entity/Fork.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.entity 2 | 3 | 4 | data class Fork( 5 | val name: String, 6 | val fullName: String, 7 | val owner: User 8 | ) 9 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/entity/NetError.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.entity 2 | 3 | sealed interface NetError { 4 | object Network : NetError 5 | object Timeout : NetError 6 | data class BadRequest(val code: Int, val message: String) : NetError 7 | data class InternalServer(val code: Int, val message: String) : NetError 8 | class Unknown(val throwable: Throwable) : NetError 9 | } 10 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/entity/Repo.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.entity 2 | 3 | 4 | data class Repo( 5 | val name: String, 6 | val description: String, 7 | val starCount: String 8 | ) 9 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/entity/User.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.entity 2 | 3 | 4 | data class User( 5 | val name: String, 6 | val profileImageUrl: String 7 | ) 8 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/interactor/ErrorHandler.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.interactor 2 | 3 | import com.jaydev.github.domain.entity.NetError 4 | 5 | interface ErrorHandler { 6 | fun getError(throwable: Throwable): NetError 7 | } 8 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/interactor/NetworkConnectException.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.interactor 2 | 3 | import java.io.IOException 4 | 5 | class NetworkConnectException : IOException("Network is Not Connected.") 6 | 7 | class InternalServerException(val errorCode: Int, val errorMessage: String) : 8 | IOException("Internal Server Error.") 9 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/interactor/usecase/BaseUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.interactor.usecase 2 | 3 | import com.jaydev.github.domain.NetResult 4 | import com.jaydev.github.domain.interactor.ErrorHandler 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.catch 7 | import kotlinx.coroutines.flow.map 8 | 9 | abstract class BaseUseCase { 10 | abstract fun run(params: Params): Type 11 | 12 | operator fun invoke( 13 | params: Params 14 | ) = run(params) 15 | 16 | protected fun Flow.toResult(errorHandler: ErrorHandler) = map { 17 | NetResult.Success(it) as NetResult 18 | }.catch { cause -> 19 | emit(NetResult.Error(errorHandler.getError(cause))) 20 | } 21 | 22 | class None 23 | } 24 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/interactor/usecase/GithubUseCase.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.interactor.usecase 2 | 3 | import com.jaydev.github.domain.NetResult 4 | import com.jaydev.github.domain.entity.Fork 5 | import com.jaydev.github.domain.entity.Repo 6 | import com.jaydev.github.domain.entity.User 7 | import com.jaydev.github.domain.interactor.ErrorHandler 8 | import com.jaydev.github.domain.repository.GithubRepository 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.zip 11 | import javax.inject.Inject 12 | 13 | class GetUserDataUseCase @Inject constructor( 14 | private val githubRepository: GithubRepository, 15 | private val errorHandler: ErrorHandler 16 | ) : BaseUseCase>>>, GetUserDataUseCase.Params>() { 17 | override fun run(params: Params) = githubRepository.getUser(params.userName) 18 | .zip(githubRepository.getRepositories(params.userName)) { user, repositories -> 19 | Pair(user, repositories.sortedByDescending { it.starCount }) 20 | }.toResult(errorHandler) 21 | 22 | data class Params( 23 | val userName: String 24 | ) 25 | } 26 | 27 | class GetRepoDetailUseCase @Inject constructor( 28 | private val githubRepository: GithubRepository, 29 | private val errorHandler: ErrorHandler 30 | ) : BaseUseCase>>>, GetRepoDetailUseCase.Params>() { 31 | override fun run(params: Params) = 32 | githubRepository.getRepository(params.userName, params.id) 33 | .zip(githubRepository.getForks(params.userName, params.id)) { repository, forks -> 34 | Pair(repository, forks) 35 | }.toResult(errorHandler) 36 | 37 | data class Params( 38 | val userName: String, 39 | val id: String 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /domain/src/main/java/com/jaydev/github/domain/repository/GithubRepository.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.domain.repository 2 | 3 | import com.jaydev.github.domain.entity.Fork 4 | import com.jaydev.github.domain.entity.Repo 5 | import com.jaydev.github.domain.entity.User 6 | import kotlinx.coroutines.flow.Flow 7 | 8 | interface GithubRepository { 9 | fun getForks(userName: String, id: String): Flow> 10 | fun getRepositories(userName: String): Flow> 11 | fun getRepository(userName: String, id: String): Flow 12 | fun getUser(userName: String): Flow 13 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | agp = "8.2.0-alpha15" 3 | kotlin = "1.8.10" 4 | core-ktx = "1.10.1" 5 | junit = "4.13.2" 6 | androidx-test-ext-junit = "1.1.5" 7 | espresso-core = "3.5.1" 8 | lifecycle-runtime-ktx = "2.6.1" 9 | activity-compose = "1.7.2" 10 | compose-bom = "2023.06.01" 11 | orbit = "6.0.0" 12 | hilt = "2.47" 13 | retrofit = "2.9.0" 14 | okhttp-bom = "4.10.0" 15 | 16 | [libraries] 17 | core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } 18 | junit = { group = "junit", name = "junit", version.ref = "junit" } 19 | androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } 20 | espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } 21 | lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } 22 | lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version = "2.6.1" } 23 | lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version = "2.6.1" } 24 | activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } 25 | compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } 26 | ui = { group = "androidx.compose.ui", name = "ui" } 27 | ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } 28 | ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } 29 | ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 30 | ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } 31 | ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 32 | material3 = { group = "androidx.compose.material3", name = "material3" } 33 | navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version = "2.6.0" } 34 | paging-compose = { group = "androidx.paging", name = "paging-compose", version = "3.2.0-rc01" } 35 | activity-ktx = { group = "androidx.activity", name = "activity-ktx", version = "1.7.2" } 36 | constraintlayout-compose = { group = "androidx.constraintlayout", name = "constraintlayout-compose", version = "1.0.1" } 37 | 38 | kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } 39 | kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } 40 | kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version = "1.7.1" } 41 | kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.5.1" } 42 | kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.4.0" } 43 | 44 | orbit-compose = { group = "org.orbit-mvi", name = "orbit-compose", version.ref = "orbit" } 45 | orbit-viewmodel = { group = "org.orbit-mvi", name = "orbit-viewmodel", version.ref = "orbit" } 46 | 47 | hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } 48 | hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } 49 | hilt-androidx-compiler = { module = "androidx.hilt:hilt-compiler", version = "1.0.0" } 50 | hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.0.0" } 51 | javax-inject = { module = "javax.inject:javax.inject", version = "1" } 52 | 53 | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } 54 | retrofit-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version = "1.0.0" } 55 | 56 | okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttp-bom" } 57 | okhttp = { module = "com.squareup.okhttp3:okhttp" } 58 | okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor" } 59 | 60 | coil-compose = { module = "io.coil-kt:coil-compose", version = "2.3.0" } 61 | 62 | logger = { group = "com.orhanobut", name = "logger", version = "2.2.0" } 63 | 64 | [plugins] 65 | androidApplication = { id = "com.android.application", version.ref = "agp" } 66 | kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 67 | kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 68 | kotlinKapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } 69 | hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } 70 | kotlinParcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JayDev-Lee/GithubBrowserSample/d15102b9dd36bc7c61189076ffca9e801feffdd9/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Jul 25 14:51:45 KST 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /remote/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /remote/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("kotlin") 3 | alias(libs.plugins.kotlinSerialization) 4 | } 5 | 6 | dependencies { 7 | api(project(":data")) 8 | 9 | implementation(libs.kotlin.stdlib) 10 | implementation(libs.kotlinx.coroutines.android) 11 | 12 | implementation(libs.retrofit) 13 | implementation(libs.kotlinx.serialization.json) 14 | 15 | implementation(platform(libs.okhttp.bom)) 16 | implementation(libs.okhttp) 17 | implementation(libs.okhttp.logging.interceptor) 18 | } -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/GithubRemoteImpl.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote 2 | 3 | import com.jaydev.github.model.source.GithubRemote 4 | import com.jaydev.github.remote.mapper.ForkEntityMapper 5 | import com.jaydev.github.remote.mapper.RepoEntityMapper 6 | import com.jaydev.github.remote.mapper.UserEntityMapper 7 | import kotlinx.coroutines.flow.flow 8 | import kotlinx.coroutines.flow.map 9 | 10 | class GithubRemoteImpl( 11 | private val githubService: GithubService, 12 | private val userEntityMapper: UserEntityMapper, 13 | private val repoEntityMapper: RepoEntityMapper, 14 | private val forkEntityMapper: ForkEntityMapper 15 | ) : GithubRemote { 16 | override fun getForks(userName: String, id: String) = flow { 17 | emit( 18 | githubService.getForks( 19 | userName = userName, 20 | repoName = id 21 | ) 22 | ) 23 | }.map { forks -> 24 | forks.map { 25 | forkEntityMapper.mapFromRemote(it) 26 | } 27 | } 28 | 29 | override fun getRepositories(userName: String) = flow { 30 | emit( 31 | githubService.getRepositories(userName) 32 | ) 33 | }.map { repositories -> 34 | repositories.map { 35 | repoEntityMapper.mapFromRemote(it) 36 | } 37 | } 38 | 39 | override fun getRepository(userName: String, id: String) = flow { 40 | emit( 41 | githubService.getRepository( 42 | userName = userName, 43 | repoName = id 44 | ) 45 | ) 46 | }.map { 47 | repoEntityMapper.mapFromRemote(it) 48 | } 49 | 50 | override fun getUser(userName: String) = flow { 51 | emit( 52 | githubService.getUser(userName) 53 | ) 54 | }.map { 55 | userEntityMapper.mapFromRemote(it) 56 | } 57 | } -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/GithubService.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote 2 | 3 | import com.jaydev.github.remote.model.ForkModel 4 | import com.jaydev.github.remote.model.RepoModel 5 | import com.jaydev.github.remote.model.UserModel 6 | import retrofit2.http.* 7 | 8 | interface GithubService { 9 | @GET("/users/{userName}") 10 | suspend fun getUser( 11 | @Path("userName") userName: String 12 | ): UserModel 13 | 14 | @GET("/users/{userName}/repos") 15 | suspend fun getRepositories( 16 | @Path("userName") userName: String 17 | ): List 18 | 19 | @GET("/repos/{userName}/{repo}") 20 | suspend fun getRepository( 21 | @Path("userName") userName: String, 22 | @Path("repo") repoName: String 23 | ): RepoModel 24 | 25 | @GET("/repos/{userName}/{repo}/forks") 26 | suspend fun getForks( 27 | @Path("userName") userName: String, 28 | @Path("repo") repoName: String 29 | ): List 30 | } -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/mapper/EntityMapper.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote.mapper 2 | 3 | interface EntityMapper { 4 | fun mapFromRemote(model: MODEL): ENTITY 5 | } 6 | -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/mapper/ForkEntityMapper.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote.mapper 2 | 3 | import com.jaydev.github.domain.entity.Fork 4 | import com.jaydev.github.remote.model.ForkModel 5 | 6 | class ForkEntityMapper( 7 | private val userMapper: UserEntityMapper 8 | ) : EntityMapper { 9 | override fun mapFromRemote(model: ForkModel) = Fork( 10 | model.name, 11 | model.full_name, 12 | userMapper.mapFromRemote(model.owner) 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/mapper/RepoEntityMapper.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote.mapper 2 | 3 | import com.jaydev.github.domain.entity.Repo 4 | import com.jaydev.github.remote.model.RepoModel 5 | 6 | class RepoEntityMapper : EntityMapper { 7 | override fun mapFromRemote(model: RepoModel) = Repo( 8 | model.name, 9 | model.description ?: "", 10 | model.stargazers_count 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/mapper/UserEntityMapper.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote.mapper 2 | 3 | import com.jaydev.github.domain.entity.User 4 | import com.jaydev.github.remote.model.UserModel 5 | 6 | 7 | class UserEntityMapper : EntityMapper { 8 | override fun mapFromRemote(model: UserModel) = User(model.login, model.avatar_url) 9 | } 10 | -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/model/ForkModel.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class ForkModel( 7 | val name: String, 8 | val full_name: String, 9 | val owner: UserModel 10 | ) 11 | -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/model/RepoModel.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class RepoModel( 7 | val name: String, 8 | val description: String?, 9 | val stargazers_count: String 10 | ) 11 | -------------------------------------------------------------------------------- /remote/src/main/java/com/jaydev/github/remote/model/UserModel.kt: -------------------------------------------------------------------------------- 1 | package com.jaydev.github.remote.model 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class UserModel( 7 | val login: String, 8 | val avatar_url: String, 9 | ) 10 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | 16 | rootProject.name = "GithubBrowserSample" 17 | include(":app") 18 | include(":data") 19 | include(":domain") 20 | include(":remote") --------------------------------------------------------------------------------