├── .gitignore ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-web.png │ ├── java │ └── tech │ │ └── thdev │ │ └── githubusersearch │ │ └── GithubApp.kt │ └── res │ ├── drawable │ ├── ic_launcher_background.xml │ └── ic_launcher_foreground.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 ├── build.gradle.kts ├── core ├── data │ └── github │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── proguard-rules.pro │ │ └── src │ │ ├── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ │ └── tech │ │ │ └── thdev │ │ │ └── githubusersearch │ │ │ └── data │ │ │ └── github │ │ │ ├── GitHubApi.kt │ │ │ ├── GitHubSearchRepositoryImpl.kt │ │ │ ├── di │ │ │ ├── GitHubApiModule.kt │ │ │ └── GitHubSearchModule.kt │ │ │ └── model │ │ │ └── GitHubUserResponse.kt │ │ └── test │ │ └── java │ │ └── tech │ │ └── thdev │ │ └── githubusersearch │ │ └── data │ │ └── github │ │ └── GitHubSearchRepositoryImplTest.kt ├── database │ ├── github-api │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── proguard-rules.pro │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── java │ │ │ └── tech │ │ │ └── thdev │ │ │ └── githubusersearch │ │ │ └── database │ │ │ └── github │ │ │ └── api │ │ │ ├── GitHubUserDao.kt │ │ │ └── model │ │ │ └── GitHubUser.kt │ └── github │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── proguard-rules.pro │ │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tech │ │ └── thdev │ │ └── githubusersearch │ │ └── database │ │ └── github │ │ ├── GitHubDatabase.kt │ │ └── di │ │ └── GitHubLocalDatabaseModule.kt ├── design-system │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── java │ │ └── tech │ │ │ └── thdev │ │ │ └── githubusersearch │ │ │ └── design │ │ │ └── system │ │ │ ├── AsyncImageComponent.kt │ │ │ ├── GitScaffoldComponent.kt │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── res │ │ ├── drawable │ │ ├── ic_close_white_24dp.xml │ │ ├── ic_collections_bookmark_black_24dp.xml │ │ ├── ic_error_outline_black_24dp.xml │ │ ├── ic_favorite_border_red_24dp.xml │ │ ├── ic_favorite_red_24dp.xml │ │ ├── ic_search_black_24dp.xml │ │ ├── ic_sort.xml │ │ ├── ic_sort_alphabet.xml │ │ └── ic_sort_numbers.xml │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml ├── domain │ └── github │ │ ├── .gitignore │ │ ├── build.gradle.kts │ │ ├── proguard-rules.pro │ │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── java │ │ └── tech │ │ └── thdev │ │ └── githubusersearch │ │ └── domain │ │ └── github │ │ ├── GitHubSearchRepository.kt │ │ └── model │ │ ├── GitHubSortType.kt │ │ └── GitHubUserEntity.kt └── network │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ └── main │ ├── AndroidManifest.xml │ └── java │ └── tech │ └── thdev │ └── githubusersearch │ └── network │ └── di │ └── NetworkModule.kt ├── feature └── github-search │ ├── .gitignore │ ├── build.gradle.kts │ ├── proguard-rules.pro │ └── src │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── tech │ │ └── thdev │ │ └── githubusersearch │ │ └── feature │ │ └── github │ │ ├── MainActivity.kt │ │ ├── MainViewModel.kt │ │ ├── ViewType.kt │ │ ├── compose │ │ ├── MainScreen.kt │ │ └── component │ │ │ ├── ResultEmptyComponent.kt │ │ │ └── ResultItemComponent.kt │ │ ├── holder │ │ ├── like │ │ │ ├── LikeComposableHolder.kt │ │ │ ├── LikeViewModel.kt │ │ │ ├── compose │ │ │ │ ├── LikeScreen.kt │ │ │ │ └── component │ │ │ │ │ └── LikeResultComponent.kt │ │ │ └── model │ │ │ │ ├── LikeUiState.kt │ │ │ │ └── convert │ │ │ │ └── LikeConvert.kt │ │ └── search │ │ │ ├── SearchComposableHolder.kt │ │ │ ├── SearchViewModel.kt │ │ │ ├── compose │ │ │ ├── SearchScreen.kt │ │ │ └── component │ │ │ │ ├── MultiFloatingActionButtonComponent.kt │ │ │ │ ├── SearchResultComponent.kt │ │ │ │ ├── SearchResultLoadingComponent.kt │ │ │ │ └── SearchTextFieldComponent.kt │ │ │ └── model │ │ │ ├── SearchUiState.kt │ │ │ └── convert │ │ │ └── SearchConvert.kt │ │ └── model │ │ └── MainUiState.kt │ └── test │ └── java │ └── tech │ └── thdev │ └── githubusersearch │ └── feature │ └── github │ ├── MainViewModelTest.kt │ └── holder │ ├── like │ └── LikeViewModelTest.kt │ └── search │ └── SearchViewModelTest.kt ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images ├── architecture.webp ├── like_page.png ├── mad-arch-overview-ui.png └── search_page.png ├── img.png ├── img_1.png └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /captures 3 | .externalNativeBuild 4 | ### Android template 5 | # Built application files 6 | *.apk 7 | *.ap_ 8 | 9 | # Files for the ART/Dalvik VM 10 | *.dex 11 | 12 | # Java class files 13 | *.class 14 | 15 | # Generated files 16 | bin/ 17 | gen/ 18 | out/ 19 | 20 | # Gradle files 21 | .gradle/ 22 | build/ 23 | 24 | # Local configuration file (sdk path, etc) 25 | local.properties 26 | 27 | # Proguard folder generated by Eclipse 28 | proguard/ 29 | 30 | # Log Files 31 | *.log 32 | 33 | # Android Studio Navigation editor temp files 34 | .navigation/ 35 | 36 | # Android Studio captures folder 37 | captures/ 38 | 39 | # IntelliJ 40 | *.iml 41 | .idea 42 | 43 | # Keystore files 44 | # Uncomment the following line if you do not want to check your keystore files in. 45 | #*.jks 46 | 47 | # External native build folder generated in Android Studio 2.2 and later 48 | #.externalNativeBuild 49 | 50 | # Google Services (e.g. APIs or Firebase) 51 | google-services.json 52 | 53 | # Freeline 54 | freeline.py 55 | freeline/ 56 | freeline_project_description.json 57 | 58 | # fastlane 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots 62 | fastlane/test_output 63 | fastlane/readme.md 64 | 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHubUserSearch 2 | 3 | Use api GitHub [GitHub search](https://docs.github.com/en/free-pro-team@latest/rest/search/search?apiVersion=2022-11-28#search-code). 4 | 5 | # Sample Page 6 | 7 | Search page and liked page 8 | 9 | | User search | User like and sort button. | 10 | |:-------------------------------:|:-------------------------------:| 11 | | ![main](images/search_page.png) | ![detail](images/like_page.png) | 12 | 13 | # Sample list 14 | 15 | | View Type | Module | Async | DI | Architecture | link | 16 | |:--------------------:|:------:|:-----------------:|:---------------------------:|:--------------------:|:------------------------------------------------------------------------------------------------------------:| 17 | | Compose + Navigation | O | coroutines + flow | Hilt | Android Architecture | [link](https://github.com/taehwandev/GithubUserSearch/tree/MVVM-Compose-Hilt-coroutines-Split-Module-sample) | 18 | | Compose + Navigation | X | coroutines + flow | Hilt | Android Architecture | [link](https://github.com/taehwandev/GithubUserSearch/tree/MVVM-Compose-Hilt-coroutines-sample) | 19 | | XML + Navigation | X | coroutines + flow | Hilt | Android Architecture | [link](https://github.com/taehwandev/GithubUserSearch/tree/MVVM-Hilt-coroutines-sample) | 20 | | XML + Navigation | X | coroutines + flow | Manual dependency injection | MVVM | [link](https://github.com/taehwandev/GithubUserSearch/tree/MVVM-ManualDependencyInjection-coroutines-sample) | 21 | | XML + Navigation | X | RxJava3 | Manual dependency injection | MVVM | [link](https://github.com/taehwandev/GithubUserSearch/tree/MVVM-ManualDependencyInjection-rxjava-sample) | 22 | 23 | # Android Architecture 24 | 25 | MVVM patten and Use Hilt, Compose 26 | 27 | ![mad-arch-overview-ui.png](images/mad-arch-overview-ui.png) 28 | 29 | # Use Library 30 | 31 | all library info : [libs.versions.toml](gradle/libs.versions.toml) 32 | 33 | - UI 34 | - Android compose navigation + compose 35 | - AAC-ViewModel 36 | - [Compose Floating](https://medium.com/@khambhaytajaydip/jetpack-compose-multiple-floatingactionbutton-2fe26f19404e) 37 | - [Compose keyboard controller](https://github.com/taehwandev/ComposeKeyboardState) 38 | - async 39 | - Coroutines 40 | - DI 41 | - Hilt 42 | - Image loader 43 | - coil 44 | - Test 45 | - Junit5 46 | - Coroutines test 47 | - Coroutines turbin 48 | - Network 49 | - retrofit2 50 | - okhttp3 51 | 52 | # License 53 | 54 | ``` 55 | Copyright 2018-2023 Tae-hwan 56 | 57 | Licensed under the Apache License, Version 2.0 (the "License"); 58 | you may not use this file except in compliance with the License. 59 | You may obtain a copy of the License at 60 | 61 | http://www.apache.org/licenses/LICENSE-2.0 62 | 63 | Unless required by applicable law or agreed to in writing, software 64 | distributed under the License is distributed on an "AS IS" BASIS, 65 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 66 | See the License for the specific language governing permissions and 67 | limitations under the License. 68 | ``` 69 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidApp) 4 | alias(libs.plugins.kotlinAndroid) 5 | alias(libs.plugins.androidHilt) 6 | alias(libs.plugins.ksp) 7 | id("kotlinx-serialization") 8 | } 9 | 10 | android { 11 | namespace = "tech.thdev.githubusersearch" 12 | compileSdk = libs.versions.compileSdk.get().toInt() 13 | 14 | defaultConfig { 15 | applicationId = "tech.thdev.githubusersearch" 16 | minSdk = libs.versions.minSdk.get().toInt() 17 | targetSdk = libs.versions.targetSdk.get().toInt() 18 | versionCode = libs.versions.versionCode.get().toInt() 19 | versionName = "${libs.versions.major.get()}.${libs.versions.minor.get()}.${libs.versions.hotfix.get()}" 20 | 21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 22 | vectorDrawables { 23 | useSupportLibrary = true 24 | } 25 | } 26 | 27 | buildTypes { 28 | release { 29 | isMinifyEnabled = false 30 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 31 | } 32 | } 33 | compileOptions { 34 | sourceCompatibility = JavaVersion.VERSION_17 35 | targetCompatibility = JavaVersion.VERSION_17 36 | } 37 | kotlinOptions { 38 | jvmTarget = "17" 39 | } 40 | tasks.withType { 41 | useJUnitPlatform() 42 | } 43 | buildFeatures { 44 | compose = true 45 | } 46 | composeOptions { 47 | kotlinCompilerExtensionVersion = libs.versions.compose.compilerVersion.get() 48 | } 49 | packaging { 50 | resources { 51 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 52 | } 53 | } 54 | } 55 | 56 | dependencies { 57 | implementation(libs.kotlin.stdlib) 58 | implementation(libs.kotlin.serializationJson) 59 | 60 | implementation(libs.coroutines.android) 61 | 62 | implementation(libs.androidx.appCompat) 63 | implementation(libs.androidx.activity) 64 | implementation(libs.androidx.fragment) 65 | implementation(libs.androidx.navigation.fragment) 66 | implementation(libs.androidx.navigation.ui) 67 | implementation(libs.androidx.recyclerView) 68 | implementation(libs.androidx.constraintLayout) 69 | implementation(libs.androidx.core) 70 | implementation(libs.androidx.lifecycle.viewModel) 71 | implementation(libs.androidx.room) 72 | implementation(libs.androidx.room.runtime) 73 | ksp(libs.androidx.room.compiler) 74 | implementation(libs.androidx.hilt.android) 75 | ksp(libs.androidx.hilt.android.compiler) 76 | implementation(libs.androidx.hilt.navigation.compose) 77 | 78 | implementation(libs.google.material) 79 | 80 | implementation(libs.network.retrofit) 81 | implementation(libs.network.retrofit.kotlinxSerializationConvert) 82 | implementation(libs.network.okhttp) 83 | implementation(libs.network.okhttp.logging) 84 | 85 | implementation(libs.compose.activity) 86 | implementation(libs.compose.ui) 87 | implementation(libs.compose.foundation) 88 | implementation(libs.compose.material3) 89 | implementation(libs.compose.runtime) 90 | implementation(libs.compose.uiToolingPreview) 91 | implementation(libs.compose.constraintLayout) 92 | implementation(libs.compose.animation) 93 | implementation(libs.coil) 94 | implementation(libs.androidx.lifecycleRuntimeCompose) 95 | 96 | implementation(libs.compose.keyboardState) 97 | implementation(libs.kotlin.collectionsImmutable) 98 | 99 | debugRuntimeOnly(libs.compose.uiTooling) 100 | 101 | implementation(project(":core:design-system")) 102 | implementation(project(":core:network")) 103 | implementation(project(":core:design-system")) 104 | implementation(project(":core:data:github")) 105 | implementation(project(":core:domain:github")) 106 | implementation(project(":core:database:github")) 107 | implementation(project(":core:database:github-api")) 108 | implementation(project(":feature:github-search")) 109 | 110 | libs.test.run { 111 | testImplementation(androidx.core) 112 | testImplementation(androidx.runner) 113 | testImplementation(androidx.junit) 114 | testImplementation(mockito.kotlin) 115 | testImplementation(junit5) 116 | testImplementation(junit5.engine) 117 | testRuntimeOnly(junit5.vintage) 118 | testImplementation(coroutines) 119 | testImplementation(coroutines.turbine) 120 | } 121 | } -------------------------------------------------------------------------------- /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 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /app/src/main/java/tech/thdev/githubusersearch/GithubApp.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class GithubApp : Application() -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 9 | 11 | 13 | 15 | 17 | 19 | 21 | 23 | 25 | 27 | 29 | 31 | 33 | 35 | 37 | 39 | 41 | 43 | 45 | 47 | 49 | 51 | 53 | 55 | 57 | 59 | 61 | 63 | 65 | 67 | 69 | 71 | 73 | 74 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /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/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /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.androidApp) apply false 5 | alias(libs.plugins.androidLibrary) apply false 6 | alias(libs.plugins.kotlinAndroid) apply false 7 | alias(libs.plugins.androidHilt) apply false 8 | alias(libs.plugins.kotlinSerialization) apply false 9 | alias(libs.plugins.ksp) apply false 10 | } 11 | true // Needed to make the Suppress annotation work for the plugins block -------------------------------------------------------------------------------- /core/data/github/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /core/data/github/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinAndroid) 5 | alias(libs.plugins.androidHilt) 6 | alias(libs.plugins.ksp) 7 | id("kotlinx-serialization") 8 | } 9 | 10 | android { 11 | namespace = "tech.thdev.githubusersearchj.data.github" 12 | compileSdk = libs.versions.compileSdk.get().toInt() 13 | 14 | defaultConfig { 15 | minSdk = libs.versions.minSdk.get().toInt() 16 | targetSdk = libs.versions.targetSdk.get().toInt() 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | isMinifyEnabled = false 24 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 25 | } 26 | } 27 | compileOptions { 28 | sourceCompatibility = JavaVersion.VERSION_17 29 | targetCompatibility = JavaVersion.VERSION_17 30 | } 31 | kotlinOptions { 32 | jvmTarget = "17" 33 | } 34 | tasks.withType { 35 | useJUnitPlatform() 36 | } 37 | packaging { 38 | resources { 39 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 40 | } 41 | } 42 | } 43 | 44 | dependencies { 45 | implementation(libs.kotlin.stdlib) 46 | implementation(libs.kotlin.serializationJson) 47 | 48 | implementation(libs.coroutines.core) 49 | 50 | implementation(libs.network.retrofit) 51 | 52 | implementation(libs.androidx.hilt.android) 53 | ksp(libs.androidx.hilt.android.compiler) 54 | 55 | implementation(project(":core:domain:github")) 56 | implementation(project(":core:database:github-api")) 57 | 58 | libs.test.run { 59 | testImplementation(androidx.core) 60 | testImplementation(androidx.runner) 61 | testImplementation(androidx.junit) 62 | testImplementation(mockito.kotlin) 63 | testImplementation(junit5) 64 | testImplementation(junit5.engine) 65 | testRuntimeOnly(junit5.vintage) 66 | testImplementation(coroutines) 67 | testImplementation(coroutines.turbine) 68 | } 69 | } -------------------------------------------------------------------------------- /core/data/github/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 22 | -------------------------------------------------------------------------------- /core/data/github/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/data/github/src/main/java/tech/thdev/githubusersearch/data/github/GitHubApi.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.data.github 2 | 3 | import retrofit2.http.GET 4 | import retrofit2.http.Query 5 | import tech.thdev.githubusersearch.data.github.model.GitHubUserResponse 6 | 7 | 8 | interface GitHubApi { 9 | 10 | /** 11 | * Github user search api - https://docs.github.com/en/free-pro-team@latest/rest/search/search?apiVersion=2022-11-28#search-code 12 | * Find users via various criteria. This method returns up to 100 results per page. 13 | * 14 | * @param q String : Required. The search terms. 15 | * @param sort String : The sort field. Can be followers, repositories, or joined. Default: results are sorted by best match. 16 | * @param order String : The sort order if sort parameter is provided. One of asc or desc. Default: desc 17 | * 18 | * Example api - curl https://api.github.com/search/users?q=tom+repos:%3E42+followers:%3E1000 19 | */ 20 | @GET("/search/users?") 21 | suspend fun searchUser( 22 | @Query(value = "q", encoded = true) searchKeyword: String, 23 | @Query("page") page: Int, 24 | @Query("per_page") perPage: Int, 25 | ): GitHubUserResponse 26 | } -------------------------------------------------------------------------------- /core/data/github/src/main/java/tech/thdev/githubusersearch/data/github/GitHubSearchRepositoryImpl.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.data.github 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import javax.inject.Inject 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.MutableStateFlow 8 | import kotlinx.coroutines.flow.flatMapLatest 9 | import kotlinx.coroutines.flow.flow 10 | import kotlinx.coroutines.flow.map 11 | import kotlinx.coroutines.flow.onEach 12 | import tech.thdev.githubusersearch.data.github.model.GitHubUserInfoResponse 13 | import tech.thdev.githubusersearch.database.github.api.GitHubUserDao 14 | import tech.thdev.githubusersearch.database.github.api.model.GitHubUser 15 | import tech.thdev.githubusersearch.domain.github.GitHubSearchRepository 16 | import tech.thdev.githubusersearch.domain.github.model.GitHubSortType 17 | import tech.thdev.githubusersearch.domain.github.model.GitHubUserEntity 18 | 19 | class GitHubSearchRepositoryImpl @Inject constructor( 20 | private val gitHubApi: GitHubApi, 21 | private val gitHubUserDao: GitHubUserDao, 22 | ) : GitHubSearchRepository { 23 | 24 | @VisibleForTesting 25 | val sortList = MutableStateFlow(GitHubSortType.FILTER_SORT_DEFAULT) 26 | 27 | @VisibleForTesting 28 | val cacheList = mutableListOf() 29 | 30 | @VisibleForTesting 31 | var cacheSearchKeyword = "" 32 | 33 | @VisibleForTesting 34 | var page = 1 35 | 36 | @VisibleForTesting 37 | var endPage = false 38 | 39 | @OptIn(ExperimentalCoroutinesApi::class) 40 | override fun flowLoadData(searchKeyword: String, perPage: Int): Flow> = 41 | flow { 42 | if (cacheSearchKeyword == searchKeyword) { 43 | page++ 44 | } else { 45 | clear() 46 | } 47 | emit(true) 48 | } 49 | .map { 50 | gitHubApi.searchUser( 51 | searchKeyword = searchKeyword, 52 | page = page, 53 | perPage = perPage, 54 | ) 55 | } 56 | .onEach { 57 | cacheSearchKeyword = searchKeyword 58 | endPage = it.incompleteResults 59 | } 60 | .onEach { 61 | cacheList.addAll(it.items) 62 | } 63 | .map { cacheList } 64 | .flatMapLatest { items -> 65 | gitHubUserDao.flowLiked() 66 | .map { 67 | it to items 68 | } 69 | } 70 | .flatMapLatest { data -> 71 | sortList 72 | .map { data } 73 | } 74 | .map { (likedList, items) -> 75 | items.map { item -> 76 | GitHubUserEntity( 77 | id = item.id, 78 | login = item.login, 79 | avatarUrl = item.avatarUrl, 80 | score = item.score, 81 | isLike = likedList.firstOrNull { likedItem -> likedItem.id == item.id } != null, 82 | ) 83 | } 84 | } 85 | .map { newList -> 86 | newList.sort(sortList.value) 87 | } 88 | 89 | override fun flowLoadLikedData(): Flow> = 90 | gitHubUserDao 91 | .flowLiked() 92 | .map { 93 | it.map { item -> 94 | GitHubUserEntity( 95 | id = item.id, 96 | login = item.login, 97 | avatarUrl = item.avatarUrl, 98 | score = item.score, 99 | isLike = true, 100 | ) 101 | } 102 | } 103 | 104 | private fun List.sort(gitHubSortType: GitHubSortType): List = 105 | when (gitHubSortType) { 106 | GitHubSortType.FILTER_SORT_DEFAULT -> this 107 | GitHubSortType.FILTER_SORT_NAME -> this.sortedBy { it.login } 108 | GitHubSortType.FILTER_SORT_DATE_OF_REGISTRATION -> this.sortedBy { it.id } 109 | } 110 | 111 | override fun sortList(gitHubSortType: GitHubSortType) { 112 | sortList.value = gitHubSortType 113 | } 114 | 115 | override suspend fun likeUserInfo(item: GitHubUserEntity) { 116 | gitHubUserDao.insert( 117 | user = GitHubUser( 118 | id = item.id, 119 | login = item.login, 120 | avatarUrl = item.avatarUrl, 121 | score = item.score, 122 | ) 123 | ) 124 | } 125 | 126 | override suspend fun unlikeUserInfo(id: Int) { 127 | gitHubUserDao.deleteUser(id) 128 | } 129 | 130 | override fun clear() { 131 | cacheList.clear() 132 | endPage = false 133 | page = 1 134 | cacheSearchKeyword = "" 135 | } 136 | } -------------------------------------------------------------------------------- /core/data/github/src/main/java/tech/thdev/githubusersearch/data/github/di/GitHubApiModule.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.data.github.di 2 | 3 | import dagger.Module 4 | import dagger.Provides 5 | import dagger.hilt.InstallIn 6 | import dagger.hilt.components.SingletonComponent 7 | import retrofit2.Retrofit 8 | import tech.thdev.githubusersearch.data.github.GitHubApi 9 | 10 | @Module 11 | @InstallIn(SingletonComponent::class) 12 | class GitHubApiModule { 13 | 14 | @Provides 15 | fun provideGitHubApi( 16 | retrofit: Retrofit, 17 | ): GitHubApi = 18 | retrofit.create(GitHubApi::class.java) 19 | } -------------------------------------------------------------------------------- /core/data/github/src/main/java/tech/thdev/githubusersearch/data/github/di/GitHubSearchModule.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.data.github.di 2 | 3 | import dagger.Binds 4 | import dagger.Module 5 | import dagger.Reusable 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import tech.thdev.githubusersearch.data.github.GitHubSearchRepositoryImpl 9 | import tech.thdev.githubusersearch.domain.github.GitHubSearchRepository 10 | 11 | @Module 12 | @InstallIn(SingletonComponent::class) 13 | abstract class GitHubSearchModule { 14 | 15 | @Binds 16 | @Reusable 17 | abstract fun bindGitHubSearchRepository( 18 | gitHubSearchRepository: GitHubSearchRepositoryImpl, 19 | ): GitHubSearchRepository 20 | } -------------------------------------------------------------------------------- /core/data/github/src/main/java/tech/thdev/githubusersearch/data/github/model/GitHubUserResponse.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.data.github.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class GitHubUserResponse( 8 | @SerialName("total_count") val totalCount: Int = 0, 9 | @SerialName("incomplete_results") val incompleteResults: Boolean = false, 10 | @SerialName("items") val items: List = emptyList(), 11 | ) 12 | 13 | @Serializable 14 | data class GitHubUserInfoResponse( 15 | @SerialName("id") val id: Int = 0, 16 | @SerialName("login") val login: String = "", 17 | @SerialName("avatar_url") val avatarUrl: String = "", 18 | @SerialName("score") val score: Double = 0.0, 19 | ) -------------------------------------------------------------------------------- /core/data/github/src/test/java/tech/thdev/githubusersearch/data/github/GitHubSearchRepositoryImplTest.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.data.github 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.flow.flowOf 7 | import kotlinx.coroutines.test.TestDispatcher 8 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 9 | import kotlinx.coroutines.test.resetMain 10 | import kotlinx.coroutines.test.runTest 11 | import kotlinx.coroutines.test.setMain 12 | import org.junit.jupiter.api.AfterEach 13 | import org.junit.jupiter.api.Assertions 14 | import org.junit.jupiter.api.BeforeEach 15 | import org.junit.jupiter.api.Test 16 | import org.mockito.kotlin.mock 17 | import org.mockito.kotlin.verify 18 | import org.mockito.kotlin.whenever 19 | import tech.thdev.githubusersearch.data.github.model.GitHubUserInfoResponse 20 | import tech.thdev.githubusersearch.data.github.model.GitHubUserResponse 21 | import tech.thdev.githubusersearch.database.github.api.GitHubUserDao 22 | import tech.thdev.githubusersearch.database.github.api.model.GitHubUser 23 | import tech.thdev.githubusersearch.domain.github.model.GitHubSortType 24 | import tech.thdev.githubusersearch.domain.github.model.GitHubUserEntity 25 | 26 | @OptIn(ExperimentalCoroutinesApi::class) 27 | class GitHubSearchRepositoryImplTest { 28 | 29 | private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() 30 | 31 | private val gitHubApi = mock() 32 | private val gitHubUserDao = mock() 33 | 34 | private val repository = GitHubSearchRepositoryImpl( 35 | gitHubApi = gitHubApi, 36 | gitHubUserDao = gitHubUserDao, 37 | ) 38 | 39 | @BeforeEach 40 | fun beforeEach() { 41 | Dispatchers.setMain(dispatcher) 42 | } 43 | 44 | @AfterEach 45 | fun afterEach() { 46 | Dispatchers.resetMain() 47 | } 48 | 49 | 50 | @Test 51 | fun `test initData`() { 52 | Assertions.assertEquals(GitHubSortType.FILTER_SORT_DEFAULT, repository.sortList.value) 53 | Assertions.assertTrue(repository.cacheList.isEmpty()) 54 | Assertions.assertEquals("", repository.cacheSearchKeyword) 55 | Assertions.assertEquals(1, repository.page) 56 | Assertions.assertFalse(repository.endPage) 57 | } 58 | 59 | @Test 60 | fun `test flowLoadData`() = runTest { 61 | val keyword = "new" 62 | 63 | val mockResponse = GitHubUserResponse( 64 | incompleteResults = true, 65 | items = listOf( 66 | GitHubUserInfoResponse( 67 | login = "abc", 68 | ), 69 | ) 70 | ) 71 | whenever(gitHubApi.searchUser(searchKeyword = "new", page = 1, perPage = 30)).thenReturn(mockResponse) 72 | whenever(gitHubUserDao.flowLiked()).thenReturn(flowOf(emptyList())) 73 | 74 | repository.flowLoadData(keyword, 30) 75 | .test { 76 | val convert = listOf( 77 | GitHubUserEntity.Default.copy( 78 | login = "abc", 79 | ), 80 | ) 81 | Assertions.assertEquals(convert, awaitItem()) 82 | Assertions.assertEquals(mockResponse.items, repository.cacheList) 83 | Assertions.assertEquals(keyword, repository.cacheSearchKeyword) 84 | Assertions.assertEquals(1, repository.page) 85 | Assertions.assertTrue(repository.endPage) 86 | 87 | verify(gitHubApi).searchUser(searchKeyword = "new", page = 1, perPage = 30) 88 | verify(gitHubUserDao).flowLiked() 89 | 90 | cancelAndConsumeRemainingEvents() 91 | } 92 | } 93 | 94 | @Test 95 | fun `test flowLoadData - liked`() = runTest { 96 | val keyword = "new" 97 | 98 | val mockResponse = GitHubUserResponse( 99 | incompleteResults = true, 100 | items = listOf( 101 | GitHubUserInfoResponse( 102 | id = 12, 103 | login = "abc", 104 | ), 105 | ) 106 | ) 107 | whenever(gitHubApi.searchUser(searchKeyword = "new", page = 1, perPage = 30)).thenReturn(mockResponse) 108 | val mockLikedList = listOf( 109 | GitHubUser( 110 | id = 12, 111 | login = "abc", 112 | avatarUrl = "", 113 | score = 0.0, 114 | ) 115 | ) 116 | whenever(gitHubUserDao.flowLiked()).thenReturn(flowOf(mockLikedList)) 117 | 118 | repository.flowLoadData(keyword, 30) 119 | .test { 120 | val convert = listOf( 121 | GitHubUserEntity.Default.copy( 122 | id = 12, 123 | login = "abc", 124 | isLike = true, 125 | ), 126 | ) 127 | Assertions.assertEquals(convert, awaitItem()) 128 | Assertions.assertEquals(mockResponse.items, repository.cacheList) 129 | Assertions.assertEquals(keyword, repository.cacheSearchKeyword) 130 | Assertions.assertEquals(1, repository.page) 131 | Assertions.assertTrue(repository.endPage) 132 | 133 | verify(gitHubApi).searchUser(searchKeyword = "new", page = 1, perPage = 30) 134 | verify(gitHubUserDao).flowLiked() 135 | 136 | cancelAndConsumeRemainingEvents() 137 | } 138 | } 139 | 140 | @Test 141 | fun `test flowLoadData - sorted`() = runTest { 142 | val keyword = "new" 143 | 144 | repository.sortList.value = GitHubSortType.FILTER_SORT_DATE_OF_REGISTRATION 145 | 146 | val mockResponse = GitHubUserResponse( 147 | incompleteResults = true, 148 | items = listOf( 149 | GitHubUserInfoResponse( 150 | id = 22, 151 | login = "bbb", 152 | ), 153 | GitHubUserInfoResponse( 154 | id = 12, 155 | login = "abc", 156 | ), 157 | ) 158 | ) 159 | whenever(gitHubApi.searchUser(searchKeyword = "new", page = 1, perPage = 30)).thenReturn(mockResponse) 160 | val mockLikedList = listOf( 161 | GitHubUser( 162 | id = 12, 163 | login = "abc", 164 | avatarUrl = "", 165 | score = 0.0, 166 | ) 167 | ) 168 | whenever(gitHubUserDao.flowLiked()).thenReturn(flowOf(mockLikedList)) 169 | 170 | repository.flowLoadData(keyword, 30) 171 | .test { 172 | val convert = listOf( 173 | GitHubUserEntity.Default.copy( 174 | id = 12, 175 | login = "abc", 176 | isLike = true, 177 | ), 178 | GitHubUserEntity.Default.copy( 179 | id = 22, 180 | login = "bbb", 181 | isLike = false, 182 | ), 183 | ) 184 | Assertions.assertEquals(convert, awaitItem()) 185 | Assertions.assertEquals(mockResponse.items, repository.cacheList) 186 | Assertions.assertEquals(keyword, repository.cacheSearchKeyword) 187 | Assertions.assertEquals(1, repository.page) 188 | Assertions.assertTrue(repository.endPage) 189 | 190 | verify(gitHubApi).searchUser(searchKeyword = "new", page = 1, perPage = 30) 191 | verify(gitHubUserDao).flowLiked() 192 | 193 | cancelAndConsumeRemainingEvents() 194 | } 195 | } 196 | 197 | @Test 198 | fun `test flowLoadLikedData`() = runTest { 199 | val mockItem = listOf( 200 | GitHubUser( 201 | id = 0, 202 | login = "abc", 203 | avatarUrl = "", 204 | score = 0.0, 205 | ) 206 | ) 207 | whenever(gitHubUserDao.flowLiked()).thenReturn(flowOf(mockItem)) 208 | 209 | repository.flowLoadLikedData() 210 | .test { 211 | val convert = listOf( 212 | GitHubUserEntity.Default.copy( 213 | login = "abc", 214 | isLike = true, 215 | ), 216 | ) 217 | Assertions.assertEquals(convert, awaitItem()) 218 | 219 | verify(gitHubUserDao).flowLiked() 220 | 221 | cancelAndConsumeRemainingEvents() 222 | } 223 | } 224 | 225 | @Test 226 | fun `test sortList`() { 227 | repository.sortList(GitHubSortType.FILTER_SORT_NAME) 228 | Assertions.assertEquals(GitHubSortType.FILTER_SORT_NAME, repository.sortList.value) 229 | } 230 | 231 | @Test 232 | fun `test likeUserInfo`() = runTest { 233 | val mockItem = GitHubUserEntity.Default.copy(id = 12, login = "aaa") 234 | repository.likeUserInfo(mockItem) 235 | verify(gitHubUserDao).insert( 236 | GitHubUser( 237 | id = 12, 238 | login = "aaa", 239 | avatarUrl = "", 240 | score = 0.0, 241 | ) 242 | ) 243 | } 244 | 245 | @Test 246 | fun `test unlikeUserInfo`() = runTest { 247 | repository.unlikeUserInfo(12) 248 | verify(gitHubUserDao).deleteUser(12) 249 | } 250 | } -------------------------------------------------------------------------------- /core/database/github-api/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /core/database/github-api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinAndroid) 5 | alias(libs.plugins.androidHilt) 6 | alias(libs.plugins.ksp) 7 | } 8 | 9 | android { 10 | namespace = "tech.thdev.githubusersearch.database.github.api" 11 | compileSdk = libs.versions.compileSdk.get().toInt() 12 | 13 | defaultConfig { 14 | minSdk = libs.versions.minSdk.get().toInt() 15 | targetSdk = libs.versions.targetSdk.get().toInt() 16 | 17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | isMinifyEnabled = false 23 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility = JavaVersion.VERSION_17 28 | targetCompatibility = JavaVersion.VERSION_17 29 | } 30 | kotlinOptions { 31 | jvmTarget = "17" 32 | } 33 | packaging { 34 | resources { 35 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 36 | } 37 | } 38 | } 39 | 40 | dependencies { 41 | implementation(libs.kotlin.stdlib) 42 | 43 | implementation(libs.coroutines.core) 44 | 45 | implementation(libs.androidx.room) 46 | implementation(libs.androidx.room.runtime) 47 | ksp(libs.androidx.room.compiler) 48 | implementation(libs.androidx.hilt.android) 49 | ksp(libs.androidx.hilt.android.compiler) 50 | } -------------------------------------------------------------------------------- /core/database/github-api/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 22 | -------------------------------------------------------------------------------- /core/database/github-api/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/database/github-api/src/main/java/tech/thdev/githubusersearch/database/github/api/GitHubUserDao.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.database.github.api 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.Query 6 | import kotlinx.coroutines.flow.Flow 7 | import tech.thdev.githubusersearch.database.github.api.model.GitHubUser 8 | 9 | @Dao 10 | interface GitHubUserDao { 11 | 12 | @Insert 13 | suspend fun insert(user: GitHubUser) 14 | 15 | @Query("SELECT * FROM gitHubUser ORDER BY score DESC") 16 | fun flowLiked(): Flow> 17 | 18 | @Query("SELECT * FROM gitHubUser where login LIKE '%' || :login || '%' ORDER BY score DESC") 19 | fun flowSearchUser(login: String): Flow> 20 | 21 | @Query("DELETE FROM gitHubUser where id = :id") 22 | suspend fun deleteUser(id: Int) 23 | } -------------------------------------------------------------------------------- /core/database/github-api/src/main/java/tech/thdev/githubusersearch/database/github/api/model/GitHubUser.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.database.github.api.model 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity 8 | data class GitHubUser( 9 | @PrimaryKey val id: Int, 10 | @ColumnInfo(name = "login") val login: String, 11 | @ColumnInfo(name = "avatar_url") val avatarUrl: String, 12 | @ColumnInfo(name = "score") val score: Double, 13 | ) -------------------------------------------------------------------------------- /core/database/github/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /core/database/github/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinAndroid) 5 | alias(libs.plugins.androidHilt) 6 | alias(libs.plugins.ksp) 7 | } 8 | 9 | android { 10 | namespace = "tech.thdev.githubusersearch.database.github" 11 | compileSdk = libs.versions.compileSdk.get().toInt() 12 | 13 | defaultConfig { 14 | minSdk = libs.versions.minSdk.get().toInt() 15 | targetSdk = libs.versions.targetSdk.get().toInt() 16 | 17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | isMinifyEnabled = false 23 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility = JavaVersion.VERSION_17 28 | targetCompatibility = JavaVersion.VERSION_17 29 | } 30 | kotlinOptions { 31 | jvmTarget = "17" 32 | } 33 | packaging { 34 | resources { 35 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 36 | } 37 | } 38 | } 39 | 40 | dependencies { 41 | implementation(libs.kotlin.stdlib) 42 | 43 | implementation(libs.coroutines.core) 44 | 45 | implementation(libs.androidx.room) 46 | implementation(libs.androidx.room.runtime) 47 | ksp(libs.androidx.room.compiler) 48 | implementation(libs.androidx.hilt.android) 49 | ksp(libs.androidx.hilt.android.compiler) 50 | 51 | implementation(project(":core:database:github-api")) 52 | } -------------------------------------------------------------------------------- /core/database/github/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 22 | -------------------------------------------------------------------------------- /core/database/github/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/database/github/src/main/java/tech/thdev/githubusersearch/database/github/GitHubDatabase.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.database.github 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import tech.thdev.githubusersearch.database.github.api.GitHubUserDao 8 | import tech.thdev.githubusersearch.database.github.api.model.GitHubUser 9 | 10 | @Database(entities = [GitHubUser::class], version = 1) 11 | abstract class GitHubDatabase : RoomDatabase() { 12 | 13 | abstract fun gitHubUserDao(): GitHubUserDao 14 | 15 | companion object { 16 | 17 | // For Singleton instantiation 18 | private var instance: GitHubDatabase? = null 19 | 20 | fun getInstance(context: Context) = 21 | instance ?: synchronized(this) { 22 | instance ?: Room.databaseBuilder( 23 | context, 24 | GitHubDatabase::class.java, "github.db" 25 | ).build() 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /core/database/github/src/main/java/tech/thdev/githubusersearch/database/github/di/GitHubLocalDatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.database.github.di 2 | 3 | import android.content.Context 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.android.qualifiers.ApplicationContext 8 | import dagger.hilt.components.SingletonComponent 9 | import javax.inject.Singleton 10 | import tech.thdev.githubusersearch.database.github.GitHubDatabase 11 | import tech.thdev.githubusersearch.database.github.api.GitHubUserDao 12 | 13 | @Module 14 | @InstallIn(SingletonComponent::class) 15 | object GitHubLocalDatabaseModule { 16 | 17 | @Singleton 18 | @Provides 19 | fun provideGitHubDatabase( 20 | @ApplicationContext context: Context 21 | ): GitHubDatabase { 22 | return GitHubDatabase.getInstance(context) 23 | } 24 | 25 | @Provides 26 | fun provideGitHubUserDao(gitHubDatabase: GitHubDatabase): GitHubUserDao { 27 | return gitHubDatabase.gitHubUserDao() 28 | } 29 | } -------------------------------------------------------------------------------- /core/design-system/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/design-system/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinAndroid) 5 | } 6 | 7 | android { 8 | namespace = "tech.thdev.githubusersearch.design.system" 9 | compileSdk = libs.versions.compileSdk.get().toInt() 10 | 11 | defaultConfig { 12 | minSdk = libs.versions.minSdk.get().toInt() 13 | targetSdk = libs.versions.targetSdk.get().toInt() 14 | 15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 16 | vectorDrawables { 17 | useSupportLibrary = true 18 | } 19 | } 20 | 21 | buildTypes { 22 | release { 23 | isMinifyEnabled = false 24 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 25 | } 26 | } 27 | compileOptions { 28 | sourceCompatibility = JavaVersion.VERSION_17 29 | targetCompatibility = JavaVersion.VERSION_17 30 | } 31 | kotlinOptions { 32 | jvmTarget = "17" 33 | } 34 | tasks.withType { 35 | useJUnitPlatform() 36 | } 37 | buildFeatures { 38 | compose = true 39 | } 40 | composeOptions { 41 | kotlinCompilerExtensionVersion = libs.versions.compose.compilerVersion.get() 42 | } 43 | packaging { 44 | resources { 45 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 46 | } 47 | } 48 | } 49 | 50 | dependencies { 51 | implementation(libs.kotlin.stdlib) 52 | implementation(libs.androidx.appCompat) 53 | 54 | implementation(libs.google.material) 55 | 56 | implementation(libs.compose.ui) 57 | implementation(libs.compose.foundation) 58 | implementation(libs.compose.material3) 59 | implementation(libs.compose.runtime) 60 | implementation(libs.compose.uiToolingPreview) 61 | implementation(libs.compose.constraintLayout) 62 | implementation(libs.compose.animation) 63 | implementation(libs.coil) 64 | implementation(libs.androidx.lifecycleRuntimeCompose) 65 | 66 | implementation(libs.compose.keyboardState) 67 | implementation(libs.kotlin.collectionsImmutable) 68 | 69 | debugRuntimeOnly(libs.compose.uiTooling) 70 | } -------------------------------------------------------------------------------- /core/design-system/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 -------------------------------------------------------------------------------- /core/design-system/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/design-system/src/main/java/tech/thdev/githubusersearch/design/system/AsyncImageComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.design.system 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.ui.Alignment 5 | import androidx.compose.ui.Modifier 6 | import androidx.compose.ui.graphics.FilterQuality 7 | import androidx.compose.ui.layout.ContentScale 8 | import androidx.compose.ui.platform.LocalContext 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import coil.ImageLoader 11 | import coil.compose.AsyncImage 12 | import coil.compose.AsyncImagePainter 13 | import coil.imageLoader 14 | import coil.request.CachePolicy 15 | import coil.request.ImageRequest 16 | import coil.size.Size 17 | 18 | @Composable 19 | internal fun GitAsyncImage( 20 | modifier: Modifier = Modifier, 21 | model: Any?, 22 | onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null, 23 | onError: (() -> Unit)? = null, 24 | contentDescription: String? = null, 25 | imageLoader: ImageLoader = LocalContext.current.imageLoader, 26 | alignment: Alignment = Alignment.Center, 27 | contentScale: ContentScale = ContentScale.Fit, 28 | filterQuality: FilterQuality = FilterQuality.None, 29 | ) { 30 | AsyncImage( 31 | model = model, 32 | contentDescription = contentDescription, 33 | alignment = alignment, 34 | contentScale = contentScale, 35 | filterQuality = filterQuality, 36 | imageLoader = imageLoader, 37 | onSuccess = { 38 | onSuccess?.invoke(it) 39 | }, 40 | onError = { 41 | onError?.invoke() 42 | }, 43 | modifier = modifier 44 | ) 45 | } 46 | 47 | /** 48 | * coil을 이용한 AsyncImage 매핑 49 | */ 50 | @Composable 51 | fun GitAsyncImage( 52 | modifier: Modifier = Modifier, 53 | imageUrl: Any?, 54 | size: Size = Size.ORIGINAL, 55 | placeholder: Int = 0, 56 | imageLoader: ImageLoader = LocalContext.current.imageLoader, 57 | onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null, 58 | onError: (() -> Unit)? = null, 59 | contentDescription: String? = null, 60 | alignment: Alignment = Alignment.Center, 61 | contentScale: ContentScale = ContentScale.Fit, 62 | filterQuality: FilterQuality = FilterQuality.Medium, 63 | ) { 64 | GitAsyncImage( 65 | model = ImageRequest.Builder(LocalContext.current) 66 | .data(data = imageUrl) 67 | .placeholder(placeholder) 68 | .crossfade(true) 69 | .size(size) 70 | .error(placeholder) 71 | .memoryCachePolicy(CachePolicy.ENABLED) 72 | .diskCachePolicy(CachePolicy.ENABLED) 73 | .networkCachePolicy(CachePolicy.ENABLED) 74 | .build(), 75 | imageLoader = imageLoader, 76 | contentDescription = contentDescription, 77 | alignment = alignment, 78 | contentScale = contentScale, 79 | filterQuality = filterQuality, 80 | onSuccess = { 81 | onSuccess?.invoke(it) 82 | }, 83 | onError = { 84 | onError?.invoke() 85 | }, 86 | modifier = modifier 87 | ) 88 | } 89 | 90 | @Preview 91 | @Composable 92 | internal fun PreviewGitAsyncImage() { 93 | GitAsyncImage( 94 | imageUrl = "", 95 | placeholder = R.drawable.ic_collections_bookmark_black_24dp, 96 | ) 97 | } -------------------------------------------------------------------------------- /core/design-system/src/main/java/tech/thdev/githubusersearch/design/system/GitScaffoldComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.design.system 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.Scaffold 8 | import androidx.compose.material3.ScaffoldDefaults 9 | import androidx.compose.material3.contentColorFor 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.CompositionLocalProvider 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import tech.thdev.compose.extensions.keyboard.state.MutableExKeyboardStateSource 15 | import tech.thdev.compose.extensions.keyboard.state.foundation.removeFocusWhenKeyboardIsHidden 16 | import tech.thdev.compose.extensions.keyboard.state.localowners.LocalMutableExKeyboardStateSourceOwner 17 | 18 | @Composable 19 | fun GitScaffold( 20 | modifier: Modifier = Modifier, 21 | topBar: @Composable () -> Unit = {}, 22 | bottomBar: @Composable () -> Unit = {}, 23 | floatingActionButton: @Composable () -> Unit = {}, 24 | containerColor: Color = MaterialTheme.colorScheme.background, 25 | contentColor: Color = contentColorFor(containerColor), 26 | contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, 27 | content: @Composable () -> Unit 28 | ) { 29 | CompositionLocalProvider( 30 | LocalMutableExKeyboardStateSourceOwner provides MutableExKeyboardStateSource() 31 | ) { 32 | Scaffold( 33 | topBar = topBar, 34 | bottomBar = bottomBar, 35 | floatingActionButton = floatingActionButton, 36 | containerColor = containerColor, 37 | contentColor = contentColor, 38 | contentWindowInsets = contentWindowInsets, 39 | modifier = modifier 40 | .removeFocusWhenKeyboardIsHidden() 41 | ) { 42 | Box( 43 | modifier = Modifier 44 | .padding(it) 45 | ) { 46 | content() 47 | } 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /core/design-system/src/main/java/tech/thdev/githubusersearch/design/system/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.design.system.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) 12 | 13 | val Purple200 = Color(0xFFBB86FC) 14 | val Purple500 = Color(0xFF6200EE) 15 | val Purple700 = Color(0xFF3700B3) 16 | val Teal200 = Color(0xFF03DAC5) 17 | val Teal700 = Color(0xFF018786) 18 | val Black = Color(0xFF000000) 19 | val White = Color(0xFFFFFFFF) 20 | 21 | val ColorTransparent = Color(0x00000000) 22 | val ColorPrimary = Color(0xFF212121) 23 | val ColorPrimaryDark = Color(0xFF000000) 24 | val ColorAccent = Color(0xFFFFFFFF) 25 | 26 | val ColorLoadingBackground = Color(66000000) -------------------------------------------------------------------------------- /core/design-system/src/main/java/tech/thdev/githubusersearch/design/system/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.design.system.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 MyApplicationTheme( 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.not()) { 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 | } -------------------------------------------------------------------------------- /core/design-system/src/main/java/tech/thdev/githubusersearch/design/system/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.design.system.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 | ) -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_close_white_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_collections_bookmark_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_error_outline_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_favorite_border_red_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_favorite_red_24dp.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_search_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_sort.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_sort_alphabet.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/drawable/ic_sort_numbers.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | 11 | #00000000 12 | #212121 13 | #000000 14 | #ffffff 15 | 16 | #66000000 17 | 18 | #757575 19 | #dedede 20 | 21 | #dadada 22 | #494949 23 | 24 | #eaeaea 25 | #838383 26 | #66ffffff 27 | #44000000 28 | #dd000000 29 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | GithubUserSearch 3 | 4 | Search 5 | Liked 6 | Search github user name. 7 | Empty liked list 8 | 9 | There was a problem with the network connection. 10 | There was a problem with unknown error. 11 | retry 12 | User score %,.2f 13 | 14 | Sort default 15 | Sort A-z 16 | Sort Date 17 | No result github user name. 18 | 19 | -------------------------------------------------------------------------------- /core/design-system/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | 17 | 21 | -------------------------------------------------------------------------------- /core/domain/github/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /core/domain/github/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinAndroid) 5 | } 6 | 7 | android { 8 | namespace = "tech.thdev.githubusersearch.domain.github" 9 | compileSdk = libs.versions.compileSdk.get().toInt() 10 | 11 | defaultConfig { 12 | minSdk = libs.versions.minSdk.get().toInt() 13 | targetSdk = libs.versions.targetSdk.get().toInt() 14 | 15 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | 18 | buildTypes { 19 | release { 20 | isMinifyEnabled = false 21 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 22 | } 23 | } 24 | compileOptions { 25 | sourceCompatibility = JavaVersion.VERSION_17 26 | targetCompatibility = JavaVersion.VERSION_17 27 | } 28 | kotlinOptions { 29 | jvmTarget = "17" 30 | } 31 | packaging { 32 | resources { 33 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 34 | } 35 | } 36 | } 37 | 38 | dependencies { 39 | implementation(libs.kotlin.stdlib) 40 | 41 | implementation(libs.coroutines.core) 42 | } -------------------------------------------------------------------------------- /core/domain/github/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 22 | -------------------------------------------------------------------------------- /core/domain/github/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/domain/github/src/main/java/tech/thdev/githubusersearch/domain/github/GitHubSearchRepository.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.domain.github 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import tech.thdev.githubusersearch.domain.github.model.GitHubSortType 5 | import tech.thdev.githubusersearch.domain.github.model.GitHubUserEntity 6 | 7 | interface GitHubSearchRepository { 8 | 9 | fun flowLoadData(searchKeyword: String, perPage: Int): Flow> 10 | 11 | fun sortList(gitHubSortType: GitHubSortType) 12 | 13 | fun flowLoadLikedData(): Flow> 14 | 15 | /** 16 | * 좋아요 상태 변경 17 | */ 18 | suspend fun likeUserInfo(item: GitHubUserEntity) 19 | 20 | suspend fun unlikeUserInfo(id: Int) 21 | 22 | fun clear() 23 | } -------------------------------------------------------------------------------- /core/domain/github/src/main/java/tech/thdev/githubusersearch/domain/github/model/GitHubSortType.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.domain.github.model 2 | 3 | enum class GitHubSortType { 4 | FILTER_SORT_DEFAULT, // default sort - best match 5 | FILTER_SORT_NAME, // sort name 6 | FILTER_SORT_DATE_OF_REGISTRATION // user id 7 | } -------------------------------------------------------------------------------- /core/domain/github/src/main/java/tech/thdev/githubusersearch/domain/github/model/GitHubUserEntity.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.domain.github.model 2 | 3 | data class GitHubUserEntity( 4 | val id: Int, 5 | val login: String, 6 | val avatarUrl: String, 7 | val score: Double, 8 | val isLike: Boolean, 9 | ) { 10 | 11 | companion object { 12 | 13 | val Default = GitHubUserEntity( 14 | login = "", 15 | id = 0, 16 | avatarUrl = "", 17 | score = 0.0, 18 | isLike = false, 19 | ) 20 | } 21 | } -------------------------------------------------------------------------------- /core/network/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /core/network/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinAndroid) 5 | alias(libs.plugins.androidHilt) 6 | alias(libs.plugins.ksp) 7 | id("kotlinx-serialization") 8 | } 9 | 10 | android { 11 | namespace = "tech.thdev.githubusersearch.network" 12 | compileSdk = libs.versions.compileSdk.get().toInt() 13 | 14 | defaultConfig { 15 | minSdk = libs.versions.minSdk.get().toInt() 16 | targetSdk = libs.versions.targetSdk.get().toInt() 17 | 18 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 19 | } 20 | 21 | buildTypes { 22 | release { 23 | isMinifyEnabled = false 24 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 25 | } 26 | } 27 | compileOptions { 28 | sourceCompatibility = JavaVersion.VERSION_17 29 | targetCompatibility = JavaVersion.VERSION_17 30 | } 31 | kotlinOptions { 32 | jvmTarget = "17" 33 | } 34 | tasks.withType { 35 | useJUnitPlatform() 36 | } 37 | packaging { 38 | resources { 39 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 40 | } 41 | } 42 | } 43 | 44 | dependencies { 45 | implementation(libs.kotlin.stdlib) 46 | implementation(libs.kotlin.serializationJson) 47 | 48 | implementation(libs.androidx.hilt.android) 49 | ksp(libs.androidx.hilt.android.compiler) 50 | 51 | implementation(libs.network.retrofit) 52 | implementation(libs.network.retrofit.kotlinxSerializationConvert) 53 | implementation(libs.network.okhttp) 54 | implementation(libs.network.okhttp.logging) 55 | } -------------------------------------------------------------------------------- /core/network/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 22 | -------------------------------------------------------------------------------- /core/network/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /core/network/src/main/java/tech/thdev/githubusersearch/network/di/NetworkModule.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.network.di 2 | 3 | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory 4 | import dagger.Module 5 | import dagger.Provides 6 | import dagger.hilt.InstallIn 7 | import dagger.hilt.components.SingletonComponent 8 | import java.util.concurrent.TimeUnit 9 | import kotlinx.serialization.json.Json 10 | import okhttp3.MediaType.Companion.toMediaType 11 | import okhttp3.OkHttpClient 12 | import okhttp3.logging.HttpLoggingInterceptor 13 | import retrofit2.Retrofit 14 | 15 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | object NetworkModule { 18 | 19 | private const val REQUEST_TIME_OUT = 60L 20 | 21 | private const val BASE_URL = "https://api.github.com" 22 | 23 | @Provides 24 | fun provideJson(): Json = 25 | Json { 26 | coerceInputValues = true 27 | ignoreUnknownKeys = true 28 | } 29 | 30 | @Provides 31 | fun provideOkHttpClient(): OkHttpClient = 32 | OkHttpClient.Builder().apply { 33 | addInterceptor(HttpLoggingInterceptor { 34 | println(it) 35 | }.setLevel(HttpLoggingInterceptor.Level.BODY)) 36 | connectTimeout(REQUEST_TIME_OUT, TimeUnit.SECONDS) 37 | readTimeout(REQUEST_TIME_OUT, TimeUnit.SECONDS) 38 | writeTimeout(REQUEST_TIME_OUT, TimeUnit.SECONDS) 39 | }.build() 40 | 41 | @Provides 42 | fun provideRetrofit( 43 | json: Json, 44 | okHttpClient: OkHttpClient, 45 | ): Retrofit = 46 | Retrofit.Builder() 47 | .baseUrl(BASE_URL) 48 | .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) 49 | .client(okHttpClient) 50 | .build() 51 | } -------------------------------------------------------------------------------- /feature/github-search/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /feature/github-search/build.gradle.kts: -------------------------------------------------------------------------------- 1 | @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed 2 | plugins { 3 | alias(libs.plugins.androidLibrary) 4 | alias(libs.plugins.kotlinAndroid) 5 | alias(libs.plugins.androidHilt) 6 | alias(libs.plugins.ksp) 7 | } 8 | 9 | android { 10 | namespace = "tech.thdev.githubusersearch.feature.github" 11 | compileSdk = libs.versions.compileSdk.get().toInt() 12 | 13 | defaultConfig { 14 | minSdk = libs.versions.minSdk.get().toInt() 15 | targetSdk = libs.versions.targetSdk.get().toInt() 16 | 17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 18 | } 19 | 20 | buildTypes { 21 | release { 22 | isMinifyEnabled = false 23 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") 24 | } 25 | } 26 | compileOptions { 27 | sourceCompatibility = JavaVersion.VERSION_17 28 | targetCompatibility = JavaVersion.VERSION_17 29 | } 30 | kotlinOptions { 31 | jvmTarget = "17" 32 | } 33 | tasks.withType { 34 | useJUnitPlatform() 35 | } 36 | buildFeatures { 37 | compose = true 38 | } 39 | composeOptions { 40 | kotlinCompilerExtensionVersion = libs.versions.compose.compilerVersion.get() 41 | } 42 | packaging { 43 | resources { 44 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 45 | } 46 | } 47 | } 48 | 49 | dependencies { 50 | implementation(libs.kotlin.stdlib) 51 | 52 | implementation(libs.coroutines.android) 53 | 54 | implementation(libs.androidx.appCompat) 55 | implementation(libs.androidx.activity) 56 | implementation(libs.androidx.core) 57 | implementation(libs.androidx.lifecycle.viewModel) 58 | implementation(libs.androidx.hilt.android) 59 | ksp(libs.androidx.hilt.android.compiler) 60 | implementation(libs.androidx.hilt.navigation.compose) 61 | 62 | implementation(libs.google.material) 63 | 64 | implementation(libs.compose.activity) 65 | implementation(libs.compose.ui) 66 | implementation(libs.compose.foundation) 67 | implementation(libs.compose.material3) 68 | implementation(libs.compose.runtime) 69 | implementation(libs.compose.uiToolingPreview) 70 | implementation(libs.compose.constraintLayout) 71 | implementation(libs.compose.animation) 72 | implementation(libs.compose.navigation) 73 | implementation(libs.androidx.lifecycleRuntimeCompose) 74 | 75 | implementation(libs.coil) 76 | implementation(libs.compose.keyboardState) 77 | 78 | implementation(libs.kotlin.collectionsImmutable) 79 | 80 | debugRuntimeOnly(libs.compose.uiTooling) 81 | 82 | implementation(project(":core:design-system")) 83 | implementation(project(":core:design-system")) 84 | implementation(project(":core:domain:github")) 85 | 86 | libs.test.run { 87 | testImplementation(androidx.core) 88 | testImplementation(androidx.runner) 89 | testImplementation(androidx.junit) 90 | testImplementation(mockito.kotlin) 91 | testImplementation(junit5) 92 | testImplementation(junit5.engine) 93 | testRuntimeOnly(junit5.vintage) 94 | testImplementation(coroutines) 95 | testImplementation(coroutines.turbine) 96 | } 97 | } -------------------------------------------------------------------------------- /feature/github-search/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 22 | -------------------------------------------------------------------------------- /feature/github-search/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github 2 | 3 | import android.os.Bundle 4 | import androidx.activity.compose.setContent 5 | import androidx.activity.viewModels 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.compose.material3.Icon 8 | import androidx.compose.material3.NavigationBar 9 | import androidx.compose.material3.NavigationBarItem 10 | import androidx.compose.material3.Text 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.getValue 13 | import androidx.compose.ui.res.painterResource 14 | import androidx.compose.ui.res.stringResource 15 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 16 | import androidx.navigation.compose.rememberNavController 17 | import dagger.hilt.android.AndroidEntryPoint 18 | import javax.inject.Inject 19 | import kotlinx.collections.immutable.persistentMapOf 20 | import tech.thdev.githubusersearch.design.system.theme.MyApplicationTheme 21 | import tech.thdev.githubusersearch.feature.github.compose.MainScreen 22 | import tech.thdev.githubusersearch.feature.github.holder.like.LikeComposableHolder 23 | import tech.thdev.githubusersearch.feature.github.holder.like.LikeViewModel 24 | import tech.thdev.githubusersearch.feature.github.holder.search.SearchComposableHolder 25 | import tech.thdev.githubusersearch.feature.github.holder.search.SearchViewModel 26 | 27 | @AndroidEntryPoint 28 | class MainActivity : AppCompatActivity() { 29 | 30 | private val mainViewModel by viewModels() 31 | 32 | @Inject 33 | lateinit var searchViewModelFactory: SearchViewModel.SearchAssistedFactory 34 | 35 | private val searchViewModel by viewModels { 36 | SearchViewModel.provideFactory(searchViewModelFactory, false) 37 | } 38 | 39 | @Inject 40 | lateinit var likeViewModelFactory: LikeViewModel.LikeAssistedFactory 41 | 42 | private val likeViewModel by viewModels { 43 | LikeViewModel.provideFactory(likeViewModelFactory, false) 44 | } 45 | 46 | override fun onCreate(savedInstanceState: Bundle?) { 47 | super.onCreate(savedInstanceState) 48 | setContent { 49 | MyApplicationTheme { 50 | val navController = rememberNavController() 51 | val mainUiState by mainViewModel.mainUiState.collectAsStateWithLifecycle() 52 | 53 | val bottomBar: @Composable () -> Unit = { 54 | NavigationBar { 55 | mainUiState.bottomItems.forEach { item -> 56 | NavigationBarItem( 57 | selected = item.selected, 58 | onClick = { 59 | mainViewModel.bottomChange(item.viewType) 60 | }, 61 | icon = { 62 | Icon( 63 | painter = painterResource(id = item.icon), 64 | contentDescription = null, 65 | ) 66 | }, 67 | label = { 68 | Text(text = stringResource(id = item.title)) 69 | } 70 | ) 71 | } 72 | } 73 | } 74 | 75 | MainScreen( 76 | navController = navController, 77 | hostMap = persistentMapOf( 78 | ViewType.SEARCH to { 79 | SearchComposableHolder( 80 | bottomBar = bottomBar, 81 | searchViewModel = searchViewModel, 82 | ) 83 | }, 84 | ViewType.LIKED to { 85 | LikeComposableHolder( 86 | bottomBar = bottomBar, 87 | likeViewModel = likeViewModel, 88 | ) 89 | }, 90 | ), 91 | ) 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github 2 | 3 | import androidx.lifecycle.ViewModel 4 | import dagger.hilt.android.lifecycle.HiltViewModel 5 | import javax.inject.Inject 6 | import kotlinx.collections.immutable.persistentListOf 7 | import kotlinx.collections.immutable.toPersistentList 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import tech.thdev.githubusersearch.design.system.R 12 | import tech.thdev.githubusersearch.feature.github.model.MainUiState 13 | 14 | @HiltViewModel 15 | class MainViewModel @Inject constructor() : ViewModel() { 16 | 17 | private val _selectNavigation = MutableStateFlow(ViewType.SEARCH) 18 | val selectNavigation: StateFlow get() = _selectNavigation.asStateFlow() 19 | 20 | private val _mainUiState = MutableStateFlow( 21 | MainUiState( 22 | bottomItems = persistentListOf( 23 | MainUiState.Bottom( 24 | title = R.string.title_search, 25 | selected = true, 26 | icon = R.drawable.ic_search_black_24dp, 27 | viewType = ViewType.SEARCH, 28 | ), 29 | MainUiState.Bottom( 30 | title = R.string.title_liked, 31 | selected = false, 32 | icon = R.drawable.ic_collections_bookmark_black_24dp, 33 | viewType = ViewType.LIKED, 34 | ), 35 | ) 36 | ) 37 | ) 38 | val mainUiState: StateFlow get() = _mainUiState.asStateFlow() 39 | 40 | fun bottomChange(viewType: ViewType) { 41 | _mainUiState.value = mainUiState.value.copy( 42 | bottomItems = mainUiState.value.bottomItems.map { item -> 43 | if (viewType == item.viewType) { 44 | item.copy(selected = true) 45 | } else { 46 | item.copy(selected = false) 47 | } 48 | }.toPersistentList() 49 | ) 50 | _selectNavigation.value = viewType 51 | } 52 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/ViewType.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github 2 | 3 | enum class ViewType { 4 | SEARCH, 5 | LIKED 6 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/compose/MainScreen.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.compose 2 | 3 | import androidx.compose.animation.EnterTransition 4 | import androidx.compose.animation.ExitTransition 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.LaunchedEffect 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.ui.tooling.preview.Preview 10 | import androidx.hilt.navigation.compose.hiltViewModel 11 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 12 | import androidx.navigation.NavHostController 13 | import androidx.navigation.compose.NavHost 14 | import androidx.navigation.compose.composable 15 | import androidx.navigation.compose.rememberNavController 16 | import kotlinx.collections.immutable.PersistentMap 17 | import kotlinx.collections.immutable.persistentMapOf 18 | import tech.thdev.githubusersearch.feature.github.MainViewModel 19 | import tech.thdev.githubusersearch.feature.github.ViewType 20 | 21 | @Composable 22 | internal fun MainScreen( 23 | hostMap: PersistentMap Unit>, 24 | navController: NavHostController = rememberNavController(), 25 | mainViewModel: MainViewModel = hiltViewModel(), 26 | ) { 27 | val selectNavigation by mainViewModel.selectNavigation.collectAsStateWithLifecycle() 28 | 29 | MainScreen( 30 | hostMap = hostMap, 31 | navController = navController, 32 | selectNavigation = selectNavigation, 33 | ) 34 | } 35 | 36 | @Composable 37 | private fun MainScreen( 38 | selectNavigation: ViewType, 39 | hostMap: PersistentMap Unit>, 40 | navController: NavHostController = rememberNavController(), 41 | ) { 42 | LaunchedEffect(selectNavigation) { 43 | navController.navigate(selectNavigation.name) 44 | } 45 | 46 | NavHost( 47 | navController = navController, 48 | startDestination = selectNavigation.name, 49 | enterTransition = { 50 | EnterTransition.None 51 | }, 52 | exitTransition = { 53 | ExitTransition.None 54 | }, 55 | ) { 56 | hostMap.forEach { (viewType, view) -> 57 | composable(viewType.name) { 58 | view() 59 | } 60 | } 61 | } 62 | } 63 | 64 | @Preview( 65 | showBackground = true, 66 | backgroundColor = 0xFFFFFFFF, 67 | ) 68 | @Composable 69 | internal fun PreviewMainScreen() { 70 | MainScreen( 71 | selectNavigation = ViewType.SEARCH, 72 | hostMap = persistentMapOf( 73 | ViewType.SEARCH to { 74 | Text(ViewType.SEARCH.name) 75 | }, 76 | ViewType.LIKED to { 77 | Text(ViewType.LIKED.name) 78 | }, 79 | ), 80 | ) 81 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/compose/component/ResultEmptyComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.compose.component 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.padding 5 | import androidx.compose.material3.Text 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.ui.Alignment 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.compose.ui.tooling.preview.Preview 11 | import androidx.compose.ui.unit.dp 12 | import androidx.compose.ui.unit.sp 13 | import tech.thdev.githubusersearch.design.system.R 14 | 15 | @Composable 16 | internal fun ResultEmpty( 17 | modifier: Modifier = Modifier, 18 | message: String?, 19 | ) { 20 | Box( 21 | contentAlignment = Alignment.Center, 22 | modifier = modifier 23 | ) { 24 | Text( 25 | text = message ?: stringResource(id = R.string.message_no_result_user_name), 26 | fontSize = 16.sp, 27 | modifier = Modifier 28 | .padding(20.dp) 29 | ) 30 | } 31 | } 32 | 33 | @Preview( 34 | showBackground = true, 35 | backgroundColor = 0xFFFFFFFF, 36 | ) 37 | @Composable 38 | internal fun PreviewResultEmpty() { 39 | ResultEmpty( 40 | message = null, 41 | ) 42 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/compose/component/ResultItemComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.compose.component 2 | 3 | import androidx.compose.foundation.Image 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.Column 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.layout.size 11 | import androidx.compose.material3.HorizontalDivider 12 | import androidx.compose.material3.Text 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.painterResource 16 | import androidx.compose.ui.text.style.TextOverflow 17 | import androidx.compose.ui.tooling.preview.Preview 18 | import androidx.compose.ui.unit.dp 19 | import androidx.compose.ui.unit.sp 20 | import tech.thdev.githubusersearch.design.system.GitAsyncImage 21 | import tech.thdev.githubusersearch.design.system.R 22 | 23 | @Composable 24 | internal fun ResultItem( 25 | login: String, 26 | avatarUrl: String, 27 | score: Double, 28 | isLike: Boolean, 29 | onClick: () -> Unit, 30 | ) { 31 | Box( 32 | modifier = Modifier 33 | .fillMaxWidth() 34 | .clickable(onClick = onClick) 35 | ) { 36 | Column { 37 | Row( 38 | modifier = Modifier 39 | .padding(10.dp) 40 | ) { 41 | GitAsyncImage( 42 | imageUrl = avatarUrl, 43 | modifier = Modifier 44 | .size(65.dp) 45 | ) 46 | 47 | Column( 48 | modifier = Modifier 49 | .weight(1f) 50 | .padding(start = 12.dp, end = 12.dp) 51 | ) { 52 | Text( 53 | text = login, 54 | fontSize = 25.sp, 55 | overflow = TextOverflow.Ellipsis, 56 | ) 57 | Text( 58 | text = score.toString(), 59 | fontSize = 16.sp, 60 | overflow = TextOverflow.Ellipsis, 61 | ) 62 | } 63 | 64 | if (isLike) { 65 | Image( 66 | painter = painterResource(id = R.drawable.ic_favorite_red_24dp), 67 | contentDescription = "liked", 68 | ) 69 | } else { 70 | Image( 71 | painter = painterResource(id = R.drawable.ic_favorite_border_red_24dp), 72 | contentDescription = "unliked", 73 | ) 74 | } 75 | } 76 | 77 | HorizontalDivider() 78 | } 79 | } 80 | } 81 | 82 | @Preview( 83 | showBackground = true, 84 | backgroundColor = 0xFFFFFFFF, 85 | ) 86 | @Composable 87 | internal fun PreviewResultItem() { 88 | Column { 89 | ResultItem( 90 | login = "User name", 91 | avatarUrl = "", 92 | score = 0.0, 93 | isLike = true, 94 | onClick = {}, 95 | ) 96 | 97 | ResultItem( 98 | login = "User name two", 99 | avatarUrl = "", 100 | score = 0.0, 101 | isLike = false, 102 | onClick = {}, 103 | ) 104 | } 105 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/like/LikeComposableHolder.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.like 2 | 3 | import androidx.compose.material3.ExperimentalMaterial3Api 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TopAppBar 6 | import androidx.compose.runtime.Composable 7 | import tech.thdev.githubusersearch.design.system.GitScaffold 8 | import tech.thdev.githubusersearch.feature.github.holder.like.compose.LikeScreen 9 | 10 | @OptIn(ExperimentalMaterial3Api::class) 11 | @Composable 12 | internal fun LikeComposableHolder( 13 | bottomBar: @Composable () -> Unit, 14 | likeViewModel: LikeViewModel, 15 | ) { 16 | GitScaffold( 17 | topBar = { 18 | TopAppBar( 19 | title = { 20 | Text(text = "GitHubUserSearch") 21 | }, 22 | ) 23 | }, 24 | bottomBar = bottomBar, 25 | ) { 26 | LikeScreen(likeViewModel = likeViewModel) 27 | } 28 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/like/LikeViewModel.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.like 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.lifecycle.viewModelScope 7 | import dagger.assisted.Assisted 8 | import dagger.assisted.AssistedFactory 9 | import dagger.assisted.AssistedInject 10 | import javax.inject.Named 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlinx.coroutines.flow.asStateFlow 16 | import kotlinx.coroutines.flow.flowOn 17 | import kotlinx.coroutines.flow.launchIn 18 | import kotlinx.coroutines.flow.map 19 | import kotlinx.coroutines.flow.onEach 20 | import kotlinx.coroutines.launch 21 | import tech.thdev.githubusersearch.domain.github.GitHubSearchRepository 22 | import tech.thdev.githubusersearch.feature.github.holder.like.model.LikeUiState 23 | import tech.thdev.githubusersearch.feature.github.holder.like.model.convert.convert 24 | 25 | class LikeViewModel @AssistedInject constructor( 26 | private val gitHubSearchRepository: GitHubSearchRepository, 27 | @Assisted isTest: Boolean = false, 28 | ) : ViewModel() { 29 | 30 | @AssistedFactory 31 | interface LikeAssistedFactory { 32 | 33 | fun create(@Named("is_test") isTest: Boolean): LikeViewModel 34 | } 35 | 36 | private val _likeUiState = MutableStateFlow(LikeUiState.Empty.Default) 37 | val likeUiState: StateFlow get() = _likeUiState.asStateFlow() 38 | 39 | init { 40 | if (isTest.not()) { 41 | loadData() 42 | .launchIn(viewModelScope) 43 | } 44 | } 45 | 46 | @VisibleForTesting 47 | fun loadData(): Flow = 48 | gitHubSearchRepository.flowLoadLikedData() 49 | .flowOn(Dispatchers.Default) 50 | .map { 51 | it.convert() 52 | } 53 | .onEach { 54 | _likeUiState.value = it 55 | } 56 | 57 | fun selectedLikeChange(item: LikeUiState.UserItems.Info) = viewModelScope.launch { 58 | gitHubSearchRepository.unlikeUserInfo(id = item.id) 59 | } 60 | 61 | companion object { 62 | 63 | fun provideFactory( 64 | assistedFactory: LikeAssistedFactory, 65 | isTest: Boolean, 66 | ): ViewModelProvider.Factory = 67 | object : ViewModelProvider.Factory { 68 | 69 | override fun create(modelClass: Class): T { 70 | @Suppress("UNCHECKED_CAST") 71 | return assistedFactory.create(isTest) as T 72 | } 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/like/compose/LikeScreen.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.like.compose 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.runtime.getValue 7 | import androidx.compose.ui.Modifier 8 | import androidx.compose.ui.tooling.preview.Preview 9 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 10 | import kotlinx.collections.immutable.persistentListOf 11 | import tech.thdev.githubusersearch.feature.github.compose.component.ResultEmpty 12 | import tech.thdev.githubusersearch.feature.github.holder.like.LikeViewModel 13 | import tech.thdev.githubusersearch.feature.github.holder.like.compose.component.LikeResult 14 | import tech.thdev.githubusersearch.feature.github.holder.like.model.LikeUiState 15 | 16 | @Composable 17 | internal fun LikeScreen( 18 | likeViewModel: LikeViewModel, 19 | ) { 20 | val likeUiState by likeViewModel.likeUiState.collectAsStateWithLifecycle() 21 | 22 | LikeScreen( 23 | likeUiState = likeUiState, 24 | onClick = likeViewModel::selectedLikeChange, 25 | ) 26 | } 27 | 28 | @Composable 29 | private fun LikeScreen( 30 | likeUiState: LikeUiState, 31 | onClick: (LikeUiState.UserItems.Info) -> Unit = {}, 32 | ) { 33 | Column( 34 | modifier = Modifier 35 | .fillMaxWidth() 36 | ) { 37 | when (likeUiState) { 38 | is LikeUiState.Empty -> { 39 | ResultEmpty( 40 | message = likeUiState.message, 41 | modifier = Modifier 42 | .fillMaxWidth() 43 | .weight(1f) 44 | ) 45 | } 46 | 47 | is LikeUiState.UserItems -> { 48 | LikeResult( 49 | userItems = likeUiState.items, 50 | onClick = onClick, 51 | modifier = Modifier 52 | .fillMaxWidth() 53 | .weight(1f) 54 | ) 55 | } 56 | } 57 | } 58 | } 59 | 60 | @Preview( 61 | showBackground = true, 62 | backgroundColor = 0xFFFFFFFF, 63 | ) 64 | @Composable 65 | internal fun PreviewLikeScreenEmpty() { 66 | LikeScreen( 67 | likeUiState = LikeUiState.Empty( 68 | message = "Empty result", 69 | ), 70 | onClick = {}, 71 | ) 72 | } 73 | 74 | @Preview( 75 | showBackground = true, 76 | backgroundColor = 0xFFFFFFFF, 77 | ) 78 | @Composable 79 | internal fun PreviewLikeScreenItems() { 80 | LikeScreen( 81 | likeUiState = LikeUiState.UserItems( 82 | items = persistentListOf( 83 | LikeUiState.UserItems.Info.Default.copy( 84 | login = "a", 85 | score = 0.14, 86 | ), 87 | LikeUiState.UserItems.Info.Default.copy( 88 | login = "b", 89 | score = 1.0, 90 | isLike = true, 91 | ), 92 | ), 93 | ), 94 | onClick = {}, 95 | ) 96 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/like/compose/component/LikeResultComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.like.compose.component 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.foundation.lazy.items 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import kotlinx.collections.immutable.PersistentList 9 | import kotlinx.collections.immutable.persistentListOf 10 | import tech.thdev.githubusersearch.feature.github.compose.component.ResultItem 11 | import tech.thdev.githubusersearch.feature.github.holder.like.model.LikeUiState 12 | 13 | @Composable 14 | internal fun LikeResult( 15 | modifier: Modifier = Modifier, 16 | userItems: PersistentList, 17 | onClick: (LikeUiState.UserItems.Info) -> Unit = {}, 18 | ) { 19 | LazyColumn( 20 | modifier = modifier 21 | ) { 22 | items( 23 | items = userItems, 24 | key = { 25 | it.id + it.hashCode() 26 | }, 27 | ) { item -> 28 | ResultItem( 29 | login = item.login, 30 | avatarUrl = item.avatarUrl, 31 | score = item.score, 32 | isLike = item.isLike, 33 | onClick = { 34 | onClick(item) 35 | }, 36 | ) 37 | } 38 | } 39 | } 40 | 41 | @Preview( 42 | showBackground = true, 43 | backgroundColor = 0xFFFFFFFF, 44 | ) 45 | @Composable 46 | internal fun PreviewLikeResult() { 47 | LikeResult( 48 | userItems = persistentListOf( 49 | LikeUiState.UserItems.Info.Default.copy( 50 | login = "a", 51 | score = 0.14, 52 | ), 53 | LikeUiState.UserItems.Info.Default.copy( 54 | login = "b", 55 | score = 1.0, 56 | isLike = true, 57 | ), 58 | ), 59 | ) 60 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/like/model/LikeUiState.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.like.model 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.runtime.Stable 5 | import kotlinx.collections.immutable.PersistentList 6 | import kotlinx.collections.immutable.persistentListOf 7 | 8 | @Stable 9 | sealed interface LikeUiState { 10 | 11 | @Immutable 12 | data class Empty(val message: String?) : LikeUiState { 13 | 14 | companion object { 15 | 16 | val Default = Empty( 17 | message = null, 18 | ) 19 | } 20 | } 21 | 22 | @Immutable 23 | data class UserItems( 24 | val items: PersistentList, 25 | ) : LikeUiState { 26 | 27 | @Immutable 28 | data class Info( 29 | val id: Int, 30 | val login: String, 31 | val avatarUrl: String, 32 | val score: Double, 33 | val isLike: Boolean, 34 | ) { 35 | 36 | companion object { 37 | 38 | val Default = Info( 39 | id = 0, 40 | login = "", 41 | avatarUrl = "", 42 | score = 0.0, 43 | isLike = false, 44 | ) 45 | } 46 | } 47 | 48 | companion object { 49 | 50 | val Default = UserItems( 51 | items = persistentListOf(), 52 | ) 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/like/model/convert/LikeConvert.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.like.model.convert 2 | 3 | import kotlinx.collections.immutable.toPersistentList 4 | import tech.thdev.githubusersearch.domain.github.model.GitHubUserEntity 5 | import tech.thdev.githubusersearch.feature.github.holder.like.model.LikeUiState 6 | 7 | internal fun List.convert(): LikeUiState = 8 | LikeUiState.UserItems( 9 | items = map { item -> 10 | LikeUiState.UserItems.Info( 11 | id = item.id, 12 | login = item.login, 13 | avatarUrl = item.avatarUrl, 14 | score = item.score, 15 | isLike = item.isLike, 16 | ) 17 | }.toSet().toPersistentList() 18 | ) -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/SearchComposableHolder.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search 2 | 3 | import androidx.compose.material3.ExperimentalMaterial3Api 4 | import androidx.compose.material3.Text 5 | import androidx.compose.material3.TopAppBar 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.getValue 8 | import androidx.compose.ui.res.painterResource 9 | import androidx.compose.ui.res.stringResource 10 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 11 | import kotlinx.collections.immutable.persistentListOf 12 | import tech.thdev.githubusersearch.design.system.GitScaffold 13 | import tech.thdev.githubusersearch.design.system.R 14 | import tech.thdev.githubusersearch.domain.github.model.GitHubSortType 15 | import tech.thdev.githubusersearch.feature.github.holder.search.compose.SearchScreen 16 | import tech.thdev.githubusersearch.feature.github.holder.search.compose.component.FabItem 17 | import tech.thdev.githubusersearch.feature.github.holder.search.compose.component.MultiFloatingActionButton 18 | 19 | @OptIn(ExperimentalMaterial3Api::class) 20 | @Composable 21 | internal fun SearchComposableHolder( 22 | bottomBar: @Composable () -> Unit, 23 | searchViewModel: SearchViewModel, 24 | ) { 25 | val mainListUiState by searchViewModel.searchUiState.collectAsStateWithLifecycle() 26 | 27 | GitScaffold( 28 | topBar = { 29 | TopAppBar( 30 | title = { 31 | Text(text = "GitHubUserSearch") 32 | }, 33 | ) 34 | }, 35 | bottomBar = bottomBar, 36 | floatingActionButton = { 37 | MultiFloatingActionButton( 38 | fabIcon = painterResource(id = mainListUiState.sortIcon), 39 | items = persistentListOf( 40 | FabItem( 41 | icon = painterResource(id = R.drawable.ic_sort_numbers), 42 | label = stringResource(id = R.string.label_sort_default), 43 | onFabItemClicked = { 44 | searchViewModel.changeSortType(GitHubSortType.FILTER_SORT_DEFAULT) 45 | }, 46 | ), 47 | FabItem( 48 | icon = painterResource(id = R.drawable.ic_sort_alphabet), 49 | label = stringResource(id = R.string.label_sort_name), 50 | onFabItemClicked = { 51 | searchViewModel.changeSortType(GitHubSortType.FILTER_SORT_NAME) 52 | }, 53 | ), 54 | FabItem( 55 | icon = painterResource(id = R.drawable.ic_sort), 56 | label = stringResource(id = R.string.label_sort_date_of_registration), 57 | onFabItemClicked = { 58 | searchViewModel.changeSortType(GitHubSortType.FILTER_SORT_DATE_OF_REGISTRATION) 59 | }, 60 | ), 61 | ), 62 | ) 63 | }, 64 | ) { 65 | SearchScreen(searchViewModel = searchViewModel) 66 | } 67 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search 2 | 3 | import androidx.annotation.VisibleForTesting 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.lifecycle.viewModelScope 7 | import dagger.assisted.Assisted 8 | import dagger.assisted.AssistedFactory 9 | import dagger.assisted.AssistedInject 10 | import javax.inject.Named 11 | import kotlinx.coroutines.ExperimentalCoroutinesApi 12 | import kotlinx.coroutines.flow.Flow 13 | import kotlinx.coroutines.flow.MutableStateFlow 14 | import kotlinx.coroutines.flow.StateFlow 15 | import kotlinx.coroutines.flow.asStateFlow 16 | import kotlinx.coroutines.flow.filter 17 | import kotlinx.coroutines.flow.flatMapLatest 18 | import kotlinx.coroutines.flow.launchIn 19 | import kotlinx.coroutines.flow.map 20 | import kotlinx.coroutines.flow.onEach 21 | import kotlinx.coroutines.flow.retry 22 | import kotlinx.coroutines.launch 23 | import tech.thdev.githubusersearch.design.system.R 24 | import tech.thdev.githubusersearch.domain.github.GitHubSearchRepository 25 | import tech.thdev.githubusersearch.domain.github.model.GitHubSortType 26 | import tech.thdev.githubusersearch.domain.github.model.GitHubUserEntity 27 | import tech.thdev.githubusersearch.feature.github.holder.search.model.SearchUiState 28 | import tech.thdev.githubusersearch.feature.github.holder.search.model.convert.convert 29 | 30 | class SearchViewModel @AssistedInject constructor( 31 | private val gitHubSearchRepository: GitHubSearchRepository, 32 | @Assisted isTest: Boolean = false, 33 | ) : ViewModel() { 34 | 35 | private val _showProgress = MutableStateFlow(false) 36 | val showProgress: StateFlow get() = _showProgress.asStateFlow() 37 | 38 | private val _searchUiState = MutableStateFlow(SearchUiState.Default) 39 | val searchUiState: StateFlow get() = _searchUiState.asStateFlow() 40 | 41 | @VisibleForTesting 42 | val loadData = MutableStateFlow(false) 43 | 44 | @VisibleForTesting 45 | val flowSearchKeyword = MutableStateFlow("") 46 | 47 | init { 48 | if (isTest.not()) { 49 | loadData() 50 | .launchIn(viewModelScope) 51 | } 52 | } 53 | 54 | @OptIn(ExperimentalCoroutinesApi::class) 55 | @VisibleForTesting 56 | fun loadData(): Flow = 57 | loadData 58 | .filter { it } 59 | .onEach { 60 | if (flowSearchKeyword.value.isNotEmpty()) { 61 | _showProgress.value = true 62 | } 63 | } 64 | .flatMapLatest { flowSearchKeyword } 65 | .filter { searchKeyword -> 66 | searchKeyword.isNotEmpty() 67 | } 68 | .flatMapLatest { searchKeyword -> 69 | gitHubSearchRepository.flowLoadData( 70 | searchKeyword = searchKeyword, 71 | perPage = 30, 72 | ) 73 | } 74 | .map { 75 | it.convert(searchUiState.value) 76 | } 77 | .onEach { 78 | loadData.value = false 79 | _searchUiState.value = it 80 | _showProgress.value = false 81 | } 82 | .retry { 83 | loadData.value = false 84 | _showProgress.value = false 85 | _searchUiState.value = searchUiState.value.copy( 86 | uiState = SearchUiState.UiState.Empty(message = it.message), 87 | ) 88 | true 89 | } 90 | 91 | fun loadMore(visibleItemCount: Int, totalItemCount: Int, firstVisibleItem: Int) { 92 | if (loadData.value.not() && (firstVisibleItem + visibleItemCount) >= totalItemCount - 10) { 93 | loadData.value = true 94 | } 95 | } 96 | 97 | fun searchKeyword() { 98 | flowSearchKeyword.tryEmit(searchUiState.value.searchKeyword) 99 | loadData.value = true 100 | } 101 | 102 | fun changeSearchKeyword(keyword: String) { 103 | _searchUiState.value = searchUiState.value.copy( 104 | searchKeyword = keyword, 105 | ) 106 | } 107 | 108 | fun selectedLikeChange(item: SearchUiState.UiState.UserItems.Info) = viewModelScope.launch { 109 | if (item.isLike) { 110 | gitHubSearchRepository.unlikeUserInfo(id = item.id) 111 | } else { 112 | gitHubSearchRepository.likeUserInfo( 113 | GitHubUserEntity( 114 | id = item.id, 115 | login = item.login, 116 | avatarUrl = item.avatarUrl, 117 | score = item.score, 118 | isLike = false, 119 | ) 120 | ) 121 | } 122 | } 123 | 124 | fun changeSortType(sortType: GitHubSortType) { 125 | gitHubSearchRepository.sortList(sortType) 126 | _searchUiState.value = searchUiState.value.copy( 127 | sortType = sortType, 128 | sortIcon = sortType.sortIcon, 129 | ) 130 | } 131 | 132 | private val GitHubSortType.sortIcon: Int 133 | get() = when (this) { 134 | GitHubSortType.FILTER_SORT_NAME -> R.drawable.ic_sort_alphabet 135 | GitHubSortType.FILTER_SORT_DATE_OF_REGISTRATION -> R.drawable.ic_sort 136 | else -> R.drawable.ic_sort_numbers 137 | } 138 | 139 | override fun onCleared() { 140 | gitHubSearchRepository.clear() 141 | } 142 | 143 | @AssistedFactory 144 | interface SearchAssistedFactory { 145 | 146 | fun create(@Named("is_test") isTest: Boolean): SearchViewModel 147 | } 148 | 149 | companion object { 150 | 151 | fun provideFactory( 152 | assistedFactory: SearchAssistedFactory, 153 | isTest: Boolean, 154 | ): ViewModelProvider.Factory = 155 | object : ViewModelProvider.Factory { 156 | 157 | override fun create(modelClass: Class): T { 158 | @Suppress("UNCHECKED_CAST") 159 | return assistedFactory.create(isTest) as T 160 | } 161 | } 162 | } 163 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/compose/SearchScreen.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search.compose 2 | 3 | import androidx.compose.foundation.layout.Column 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.text.KeyboardActions 6 | import androidx.compose.foundation.text.KeyboardOptions 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.getValue 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.res.stringResource 11 | import androidx.compose.ui.text.input.ImeAction 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.lifecycle.compose.collectAsStateWithLifecycle 14 | import kotlinx.collections.immutable.persistentListOf 15 | import tech.thdev.githubusersearch.design.system.R 16 | import tech.thdev.githubusersearch.feature.github.compose.component.ResultEmpty 17 | import tech.thdev.githubusersearch.feature.github.holder.search.SearchViewModel 18 | import tech.thdev.githubusersearch.feature.github.holder.search.compose.component.SearchResult 19 | import tech.thdev.githubusersearch.feature.github.holder.search.compose.component.SearchResultLoading 20 | import tech.thdev.githubusersearch.feature.github.holder.search.compose.component.SearchTextField 21 | import tech.thdev.githubusersearch.feature.github.holder.search.model.SearchUiState 22 | 23 | @Composable 24 | internal fun SearchScreen( 25 | searchViewModel: SearchViewModel, 26 | ) { 27 | val mainListUiState by searchViewModel.searchUiState.collectAsStateWithLifecycle() 28 | 29 | SearchScreen( 30 | mainListUiState = mainListUiState, 31 | onClick = searchViewModel::selectedLikeChange, 32 | onValueChange = searchViewModel::changeSearchKeyword, 33 | onSearchClick = searchViewModel::searchKeyword, 34 | ) 35 | } 36 | 37 | @Composable 38 | private fun SearchScreen( 39 | mainListUiState: SearchUiState, 40 | onClick: (SearchUiState.UiState.UserItems.Info) -> Unit = {}, 41 | onValueChange: (String) -> Unit, 42 | onSearchClick: () -> Unit, 43 | ) { 44 | Column( 45 | modifier = Modifier 46 | .fillMaxWidth() 47 | ) { 48 | SearchTextField( 49 | value = mainListUiState.searchKeyword, 50 | hint = stringResource(id = R.string.search_hint), 51 | onValueChange = onValueChange, 52 | onClick = onSearchClick, 53 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), 54 | keyboardActions = KeyboardActions( 55 | onSearch = { 56 | onSearchClick() 57 | } 58 | ) 59 | ) 60 | 61 | when (mainListUiState.uiState) { 62 | is SearchUiState.UiState.Loading -> { 63 | SearchResultLoading( 64 | modifier = Modifier 65 | .fillMaxWidth() 66 | .weight(1f) 67 | ) 68 | } 69 | 70 | is SearchUiState.UiState.Empty -> { 71 | ResultEmpty( 72 | message = mainListUiState.uiState.message, 73 | modifier = Modifier 74 | .fillMaxWidth() 75 | .weight(1f) 76 | ) 77 | } 78 | 79 | is SearchUiState.UiState.UserItems -> { 80 | SearchResult( 81 | userItems = mainListUiState.uiState.items, 82 | onClick = onClick, 83 | modifier = Modifier 84 | .fillMaxWidth() 85 | .weight(1f) 86 | ) 87 | } 88 | } 89 | } 90 | } 91 | 92 | @Preview( 93 | showBackground = true, 94 | backgroundColor = 0xFFFFFFFF, 95 | ) 96 | @Composable 97 | internal fun PreviewSearchScreenEmpty() { 98 | SearchScreen( 99 | mainListUiState = SearchUiState.Default.copy( 100 | uiState = SearchUiState.UiState.Empty( 101 | message = "Empty result", 102 | ), 103 | ), 104 | onClick = {}, 105 | onValueChange = {}, 106 | onSearchClick = {}, 107 | ) 108 | } 109 | 110 | @Preview( 111 | showBackground = true, 112 | backgroundColor = 0xFFFFFFFF, 113 | ) 114 | @Composable 115 | internal fun PreviewSearchScreenItems() { 116 | SearchScreen( 117 | mainListUiState = SearchUiState.Default.copy( 118 | uiState = SearchUiState.UiState.UserItems( 119 | items = persistentListOf( 120 | SearchUiState.UiState.UserItems.Info.Default.copy( 121 | login = "a", 122 | score = 0.14, 123 | ), 124 | SearchUiState.UiState.UserItems.Info.Default.copy( 125 | login = "b", 126 | score = 1.0, 127 | isLike = true, 128 | ), 129 | ), 130 | ), 131 | ), 132 | onClick = {}, 133 | onValueChange = {}, 134 | onSearchClick = {}, 135 | ) 136 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/compose/component/MultiFloatingActionButtonComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search.compose.component 2 | 3 | import androidx.activity.compose.BackHandler 4 | import androidx.compose.animation.core.Spring 5 | import androidx.compose.animation.core.Transition 6 | import androidx.compose.animation.core.animateFloat 7 | import androidx.compose.animation.core.animateFloatAsState 8 | import androidx.compose.animation.core.spring 9 | import androidx.compose.animation.core.tween 10 | import androidx.compose.animation.core.updateTransition 11 | import androidx.compose.foundation.Canvas 12 | import androidx.compose.foundation.clickable 13 | import androidx.compose.foundation.interaction.MutableInteractionSource 14 | import androidx.compose.foundation.layout.Arrangement 15 | import androidx.compose.foundation.layout.Box 16 | import androidx.compose.foundation.layout.Column 17 | import androidx.compose.foundation.layout.Row 18 | import androidx.compose.foundation.layout.Spacer 19 | import androidx.compose.foundation.layout.fillMaxSize 20 | import androidx.compose.foundation.layout.fillMaxWidth 21 | import androidx.compose.foundation.layout.height 22 | import androidx.compose.foundation.layout.padding 23 | import androidx.compose.foundation.layout.size 24 | import androidx.compose.foundation.shape.CircleShape 25 | import androidx.compose.material3.FloatingActionButton 26 | import androidx.compose.material3.FloatingActionButtonDefaults 27 | import androidx.compose.material3.Icon 28 | import androidx.compose.material3.SmallFloatingActionButton 29 | import androidx.compose.material3.Text 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.getValue 32 | import androidx.compose.runtime.mutableStateOf 33 | import androidx.compose.runtime.remember 34 | import androidx.compose.runtime.setValue 35 | import androidx.compose.ui.Alignment 36 | import androidx.compose.ui.Modifier 37 | import androidx.compose.ui.draw.alpha 38 | import androidx.compose.ui.draw.rotate 39 | import androidx.compose.ui.draw.scale 40 | import androidx.compose.ui.graphics.Color 41 | import androidx.compose.ui.graphics.drawscope.scale 42 | import androidx.compose.ui.graphics.drawscope.translate 43 | import androidx.compose.ui.graphics.graphicsLayer 44 | import androidx.compose.ui.graphics.painter.Painter 45 | import androidx.compose.ui.res.painterResource 46 | import androidx.compose.ui.tooling.preview.Preview 47 | import androidx.compose.ui.unit.dp 48 | import kotlinx.collections.immutable.PersistentList 49 | import kotlinx.collections.immutable.persistentListOf 50 | import tech.thdev.githubusersearch.design.system.R 51 | import tech.thdev.githubusersearch.design.system.theme.PurpleGrey40 52 | 53 | enum class MultiFabState { 54 | COLLAPSED, EXPANDED 55 | } 56 | 57 | class FabItem( 58 | val icon: Painter, 59 | val label: String, 60 | val onFabItemClicked: () -> Unit 61 | ) 62 | 63 | @Preview 64 | @Composable 65 | internal fun FloatingButton() { 66 | FloatingActionButton( 67 | onClick = {}, 68 | shape = CircleShape, 69 | containerColor = Color.Blue, 70 | elevation = FloatingActionButtonDefaults.elevation( 71 | defaultElevation = 8.dp 72 | ) 73 | ) { 74 | Icon( 75 | painter = painterResource(id = R.drawable.ic_sort_numbers), 76 | contentDescription = null, 77 | tint = Color.White 78 | ) 79 | } 80 | } 81 | 82 | @Preview 83 | @Composable 84 | internal fun PreviewMultiFloatingActionButton() { 85 | MultiFloatingActionButton( 86 | fabIcon = painterResource(id = R.drawable.ic_sort_numbers), 87 | items = persistentListOf(FabItem(icon = painterResource(id = R.drawable.ic_sort_numbers), label = "") { 88 | 89 | }) 90 | ) 91 | } 92 | 93 | @Composable 94 | fun MultiFloatingActionButton( 95 | fabIcon: Painter, 96 | items: PersistentList, 97 | showLabels: Boolean = true, 98 | onStateChanged: ((state: MultiFabState) -> Unit)? = null 99 | ) { 100 | var currentState by remember { mutableStateOf(MultiFabState.COLLAPSED) } 101 | val stateTransition: Transition = 102 | updateTransition(targetState = currentState, label = "") 103 | val stateChange: () -> Unit = { 104 | currentState = if (stateTransition.currentState == MultiFabState.EXPANDED) { 105 | MultiFabState.COLLAPSED 106 | } else MultiFabState.EXPANDED 107 | onStateChanged?.invoke(currentState) 108 | } 109 | val rotation: Float by stateTransition.animateFloat( 110 | transitionSpec = { 111 | if (targetState == MultiFabState.EXPANDED) { 112 | spring(stiffness = Spring.StiffnessLow) 113 | } else { 114 | spring(stiffness = Spring.StiffnessMedium) 115 | } 116 | }, 117 | label = "" 118 | ) { state -> 119 | if (state == MultiFabState.EXPANDED) 45f else 0f 120 | } 121 | val isEnable = currentState == MultiFabState.EXPANDED 122 | 123 | BackHandler(isEnable) { 124 | currentState = MultiFabState.COLLAPSED 125 | } 126 | 127 | val modifier = if (currentState == MultiFabState.EXPANDED) 128 | Modifier 129 | .fillMaxSize() 130 | .clickable(indication = null, 131 | interactionSource = remember { MutableInteractionSource() }) { 132 | currentState = MultiFabState.COLLAPSED 133 | } else Modifier.fillMaxSize() 134 | 135 | Box( 136 | contentAlignment = Alignment.BottomEnd, 137 | modifier = modifier 138 | ) { 139 | Box( 140 | contentAlignment = Alignment.BottomEnd, 141 | modifier = Modifier 142 | .fillMaxWidth() 143 | .height(400.dp) 144 | ) { 145 | if (currentState == MultiFabState.EXPANDED) { 146 | Canvas(modifier = Modifier 147 | .fillMaxSize() 148 | .graphicsLayer { 149 | scaleX = 2.2f 150 | scaleY = 2.1f 151 | }) { 152 | translate(150f, top = 300f) { 153 | scale(5f) {} 154 | drawCircle(PurpleGrey40, radius = 200.dp.toPx()) 155 | 156 | } 157 | } 158 | } 159 | 160 | Column( 161 | horizontalAlignment = Alignment.End, 162 | verticalArrangement = Arrangement.Bottom, 163 | ) { 164 | items.forEach { item -> 165 | SmallFloatingActionButtonRow( 166 | item = item, 167 | stateTransition = stateTransition, 168 | showLabel = showLabels, 169 | stateChange = stateChange, 170 | ) 171 | Spacer(modifier = Modifier.height(20.dp)) 172 | } 173 | 174 | FloatingActionButton( 175 | shape = CircleShape, 176 | containerColor = Color.Blue, 177 | onClick = { 178 | stateChange() 179 | }) { 180 | Icon( 181 | painter = fabIcon, 182 | contentDescription = null, 183 | tint = Color.White, 184 | modifier = Modifier 185 | .size(24.dp) 186 | .rotate(rotation) 187 | ) 188 | } 189 | } 190 | } 191 | } 192 | } 193 | 194 | 195 | @Composable 196 | fun SmallFloatingActionButtonRow( 197 | item: FabItem, 198 | showLabel: Boolean, 199 | stateTransition: Transition, 200 | stateChange: () -> Unit, 201 | ) { 202 | val alpha: Float by stateTransition.animateFloat( 203 | transitionSpec = { 204 | tween(durationMillis = 50) 205 | }, label = "" 206 | ) { state -> 207 | if (state == MultiFabState.EXPANDED) 1f else 0f 208 | } 209 | val scale: Float by stateTransition.animateFloat( 210 | label = "" 211 | ) { state -> 212 | if (state == MultiFabState.EXPANDED) 1.0f else 0f 213 | } 214 | Row( 215 | verticalAlignment = Alignment.CenterVertically, 216 | modifier = Modifier 217 | .alpha(animateFloatAsState(targetValue = (alpha), label = "").value) 218 | .scale(animateFloatAsState(targetValue = scale, label = "").value) 219 | ) { 220 | if (showLabel) { 221 | Text( 222 | text = item.label, 223 | modifier = Modifier 224 | .padding(start = 6.dp, end = 6.dp, top = 4.dp, bottom = 4.dp) 225 | .clickable( 226 | onClick = { 227 | stateChange() 228 | item.onFabItemClicked() 229 | } 230 | ) 231 | ) 232 | } 233 | 234 | SmallFloatingActionButton( 235 | shape = CircleShape, 236 | onClick = { 237 | stateChange() 238 | item.onFabItemClicked() 239 | }, 240 | containerColor = Color.Blue, 241 | contentColor = Color.White, 242 | modifier = Modifier 243 | .padding(4.dp) 244 | ) { 245 | Icon( 246 | painter = item.icon, 247 | contentDescription = item.label 248 | ) 249 | } 250 | } 251 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/compose/component/SearchResultComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search.compose.component 2 | 3 | import androidx.compose.foundation.lazy.LazyColumn 4 | import androidx.compose.foundation.lazy.items 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.tooling.preview.Preview 8 | import kotlinx.collections.immutable.PersistentList 9 | import kotlinx.collections.immutable.persistentListOf 10 | import tech.thdev.githubusersearch.feature.github.compose.component.ResultItem 11 | import tech.thdev.githubusersearch.feature.github.holder.search.model.SearchUiState 12 | 13 | @Composable 14 | internal fun SearchResult( 15 | modifier: Modifier = Modifier, 16 | userItems: PersistentList, 17 | onClick: (SearchUiState.UiState.UserItems.Info) -> Unit = {}, 18 | ) { 19 | LazyColumn( 20 | modifier = modifier 21 | ) { 22 | items( 23 | items = userItems, 24 | key = { 25 | it.id + it.hashCode() 26 | }, 27 | ) { item -> 28 | ResultItem( 29 | login = item.login, 30 | avatarUrl = item.avatarUrl, 31 | score = item.score, 32 | isLike = item.isLike, 33 | onClick = { 34 | onClick(item) 35 | }, 36 | ) 37 | } 38 | } 39 | } 40 | 41 | @Preview( 42 | showBackground = true, 43 | backgroundColor = 0xFFFFFFFF, 44 | ) 45 | @Composable 46 | internal fun PreviewSearchResult() { 47 | SearchResult( 48 | userItems = persistentListOf( 49 | SearchUiState.UiState.UserItems.Info.Default.copy( 50 | login = "a", 51 | score = 0.14, 52 | ), 53 | SearchUiState.UiState.UserItems.Info.Default.copy( 54 | login = "b", 55 | score = 1.0, 56 | isLike = true, 57 | ), 58 | ), 59 | ) 60 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/compose/component/SearchResultLoadingComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search.compose.component 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.material3.CircularProgressIndicator 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Alignment 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.res.stringResource 12 | import androidx.compose.ui.tooling.preview.Preview 13 | import androidx.compose.ui.unit.dp 14 | import androidx.compose.ui.unit.sp 15 | import tech.thdev.githubusersearch.design.system.R 16 | import tech.thdev.githubusersearch.design.system.theme.ColorLoadingBackground 17 | 18 | @Composable 19 | internal fun SearchResultLoading( 20 | modifier: Modifier = Modifier 21 | ) { 22 | Box( 23 | contentAlignment = Alignment.Center, 24 | modifier = modifier 25 | .background(color = ColorLoadingBackground) 26 | ) { 27 | CircularProgressIndicator() 28 | Text( 29 | text = stringResource(id = R.string.search_hint), 30 | fontSize = 16.sp, 31 | modifier = Modifier 32 | .padding(20.dp) 33 | ) 34 | } 35 | } 36 | 37 | @Preview( 38 | showBackground = true, 39 | backgroundColor = 0xFFFFFFFF, 40 | ) 41 | @Composable 42 | internal fun PreviewSearchResultLoading() { 43 | SearchResultLoading() 44 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/compose/component/SearchTextFieldComponent.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search.compose.component 2 | 3 | import androidx.compose.foundation.layout.Row 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.foundation.text.KeyboardActions 7 | import androidx.compose.foundation.text.KeyboardOptions 8 | import androidx.compose.material3.Icon 9 | import androidx.compose.material3.IconButton 10 | import androidx.compose.material3.Text 11 | import androidx.compose.material3.TextField 12 | import androidx.compose.runtime.Composable 13 | import androidx.compose.ui.Alignment 14 | import androidx.compose.ui.Modifier 15 | import androidx.compose.ui.res.painterResource 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.unit.dp 18 | import tech.thdev.githubusersearch.design.system.R 19 | 20 | @Composable 21 | internal fun SearchTextField( 22 | value: String, 23 | hint: String, 24 | onValueChange: (String) -> Unit, 25 | onClick: () -> Unit, 26 | keyboardOptions: KeyboardOptions = KeyboardOptions.Default, 27 | keyboardActions: KeyboardActions = KeyboardActions.Default, 28 | ) { 29 | Row( 30 | verticalAlignment = Alignment.CenterVertically, 31 | modifier = Modifier 32 | .fillMaxWidth() 33 | .padding(20.dp) 34 | ) { 35 | TextField( 36 | value = value, 37 | placeholder = { 38 | Text(text = hint) 39 | }, 40 | onValueChange = onValueChange, 41 | singleLine = true, 42 | keyboardOptions = keyboardOptions, 43 | keyboardActions = keyboardActions, 44 | modifier = Modifier 45 | .weight(1f) 46 | ) 47 | 48 | IconButton( 49 | onClick = onClick, 50 | ) { 51 | Icon( 52 | painter = painterResource(id = R.drawable.ic_search_black_24dp), 53 | contentDescription = "search", 54 | ) 55 | } 56 | } 57 | } 58 | 59 | @Preview( 60 | showBackground = true, 61 | backgroundColor = 0xFFFFFFFF, 62 | ) 63 | @Composable 64 | internal fun PreviewSearchTextField() { 65 | SearchTextField( 66 | value = "", 67 | hint = "Search user name", 68 | onValueChange = {}, 69 | onClick = {}, 70 | ) 71 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/model/SearchUiState.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search.model 2 | 3 | import androidx.compose.runtime.Immutable 4 | import androidx.compose.runtime.Stable 5 | import kotlinx.collections.immutable.PersistentList 6 | import kotlinx.collections.immutable.persistentListOf 7 | import tech.thdev.githubusersearch.design.system.R 8 | import tech.thdev.githubusersearch.domain.github.model.GitHubSortType 9 | 10 | @Immutable 11 | data class SearchUiState( 12 | val searchKeyword: String, 13 | val uiState: UiState, 14 | val sortType: GitHubSortType, 15 | val sortIcon: Int, 16 | ) { 17 | 18 | @Stable 19 | sealed interface UiState { 20 | 21 | @Immutable 22 | data object Loading : UiState 23 | 24 | @Immutable 25 | data class Empty(val message: String?) : UiState { 26 | 27 | companion object { 28 | 29 | val Default = Empty( 30 | message = null, 31 | ) 32 | } 33 | } 34 | 35 | @Immutable 36 | data class UserItems( 37 | val items: PersistentList, 38 | ) : UiState { 39 | 40 | @Immutable 41 | data class Info( 42 | val id: Int, 43 | val login: String, 44 | val avatarUrl: String, 45 | val score: Double, 46 | val isLike: Boolean, 47 | ) { 48 | 49 | companion object { 50 | 51 | val Default = Info( 52 | id = 0, 53 | login = "", 54 | avatarUrl = "", 55 | score = 0.0, 56 | isLike = false, 57 | ) 58 | } 59 | } 60 | 61 | companion object { 62 | 63 | val Default = UserItems( 64 | items = persistentListOf(), 65 | ) 66 | } 67 | } 68 | } 69 | 70 | companion object { 71 | 72 | val Default = SearchUiState( 73 | searchKeyword = "", 74 | uiState = UiState.Empty.Default, 75 | sortType = GitHubSortType.FILTER_SORT_DEFAULT, 76 | sortIcon = R.drawable.ic_sort_numbers, 77 | ) 78 | } 79 | } -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/holder/search/model/convert/SearchConvert.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search.model.convert 2 | 3 | import kotlinx.collections.immutable.toPersistentList 4 | import tech.thdev.githubusersearch.domain.github.model.GitHubUserEntity 5 | import tech.thdev.githubusersearch.feature.github.holder.search.model.SearchUiState 6 | 7 | internal fun List.convert( 8 | prevItem: SearchUiState, 9 | ): SearchUiState = 10 | prevItem.copy( 11 | uiState = SearchUiState.UiState.UserItems( 12 | items = map { item -> 13 | SearchUiState.UiState.UserItems.Info( 14 | id = item.id, 15 | login = item.login, 16 | avatarUrl = item.avatarUrl, 17 | score = item.score, 18 | isLike = item.isLike, 19 | ) 20 | }.toSet().toPersistentList() 21 | ) 22 | ) -------------------------------------------------------------------------------- /feature/github-search/src/main/java/tech/thdev/githubusersearch/feature/github/model/MainUiState.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.model 2 | 3 | import androidx.compose.runtime.Immutable 4 | import kotlinx.collections.immutable.PersistentList 5 | import kotlinx.collections.immutable.persistentListOf 6 | import tech.thdev.githubusersearch.feature.github.ViewType 7 | 8 | @Immutable 9 | data class MainUiState( 10 | val bottomItems: PersistentList, 11 | ) { 12 | 13 | @Immutable 14 | data class Bottom( 15 | val title: Int, 16 | val selected: Boolean, 17 | val icon: Int, 18 | val viewType: ViewType, 19 | ) 20 | 21 | companion object { 22 | 23 | val Default = MainUiState( 24 | bottomItems = persistentListOf(), 25 | ) 26 | } 27 | } -------------------------------------------------------------------------------- /feature/github-search/src/test/java/tech/thdev/githubusersearch/feature/github/MainViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github 2 | 3 | import kotlinx.collections.immutable.persistentListOf 4 | import kotlinx.coroutines.Dispatchers 5 | import kotlinx.coroutines.ExperimentalCoroutinesApi 6 | import kotlinx.coroutines.test.TestDispatcher 7 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 8 | import kotlinx.coroutines.test.resetMain 9 | import kotlinx.coroutines.test.setMain 10 | import org.junit.jupiter.api.AfterEach 11 | import org.junit.jupiter.api.Assertions 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.junit.jupiter.api.Test 14 | import tech.thdev.githubusersearch.design.system.R 15 | import tech.thdev.githubusersearch.feature.github.MainViewModel 16 | import tech.thdev.githubusersearch.feature.github.ViewType 17 | import tech.thdev.githubusersearch.feature.github.model.MainUiState 18 | 19 | @OptIn(ExperimentalCoroutinesApi::class) 20 | class MainViewModelTest { 21 | 22 | private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() 23 | 24 | private val viewModel = MainViewModel() 25 | 26 | @BeforeEach 27 | fun beforeEach() { 28 | Dispatchers.setMain(dispatcher) 29 | } 30 | 31 | @AfterEach 32 | fun afterEach() { 33 | Dispatchers.resetMain() 34 | } 35 | 36 | @Test 37 | fun `test initData`() { 38 | Assertions.assertEquals(ViewType.SEARCH, viewModel.selectNavigation.value) 39 | Assertions.assertEquals( 40 | MainUiState( 41 | bottomItems = persistentListOf( 42 | MainUiState.Bottom( 43 | title = R.string.title_search, 44 | selected = true, 45 | icon = R.drawable.ic_search_black_24dp, 46 | viewType = ViewType.SEARCH, 47 | ), 48 | MainUiState.Bottom( 49 | title = R.string.title_liked, 50 | selected = false, 51 | icon = R.drawable.ic_collections_bookmark_black_24dp, 52 | viewType = ViewType.LIKED, 53 | ), 54 | ) 55 | ), 56 | viewModel.mainUiState.value 57 | ) 58 | } 59 | 60 | @Test 61 | fun `test bottomChange`() { 62 | viewModel.bottomChange(ViewType.LIKED) 63 | 64 | Assertions.assertEquals( 65 | MainUiState( 66 | bottomItems = persistentListOf( 67 | MainUiState.Bottom( 68 | title = R.string.title_search, 69 | selected = false, 70 | icon = R.drawable.ic_search_black_24dp, 71 | viewType = ViewType.SEARCH, 72 | ), 73 | MainUiState.Bottom( 74 | title = R.string.title_liked, 75 | selected = true, 76 | icon = R.drawable.ic_collections_bookmark_black_24dp, 77 | viewType = ViewType.LIKED, 78 | ), 79 | ) 80 | ), 81 | viewModel.mainUiState.value 82 | ) 83 | } 84 | } -------------------------------------------------------------------------------- /feature/github-search/src/test/java/tech/thdev/githubusersearch/feature/github/holder/like/LikeViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.like 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.collections.immutable.persistentListOf 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.flow.flowOf 8 | import kotlinx.coroutines.test.TestDispatcher 9 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 10 | import kotlinx.coroutines.test.resetMain 11 | import kotlinx.coroutines.test.runTest 12 | import kotlinx.coroutines.test.setMain 13 | import org.junit.jupiter.api.AfterEach 14 | import org.junit.jupiter.api.Assertions 15 | import org.junit.jupiter.api.BeforeEach 16 | import org.junit.jupiter.api.Test 17 | import org.mockito.kotlin.mock 18 | import org.mockito.kotlin.verify 19 | import org.mockito.kotlin.whenever 20 | import tech.thdev.githubusersearch.domain.github.GitHubSearchRepository 21 | import tech.thdev.githubusersearch.domain.github.model.GitHubUserEntity 22 | import tech.thdev.githubusersearch.feature.github.holder.like.model.LikeUiState 23 | 24 | @OptIn(ExperimentalCoroutinesApi::class) 25 | class LikeViewModelTest { 26 | 27 | private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() 28 | 29 | private val gitHubSearchRepository = mock() 30 | 31 | private val viewModel = LikeViewModel( 32 | gitHubSearchRepository = gitHubSearchRepository, 33 | isTest = true, 34 | ) 35 | 36 | @BeforeEach 37 | fun beforeEach() { 38 | Dispatchers.setMain(dispatcher) 39 | } 40 | 41 | @AfterEach 42 | fun afterEach() { 43 | Dispatchers.resetMain() 44 | } 45 | 46 | @Test 47 | fun `test initData`() { 48 | Assertions.assertEquals(LikeUiState.Empty.Default, viewModel.likeUiState.value) 49 | } 50 | 51 | @Test 52 | fun `test loadData`() = runTest { 53 | val mockResponse = listOf( 54 | GitHubUserEntity.Default.copy( 55 | login = "name", 56 | score = 0.14, 57 | ), 58 | GitHubUserEntity.Default.copy( 59 | login = "name two", 60 | score = 0.14, 61 | ), 62 | ) 63 | whenever( 64 | gitHubSearchRepository.flowLoadLikedData() 65 | ).thenReturn(flowOf(mockResponse)) 66 | 67 | viewModel.loadData() 68 | .test { 69 | val convert = LikeUiState.UserItems( 70 | items = persistentListOf( 71 | LikeUiState.UserItems.Info.Default.copy( 72 | login = "name", 73 | score = 0.14, 74 | ), 75 | LikeUiState.UserItems.Info.Default.copy( 76 | login = "name two", 77 | score = 0.14, 78 | ), 79 | ), 80 | ) 81 | Assertions.assertEquals(convert, awaitItem()) 82 | Assertions.assertEquals(convert, viewModel.likeUiState.value) 83 | 84 | verify(gitHubSearchRepository).flowLoadLikedData() 85 | 86 | cancelAndConsumeRemainingEvents() 87 | } 88 | } 89 | 90 | @Test 91 | fun `test selectedLikeChange`() = runTest { 92 | val mockItem = LikeUiState.UserItems.Info.Default.copy(id = 1, login = "aa", isLike = true) 93 | viewModel.selectedLikeChange(mockItem) 94 | verify(gitHubSearchRepository).unlikeUserInfo(id = 1) 95 | } 96 | } -------------------------------------------------------------------------------- /feature/github-search/src/test/java/tech/thdev/githubusersearch/feature/github/holder/search/SearchViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package tech.thdev.githubusersearch.feature.github.holder.search 2 | 3 | import app.cash.turbine.test 4 | import kotlinx.collections.immutable.persistentListOf 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.ExperimentalCoroutinesApi 7 | import kotlinx.coroutines.flow.flowOf 8 | import kotlinx.coroutines.flow.map 9 | import kotlinx.coroutines.test.TestDispatcher 10 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 11 | import kotlinx.coroutines.test.resetMain 12 | import kotlinx.coroutines.test.runTest 13 | import kotlinx.coroutines.test.setMain 14 | import org.junit.jupiter.api.AfterEach 15 | import org.junit.jupiter.api.Assertions 16 | import org.junit.jupiter.api.BeforeEach 17 | import org.junit.jupiter.api.Test 18 | import org.mockito.kotlin.mock 19 | import org.mockito.kotlin.verify 20 | import org.mockito.kotlin.whenever 21 | import tech.thdev.githubusersearch.design.system.R 22 | import tech.thdev.githubusersearch.domain.github.GitHubSearchRepository 23 | import tech.thdev.githubusersearch.domain.github.model.GitHubSortType 24 | import tech.thdev.githubusersearch.domain.github.model.GitHubUserEntity 25 | import tech.thdev.githubusersearch.feature.github.holder.search.model.SearchUiState 26 | 27 | @OptIn(ExperimentalCoroutinesApi::class) 28 | class SearchViewModelTest { 29 | 30 | private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() 31 | 32 | private val gitHubSearchRepository = mock() 33 | 34 | private val viewModel = SearchViewModel( 35 | gitHubSearchRepository = gitHubSearchRepository, 36 | isTest = true, 37 | ) 38 | 39 | @BeforeEach 40 | fun beforeEach() { 41 | Dispatchers.setMain(dispatcher) 42 | } 43 | 44 | @AfterEach 45 | fun afterEach() { 46 | Dispatchers.resetMain() 47 | } 48 | 49 | @Test 50 | fun `test initData`() { 51 | Assertions.assertFalse(viewModel.showProgress.value) 52 | Assertions.assertEquals(SearchUiState.Default, viewModel.searchUiState.value) 53 | Assertions.assertFalse(viewModel.loadData.value) 54 | Assertions.assertEquals("", viewModel.flowSearchKeyword.value) 55 | } 56 | 57 | @Test 58 | fun `test loadData`() = runTest { 59 | val mockSearchKeyword = "keyword" 60 | val mockResponse = listOf( 61 | GitHubUserEntity.Default.copy( 62 | login = "name", 63 | score = 0.14, 64 | ), 65 | GitHubUserEntity.Default.copy( 66 | login = "name two", 67 | score = 0.14, 68 | ), 69 | ) 70 | whenever( 71 | gitHubSearchRepository.flowLoadData( 72 | searchKeyword = mockSearchKeyword, 73 | perPage = 30, 74 | ) 75 | ).thenReturn(flowOf(mockResponse)) 76 | 77 | viewModel.loadData() 78 | .test { 79 | expectNoEvents() 80 | 81 | viewModel.changeSearchKeyword(mockSearchKeyword) 82 | viewModel.searchKeyword() 83 | 84 | val convert = SearchUiState.Default.copy( 85 | searchKeyword = mockSearchKeyword, 86 | uiState = SearchUiState.UiState.UserItems( 87 | items = persistentListOf( 88 | SearchUiState.UiState.UserItems.Info.Default.copy( 89 | login = "name", 90 | score = 0.14, 91 | ), 92 | SearchUiState.UiState.UserItems.Info.Default.copy( 93 | login = "name two", 94 | score = 0.14, 95 | ), 96 | ), 97 | ), 98 | ) 99 | Assertions.assertEquals(convert, awaitItem()) 100 | Assertions.assertEquals(convert, viewModel.searchUiState.value) 101 | 102 | verify(gitHubSearchRepository).flowLoadData( 103 | searchKeyword = mockSearchKeyword, 104 | perPage = 30, 105 | ) 106 | 107 | cancelAndConsumeRemainingEvents() 108 | } 109 | } 110 | 111 | @Test 112 | fun `test loadData - fail case`() = runTest(dispatcher) { 113 | val mockSearchKeyword = "keyword" 114 | 115 | val fail = flowOf(false) 116 | .map { 117 | throw Exception("message") 118 | } 119 | 120 | whenever( 121 | gitHubSearchRepository.flowLoadData( 122 | searchKeyword = mockSearchKeyword, 123 | perPage = 30, 124 | ) 125 | ).thenReturn(fail) 126 | 127 | viewModel.changeSearchKeyword(mockSearchKeyword) 128 | viewModel.searchKeyword() 129 | 130 | viewModel.loadData() 131 | .test { 132 | val convert = SearchUiState.Default.copy( 133 | searchKeyword = mockSearchKeyword, 134 | uiState = SearchUiState.UiState.Empty("message"), 135 | ) 136 | Assertions.assertEquals(convert, viewModel.searchUiState.value) 137 | 138 | verify(gitHubSearchRepository).flowLoadData( 139 | searchKeyword = mockSearchKeyword, 140 | perPage = 30, 141 | ) 142 | 143 | cancelAndConsumeRemainingEvents() 144 | } 145 | } 146 | 147 | @Test 148 | fun `test changeSearchKeyword`() { 149 | val newValue = "new" 150 | viewModel.changeSearchKeyword(newValue) 151 | viewModel.searchKeyword() 152 | Assertions.assertTrue(viewModel.loadData.value) 153 | Assertions.assertEquals("new", viewModel.flowSearchKeyword.value) 154 | } 155 | 156 | @Test 157 | fun `test loadMore`() { 158 | viewModel.loadMore(10, 12, 8) 159 | Assertions.assertTrue(viewModel.loadData.value) 160 | } 161 | 162 | @Test 163 | fun `test selectedLikeChange`() = runTest { 164 | val mockItem = SearchUiState.UiState.UserItems.Info.Default.copy(id = 1, login = "aa") 165 | viewModel.selectedLikeChange(mockItem) 166 | verify(gitHubSearchRepository).likeUserInfo( 167 | GitHubUserEntity( 168 | id = 1, 169 | login = "aa", 170 | avatarUrl = "", 171 | score = 0.0, 172 | isLike = false, 173 | ) 174 | ) 175 | 176 | val mockItemTwo = SearchUiState.UiState.UserItems.Info.Default.copy(id = 1, login = "aa", isLike = true) 177 | viewModel.selectedLikeChange(mockItemTwo) 178 | verify(gitHubSearchRepository).unlikeUserInfo(id = 1) 179 | } 180 | 181 | @Test 182 | fun `test changeSortType`() { 183 | viewModel.changeSortType(GitHubSortType.FILTER_SORT_DATE_OF_REGISTRATION) 184 | Assertions.assertEquals( 185 | SearchUiState.Default.copy( 186 | sortType = GitHubSortType.FILTER_SORT_DATE_OF_REGISTRATION, 187 | sortIcon = R.drawable.ic_sort, 188 | ), 189 | viewModel.searchUiState.value 190 | ) 191 | verify(gitHubSearchRepository).sortList(GitHubSortType.FILTER_SORT_NAME) 192 | } 193 | } -------------------------------------------------------------------------------- /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 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | buildToolsVersion = "34.0.0" 3 | compileSdk = "34" 4 | targetSdk = "34" 5 | minSdk = "26" 6 | 7 | major = "1" 8 | minor = "0" 9 | hotfix = "0" 10 | versionCode = "1" 11 | 12 | ## Android gradle plugin 13 | androidGradlePlugin = "8.1.2" 14 | 15 | ## kotlin 16 | # https://github.com/JetBrains/kotlin 17 | kotlin = "1.9.10" 18 | # https://github.com/Kotlin/kotlinx.serialization 19 | kotlinSerializationJson = "1.6.0" 20 | # https://github.com/Kotlin/kotlinx.collections.immutable 21 | kotlinxCollectionsImmutable = "0.3.5" 22 | 23 | ## Coroutine 24 | # https://github.com/Kotlin/kotlinx.coroutines 25 | coroutines = "1.7.3" 26 | # https://github.com/cashapp/turbine 27 | coroutines-turbine = "1.0.0" 28 | 29 | ## KSP 30 | # https://github.com/google/ksp 31 | ksp = "1.9.10-1.0.13" 32 | 33 | ## AndroidX 34 | # https://developer.android.com/jetpack/androidx/releases/annotation 35 | androidx-annotation = "1.7.0" 36 | # https://developer.android.com/jetpack/androidx/releases/core 37 | androidx-core = "1.12.0" 38 | # https://developer.android.com/jetpack/androidx/releases/appcompat 39 | androidx-appCompat = "1.7.0-alpha03" 40 | # https://developer.android.com/jetpack/androidx/releases/activity 41 | androidx-activity = "1.8.0-rc01" 42 | # https://developer.android.com/jetpack/androidx/releases/fragment 43 | androidx-fragment = "1.7.0-alpha05" 44 | # https://developer.android.com/jetpack/androidx/releases/recyclerview 45 | androidx-recyclerView = "1.3.1" 46 | # https://developer.android.com/jetpack/androidx/releases/constraintlayout 47 | androidx-constraintLayout = "2.1.4" 48 | # https://developer.android.com/jetpack/androidx/releases/lifecycle 49 | androidx-lifecycle = "2.6.2" 50 | # https://developer.android.com/jetpack/androidx/releases/room 51 | androidx-room = "2.5.2" 52 | # https://developer.android.com/jetpack/androidx/releases/navigation 53 | androidx-navigation = "2.7.3" 54 | # https://github.com/google/dagger/releases 55 | androidx-hilt = "2.48" 56 | # https://developer.android.com/jetpack/androidx/releases/hilt 57 | androidx-hilt-compose = "1.1.0-alpha01" 58 | 59 | ## Compose 60 | # https://developer.android.com/jetpack/androidx/releases/compose 61 | compose = "1.6.0-alpha06" 62 | # https://developer.android.com/jetpack/androidx/releases/compose-kotlin 63 | compose-compilerVersion = "1.5.3" 64 | # https://developer.android.com/jetpack/androidx/releases/compose-material3 65 | compose-material3 = "1.2.0-alpha08" 66 | # https://developer.android.com/jetpack/androidx/releases/navigation 67 | compose-navigation = "2.7.3" 68 | # https://developer.android.com/jetpack/androidx/releases/constraintlayout 69 | compose-constraintLayout = "1.1.0-alpha12" 70 | 71 | ## thdev 72 | # https://github.com/taehwandev/ComposeKeyboardState 73 | compose-keyboardState = "1.6.0-alpha06" 74 | 75 | ## Google 76 | # https://github.com/material-components/material-components-android/releases 77 | google-material = "1.11.0-alpha03" 78 | 79 | ## Network 80 | # https://square.github.io/okhttp/ 81 | network-okhttp = "4.11.0" 82 | # https://github.com/square/retrofit 83 | network-retrofit = "2.9.0" 84 | # https://github.com/JakeWharton/retrofit2-kotlinx-serialization-converter 85 | network-retrofit-kotlinxSerializationConvert = "1.0.0" 86 | 87 | ## ImageLoader 88 | ## Coil 89 | # https://coil-kt.github.io/coil/ 90 | coil = "2.4.0" 91 | 92 | ## Test 93 | # https://developer.android.com/jetpack/androidx/releases/test 94 | test-androidx-core = "1.6.0-alpha02" 95 | test-androidx-runner = "1.6.0-alpha04" 96 | test-androidx-junit = "1.2.0-alpha01" 97 | # https://github.com/mockito/mockito 98 | test-mockito = "5.5.0" 99 | # https://github.com/mockito/mockito-kotlin 100 | test-mockito-kotlin = "5.1.0" 101 | # https://github.com/mannodermaus/android-junit5 102 | test-junit5 = "5.9.3" 103 | junit = "4.13.2" 104 | espresso-core = "3.5.1" 105 | 106 | [libraries] 107 | ## Kotlin 108 | kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } 109 | kotlin-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerializationJson" } 110 | kotlin-collectionsImmutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } 111 | 112 | ## Coroutines 113 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 114 | coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } 115 | 116 | ## AndroidX 117 | androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } 118 | androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } 119 | androidx-appCompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appCompat" } 120 | androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } 121 | androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "androidx-fragment" } 122 | androidx-recyclerView = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerView" } 123 | androidx-constraintLayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx-constraintLayout" } 124 | androidx-lifecycle-viewModel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } 125 | androidx-lifecycleRuntimeCompose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } 126 | androidx-room = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } 127 | androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } 128 | androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } 129 | androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } 130 | androidx-navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } 131 | androidx-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "androidx-hilt" } 132 | androidx-hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "androidx-hilt" } 133 | androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt-compose" } 134 | 135 | ## Compose 136 | compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } 137 | compose-uiTooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } 138 | compose-uiToolingPreview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } 139 | compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } 140 | compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose" } 141 | compose-animation = { module = "androidx.compose.animation:animation", version.ref = "compose" } 142 | compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "compose-material3" } 143 | compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" } 144 | compose-constraintLayout = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "compose-constraintLayout" } 145 | compose-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } 146 | 147 | ## Google 148 | google-material = { module = "com.google.android.material:material", version.ref = "google-material" } 149 | 150 | ## Network 151 | network-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "network-okhttp" } 152 | network-okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "network-okhttp" } 153 | network-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "network-retrofit" } 154 | network-retrofit-kotlinxSerializationConvert = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "network-retrofit-kotlinxSerializationConvert" } 155 | 156 | ## Image loader 157 | ## coil 158 | coil = { module = "io.coil-kt:coil-compose", version.ref = "coil" } 159 | 160 | ## thdev 161 | compose-keyboardState = { module = "tech.thdev:extensions-compose-keyboard-state", version.ref = "compose-keyboardState" } 162 | 163 | 164 | ## Test 165 | test-androidx-core = { module = "androidx.test:core", version.ref = "test-androidx-core" } 166 | test-androidx-runner = { module = "androidx.test:runner", version.ref = "test-androidx-runner" } 167 | test-androidx-junit = { module = "androidx.test.ext:junit", version.ref = "test-androidx-junit" } 168 | 169 | ## Test mockito 170 | test-mockito = { module = "org.mockito:mockito-core", version.ref = "test-mockito" } 171 | test-mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "test-mockito-kotlin" } 172 | 173 | ## Test junit 5 174 | test-junit5 = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "test-junit5" } 175 | test-junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "test-junit5" } 176 | test-junit5-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "test-junit5" } 177 | test-junit5-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "test-junit5" } 178 | 179 | ## Test coroutine 180 | test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 181 | test-coroutines-turbine = { module = "app.cash.turbine:turbine", version.ref = "coroutines-turbine" } 182 | 183 | # plugin 184 | plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 185 | plugin-kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } 186 | plugin-androidGradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" } 187 | junit = { group = "junit", name = "junit", version.ref = "junit" } 188 | espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } 189 | 190 | [plugins] 191 | androidApp = { id = "com.android.application", version.ref = "androidGradlePlugin" } 192 | androidLibrary = { id = "com.android.library", version.ref = "androidGradlePlugin" } 193 | androidHilt = { id = "com.google.dagger.hilt.android", version.ref = "androidx-hilt" } 194 | kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 195 | kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } 196 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Sep 29 20:16:19 KST 2023 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /images/architecture.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/images/architecture.webp -------------------------------------------------------------------------------- /images/like_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/images/like_page.png -------------------------------------------------------------------------------- /images/mad-arch-overview-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/images/mad-arch-overview-ui.png -------------------------------------------------------------------------------- /images/search_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/images/search_page.png -------------------------------------------------------------------------------- /img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/img.png -------------------------------------------------------------------------------- /img_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taehwandev/GithubUserSearch/5a33cc8a646fe34eaa517a38485c047802bc581d/img_1.png -------------------------------------------------------------------------------- /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 | enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") 17 | rootProject.name = "GitHubUserSearch" 18 | 19 | include(":app") 20 | include(":core:network") 21 | include(":core:design-system") 22 | include(":core:data:github") 23 | include(":core:domain:github") 24 | include(":core:database:github") 25 | include(":core:database:github-api") 26 | include(":feature:github-search") --------------------------------------------------------------------------------