├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── discord.xml ├── gradle.xml ├── misc.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── artworkspace │ │ └── github │ │ ├── ExampleInstrumentedTest.kt │ │ └── ui │ │ └── view │ │ ├── FavoriteActivityTest.kt │ │ └── MainActivityTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── artworkspace │ │ │ └── github │ │ │ ├── MyApplication.kt │ │ │ ├── adapter │ │ │ ├── ListUserAdapter.kt │ │ │ └── SectionPagerAdapter.kt │ │ │ ├── data │ │ │ ├── AppPreferences.kt │ │ │ ├── Result.kt │ │ │ ├── UserRepository.kt │ │ │ ├── local │ │ │ │ ├── entity │ │ │ │ │ └── UserEntity.kt │ │ │ │ └── room │ │ │ │ │ ├── UserDao.kt │ │ │ │ │ └── UserDatabase.kt │ │ │ └── remote │ │ │ │ ├── response │ │ │ │ └── ResponseGithub.kt │ │ │ │ └── retrofit │ │ │ │ ├── ApiConfig.kt │ │ │ │ └── ApiService.kt │ │ │ ├── di │ │ │ ├── ApiModule.kt │ │ │ ├── DataStoreModule.kt │ │ │ └── DatabaseModule.kt │ │ │ ├── ui │ │ │ ├── view │ │ │ │ ├── DetailUserActivity.kt │ │ │ │ ├── FavoriteActivity.kt │ │ │ │ ├── FollowersFragment.kt │ │ │ │ ├── FollowingFragment.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── SettingActivity.kt │ │ │ │ └── SplashActivity.kt │ │ │ └── viewmodel │ │ │ │ ├── DetailViewModel.kt │ │ │ │ ├── FavoriteViewModel.kt │ │ │ │ ├── FollowersViewModel.kt │ │ │ │ ├── FollowingViewModel.kt │ │ │ │ ├── MainViewModel.kt │ │ │ │ └── SettingViewModel.kt │ │ │ └── utils │ │ │ ├── EspressoIdlingResource.kt │ │ │ └── UIHelper.kt │ └── res │ │ ├── drawable-v24 │ │ └── profile_placeholder.png │ │ ├── drawable │ │ ├── dummy_profile.png │ │ ├── github_logo_black.png │ │ ├── github_logo_white.png │ │ ├── ic_baseline_apartment_16.xml │ │ ├── ic_baseline_blog_16.xml │ │ ├── ic_baseline_favorite_24.xml │ │ ├── ic_baseline_favorite_border_24.xml │ │ ├── ic_baseline_location_on_16.xml │ │ ├── ic_baseline_person_16.xml │ │ ├── ic_baseline_search_24.xml │ │ ├── ic_baseline_settings_24.xml │ │ ├── ic_github_mark.xml │ │ ├── ic_github_mark_white.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── splash_bg_dark.xml │ │ └── splash_bg_light.xml │ │ ├── layout │ │ ├── activity_detail_user.xml │ │ ├── activity_favorite.xml │ │ ├── activity_main.xml │ │ ├── activity_setting.xml │ │ ├── fragment_followers.xml │ │ ├── fragment_following.xml │ │ └── user_card.xml │ │ ├── menu │ │ └── home_menu.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── ic_launcher_background.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── artworkspace │ └── github │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | /app/src/main/java/com/artworkspace/github/Utils.kt 17 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/discord.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 34 | 35 | 36 | 37 | 38 | 39 | 41 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bangkit 2022: Android Fundamental Submission 📱 2 |

This is a repository that contains the source code of my submissions project at Dicoding "Belajar Fundamental Aplikasi Android" course, start from the first submission until the final submission. This course is a part of self-paced learning at Bangkit 2022 Mobile Development learning path. I try to implement the best practices of the Kotlin programming language and Android framework to this project.

3 | 4 | ## Disclaimer ⚠️ 5 | This repository is created for sharing and educational purposes only. Plagiarism is unacceptable and is not my responsibility as the author. 6 | 7 | ## Submission 1: GitHub User App 8 | * Rating: ⭐⭐⭐⭐⭐ 9 | * There are some Android components that I implement in this submission project: 10 | * Intent 11 | * Parcelable 12 | * RecyclerView 13 | * ConstraintLayout 14 | * What I learned from this submission: 15 | * Writing documentation with KDoc syntax 16 | * How to implement Kotlin's scope function in an Android project 17 | * Implement Kotlin's coding convention standard 18 | * Implement Dark and Light mode in Android apps 19 | * Screenshot of the application 20 | 21 | 22 | ## Submission 2: GitHub User App 23 | * Rating: ⭐⭐⭐⭐⭐ 24 | * There are some Android components that I implement in this submission project: 25 | * Retrofit 26 | * SearchView 27 | * CoordinatorLayout 28 | * TabLayout 29 | * What I learned from this submission: 30 | * Managing memory leaks with LeakCanary 31 | * How to store an API KEY in the right place 32 | * How to using CoordinatorLayout to improve UX in the detail user page 33 | * Writing an Extensions for a ImageView, this simplify the process to render an image with Glide library 34 | * How ViewModel and LiveData actually works. 35 | * Screenshot of the application 36 | 37 | 38 | ## Submission 3: GitHub User App 39 | * Rating: ⭐⭐⭐⭐⭐ 40 | * There are some Android components that I implement in this submission project: 41 | * Room 42 | * DataStore 43 | * Espresso 44 | * What I learned from this submission: 45 | * How to implement Repository Pattern with Kotlin Flow 46 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'kotlin-kapt' 5 | id 'kotlin-parcelize' 6 | id 'dagger.hilt.android.plugin' 7 | } 8 | 9 | android { 10 | compileSdk 31 11 | 12 | defaultConfig { 13 | applicationId "com.artworkspace.github" 14 | minSdk 23 15 | targetSdk 31 16 | versionCode 1 17 | versionName "1.0" 18 | 19 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 20 | 21 | def localProperties = new Properties() 22 | localProperties.load(new FileInputStream(rootProject.file("local.properties"))) 23 | 24 | buildConfigField("String", "API_KEY", "\"" + localProperties['apiKey'] + "\"") 25 | } 26 | 27 | buildTypes { 28 | release { 29 | minifyEnabled false 30 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 31 | } 32 | } 33 | 34 | buildFeatures { 35 | viewBinding true 36 | } 37 | 38 | compileOptions { 39 | sourceCompatibility JavaVersion.VERSION_1_8 40 | targetCompatibility JavaVersion.VERSION_1_8 41 | } 42 | kotlinOptions { 43 | jvmTarget = '1.8' 44 | } 45 | } 46 | 47 | dependencies { 48 | 49 | // Android 50 | implementation 'androidx.core:core-ktx:1.7.0' 51 | implementation "androidx.activity:activity-ktx:1.4.0" 52 | implementation "androidx.fragment:fragment-ktx:1.4.1" 53 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" 54 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.1" 55 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1" 56 | implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" 57 | implementation 'androidx.appcompat:appcompat:1.4.1' 58 | implementation 'com.google.android.material:material:1.5.0' 59 | implementation 'androidx.constraintlayout:constraintlayout:2.1.3' 60 | implementation("androidx.coordinatorlayout:coordinatorlayout:1.2.0") 61 | implementation "androidx.viewpager2:viewpager2:1.0.0" 62 | 63 | // Hilt 64 | implementation 'com.google.dagger:hilt-android:2.41' 65 | kapt 'com.google.dagger:hilt-compiler:2.41' 66 | 67 | // Room 68 | implementation 'androidx.room:room-runtime:2.4.2' 69 | implementation "androidx.room:room-ktx:2.4.2" 70 | kapt 'androidx.room:room-compiler:2.4.2' 71 | 72 | // Circular Image View 73 | implementation 'de.hdodenhof:circleimageview:3.1.0' 74 | 75 | // Glide 76 | implementation 'com.github.bumptech.glide:glide:4.13.0' 77 | implementation 'androidx.legacy:legacy-support-v4:1.0.0' 78 | annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0' 79 | 80 | // Retrofit 81 | implementation 'com.github.bumptech.glide:glide:4.13.0' 82 | implementation 'com.squareup.retrofit2:retrofit:2.9.0' 83 | implementation "com.squareup.retrofit2:converter-gson:2.9.0" 84 | implementation "com.squareup.okhttp3:logging-interceptor:4.9.0" 85 | 86 | // DataStore 87 | implementation "androidx.datastore:datastore-preferences:1.0.0" 88 | 89 | // Testing 90 | implementation 'androidx.test.espresso:espresso-idling-resource:3.4.0' 91 | testImplementation 'junit:junit:4.13.2' 92 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 93 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 94 | androidTestImplementation 'androidx.test:runner:1.4.0' 95 | androidTestImplementation 'androidx.test:rules:1.4.0' 96 | androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' 97 | 98 | // LeakCanary 99 | debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' 100 | } 101 | 102 | kapt { 103 | correctErrorTypes true 104 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/artworkspace/github/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("com.artworkspace.github", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/artworkspace/github/ui/view/FavoriteActivityTest.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import androidx.test.core.app.ActivityScenario 4 | import androidx.test.espresso.Espresso.onView 5 | import androidx.test.espresso.Espresso.pressBack 6 | import androidx.test.espresso.IdlingRegistry 7 | import androidx.test.espresso.action.ViewActions.click 8 | import androidx.test.espresso.assertion.ViewAssertions.matches 9 | import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition 10 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 11 | import androidx.test.espresso.matcher.ViewMatchers.withId 12 | import com.artworkspace.github.R 13 | import com.artworkspace.github.adapter.ListUserAdapter 14 | import com.artworkspace.github.utils.EspressoIdlingResource 15 | import org.junit.After 16 | import org.junit.Before 17 | import org.junit.Test 18 | 19 | class FavoriteActivityTest { 20 | 21 | @Before 22 | fun setUp() { 23 | IdlingRegistry.getInstance().register(EspressoIdlingResource.idlingResource) 24 | ActivityScenario.launch(FavoriteActivity::class.java) 25 | } 26 | 27 | @After 28 | fun tearDown() { 29 | IdlingRegistry.getInstance().unregister(EspressoIdlingResource.idlingResource) 30 | } 31 | 32 | @Test 33 | fun testDeleteAllFavoriteUser() { 34 | onView(withId(R.id.rv_favorite)).check(matches(isDisplayed())) 35 | 36 | while (true) { 37 | try { 38 | onView(withId(R.id.rv_favorite)).perform( 39 | actionOnItemAtPosition(0, click()) 40 | ) 41 | 42 | onView(withId(R.id.user_detail_container)).check(matches(isDisplayed())) 43 | onView(withId(R.id.tabs)).check(matches(isDisplayed())) 44 | onView(withId(R.id.fab_favorite)).check(matches(isDisplayed())) 45 | 46 | onView(withId(R.id.fab_favorite)).perform(click()) 47 | pressBack() 48 | } catch (e: Exception) { 49 | break 50 | } 51 | } 52 | 53 | onView(withId(R.id.tv_message)).check(matches(isDisplayed())) 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/artworkspace/github/ui/view/MainActivityTest.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import android.content.res.Resources 4 | import android.view.KeyEvent 5 | import androidx.test.core.app.ActivityScenario 6 | import androidx.test.espresso.Espresso.onView 7 | import androidx.test.espresso.Espresso.pressBack 8 | import androidx.test.espresso.IdlingRegistry 9 | import androidx.test.espresso.action.ViewActions.* 10 | import androidx.test.espresso.assertion.ViewAssertions.matches 11 | import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition 12 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 13 | import androidx.test.espresso.matcher.ViewMatchers.withId 14 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 15 | import com.artworkspace.github.R 16 | import com.artworkspace.github.adapter.ListUserAdapter 17 | import com.artworkspace.github.utils.EspressoIdlingResource 18 | import org.junit.After 19 | import org.junit.Before 20 | import org.junit.Test 21 | import org.junit.runner.RunWith 22 | 23 | @RunWith(AndroidJUnit4ClassRunner::class) 24 | class MainActivityTest { 25 | @Before 26 | fun setup() { 27 | IdlingRegistry.getInstance().register(EspressoIdlingResource.idlingResource) 28 | ActivityScenario.launch(MainActivity::class.java) 29 | } 30 | 31 | @After 32 | fun tearDown() { 33 | IdlingRegistry.getInstance().unregister(EspressoIdlingResource.idlingResource) 34 | } 35 | 36 | @Test 37 | fun testComponentShowCorrectly() { 38 | onView(withId(R.id.toolbar_home)).check(matches(isDisplayed())) 39 | onView(withId(R.id.tv_result_count)).check(matches(isDisplayed())) 40 | onView(withId(R.id.rv_users)).check(matches(isDisplayed())) 41 | } 42 | 43 | @Test 44 | fun testSelectFirstUser() { 45 | onView(withId(R.id.rv_users)).check(matches(isDisplayed())) 46 | onView(withId(R.id.rv_users)).perform( 47 | actionOnItemAtPosition( 48 | 0, 49 | click() 50 | ) 51 | ) 52 | 53 | onView(withId(R.id.user_detail_container)).check(matches(isDisplayed())) 54 | onView(withId(R.id.tabs)).check(matches(isDisplayed())) 55 | onView(withId(R.id.fab_favorite)).check(matches(isDisplayed())) 56 | } 57 | 58 | @Test 59 | fun testAddUserToFavorite() { 60 | onView(withId(R.id.rv_users)).check(matches(isDisplayed())) 61 | onView(withId(R.id.rv_users)).perform( 62 | actionOnItemAtPosition( 63 | 0, 64 | click() 65 | ) 66 | ) 67 | 68 | onView(withId(R.id.user_detail_container)).check(matches(isDisplayed())) 69 | onView(withId(R.id.tabs)).check(matches(isDisplayed())) 70 | onView(withId(R.id.fab_favorite)).check(matches(isDisplayed())) 71 | onView(withId(R.id.fab_favorite)).perform(click()) 72 | pressBack() 73 | 74 | onView(withId(R.id.favorite)).perform(click()) 75 | onView(withId(R.id.rv_favorite)).check(matches(isDisplayed())) 76 | } 77 | 78 | @Test 79 | fun testDeleteUserFromFavorite() { 80 | onView(withId(R.id.favorite)).check(matches(isDisplayed())) 81 | onView(withId(R.id.favorite)).perform(click()) 82 | 83 | onView(withId(R.id.rv_favorite)).perform( 84 | actionOnItemAtPosition(0, click()) 85 | ) 86 | 87 | onView(withId(R.id.user_detail_container)).check(matches(isDisplayed())) 88 | onView(withId(R.id.tabs)).check(matches(isDisplayed())) 89 | onView(withId(R.id.fab_favorite)).check(matches(isDisplayed())) 90 | onView(withId(R.id.fab_favorite)).perform(click()) 91 | pressBack() 92 | } 93 | 94 | @Test 95 | fun testSearchUserFound() { 96 | onView(withId(R.id.search)).check(matches(isDisplayed())) 97 | onView(withId(R.id.search)).perform(click()) 98 | 99 | onView( 100 | withId(androidx.appcompat.R.id.search_src_text) 101 | ).perform( 102 | clearText(), typeText("fikriyusrihan") 103 | ).perform(pressKey(KeyEvent.KEYCODE_ENTER)) 104 | 105 | onView(withId(R.id.rv_users)).check(matches(isDisplayed())) 106 | onView(withId(R.id.rv_users)).perform( 107 | actionOnItemAtPosition( 108 | 0, 109 | click() 110 | ) 111 | ) 112 | 113 | onView(withId(R.id.user_detail_container)).check(matches(isDisplayed())) 114 | onView(withId(R.id.tabs)).check(matches(isDisplayed())) 115 | onView(withId(R.id.fab_favorite)).check(matches(isDisplayed())) 116 | } 117 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 15 | 18 | 21 | 24 | 27 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-fundamental-submission/3548de13ebdac8671b1cd96af3328f33d2aa2a28/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/MyApplication.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class MyApplication : Application() -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/adapter/ListUserAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.recyclerview.widget.RecyclerView 6 | import com.artworkspace.github.data.remote.response.SimpleUser 7 | import com.artworkspace.github.utils.UIHelper.Companion.setImageGlide 8 | import com.artworkspace.github.databinding.UserCardBinding 9 | 10 | class ListUserAdapter(private val listUser: ArrayList) : 11 | RecyclerView.Adapter() { 12 | 13 | private lateinit var onItemClickCallback: OnItemClickCallback 14 | 15 | /** 16 | * Set an item click callback 17 | * 18 | * @param onItemClickCallback object that implements onItemClickCallback 19 | * @return Unit 20 | */ 21 | fun setOnItemClickCallback(onItemClickCallback: OnItemClickCallback) { 22 | this.onItemClickCallback = onItemClickCallback 23 | } 24 | 25 | class ListViewHolder(var binding: UserCardBinding) : RecyclerView.ViewHolder(binding.root) 26 | 27 | override fun onCreateViewHolder( 28 | parent: ViewGroup, 29 | viewType: Int 30 | ): ListViewHolder { 31 | val binding = UserCardBinding.inflate(LayoutInflater.from(parent.context), parent, false) 32 | return ListViewHolder(binding) 33 | } 34 | 35 | override fun onBindViewHolder(holder: ListViewHolder, position: Int) { 36 | val user = listUser[position] 37 | 38 | holder.binding.apply { 39 | cardTvUsername.text = user.login 40 | cardImageProfile.setImageGlide(holder.itemView.context, user.avatarUrl) 41 | } 42 | 43 | holder.itemView.setOnClickListener { onItemClickCallback.onItemClicked(user) } 44 | } 45 | 46 | override fun getItemCount(): Int = listUser.size 47 | 48 | interface OnItemClickCallback { 49 | fun onItemClicked(user: SimpleUser) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/adapter/SectionPagerAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.adapter 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.fragment.app.Fragment 6 | import androidx.viewpager2.adapter.FragmentStateAdapter 7 | import com.artworkspace.github.ui.view.FollowersFragment 8 | import com.artworkspace.github.ui.view.FollowingFragment 9 | 10 | class SectionPagerAdapter(activity: AppCompatActivity, private val username: String) : 11 | FragmentStateAdapter(activity) { 12 | 13 | override fun getItemCount(): Int = 2 14 | 15 | override fun createFragment(position: Int): Fragment { 16 | return when (position) { 17 | 0 -> { 18 | FollowersFragment().apply { 19 | arguments = Bundle().apply { 20 | putString(ARGS_USERNAME, username) 21 | } 22 | } 23 | } 24 | else -> { 25 | FollowingFragment().apply { 26 | arguments = Bundle().apply { 27 | putString(ARGS_USERNAME, username) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | 34 | companion object { 35 | const val ARGS_USERNAME = "username" 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/AppPreferences.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.booleanPreferencesKey 6 | import androidx.datastore.preferences.core.edit 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.map 9 | import javax.inject.Inject 10 | 11 | class AppPreferences @Inject constructor(private val dataStore: DataStore) { 12 | /** 13 | * Get theme setting for dark mode state from DataStore 14 | * 15 | * @return Flow 16 | */ 17 | fun getThemeSetting(): Flow { 18 | return dataStore.data.map { preferences -> 19 | preferences[THEME_KEY] ?: false 20 | } 21 | } 22 | 23 | /** 24 | * Save theme setting for dark mode state to DataStore 25 | * 26 | * @param darkModeState Dark mode state to save 27 | */ 28 | suspend fun saveThemeSetting(darkModeState: Boolean) { 29 | dataStore.edit { preferences -> 30 | preferences[THEME_KEY] = darkModeState 31 | } 32 | } 33 | 34 | companion object { 35 | private val THEME_KEY = booleanPreferencesKey("theme_setting") 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/Result.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data 2 | 3 | sealed class Result private constructor() { 4 | data class Success(val data: T) : Result() 5 | data class Error(val error: String) : Result() 6 | object Loading : Result() 7 | } 8 | -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data 2 | 3 | import android.util.Log 4 | import com.artworkspace.github.BuildConfig 5 | import com.artworkspace.github.data.local.entity.UserEntity 6 | import com.artworkspace.github.data.local.room.UserDao 7 | import com.artworkspace.github.data.remote.response.SimpleUser 8 | import com.artworkspace.github.data.remote.response.User 9 | import com.artworkspace.github.data.remote.retrofit.ApiService 10 | import dagger.Module 11 | import dagger.hilt.InstallIn 12 | import dagger.hilt.android.components.ActivityRetainedComponent 13 | import kotlinx.coroutines.flow.Flow 14 | import kotlinx.coroutines.flow.flow 15 | import javax.inject.Inject 16 | 17 | class UserRepository @Inject constructor( 18 | private val apiService: ApiService, 19 | private val userDao: UserDao, 20 | private val preferences: AppPreferences 21 | ) { 22 | 23 | /** 24 | * Search GitHub user with API 25 | * 26 | * @param q GitHub username query 27 | * @return LiveData>> 28 | */ 29 | fun searchUserByUsername(q: String): Flow>> = flow { 30 | emit(Result.Loading) 31 | try { 32 | val users = apiService.searchUsername(token = API_TOKEN, q).items 33 | emit(Result.Success(users)) 34 | } catch (e: Exception) { 35 | Log.d(TAG, "searchUserByUsername: ${e.message.toString()}") 36 | emit(Result.Error(e.message.toString())) 37 | } 38 | } 39 | 40 | /** 41 | * Get following information of an user from API 42 | * 43 | * @param id GitHub username 44 | * @return Flow>> 45 | */ 46 | fun getUserFollowing(id: String): Flow>> = flow { 47 | emit(Result.Loading) 48 | try { 49 | val users = apiService.getUserFollowing(token = API_TOKEN, id) 50 | emit(Result.Success(users)) 51 | } catch (e: Exception) { 52 | Log.d(TAG, "getUserFollowing: ${e.message.toString()}") 53 | emit(Result.Error(e.message.toString())) 54 | } 55 | } 56 | 57 | /** 58 | * Get followers information of an user from API 59 | * 60 | * @param id GitHub username 61 | * @return Flow>> 62 | */ 63 | fun getUserFollowers(id: String): Flow>> = flow { 64 | emit(Result.Loading) 65 | try { 66 | val users = apiService.getUserFollowers(token = API_TOKEN, id) 67 | emit(Result.Success(users)) 68 | } catch (e: Exception) { 69 | Log.d(TAG, "getUserFollowers: ${e.message.toString()}") 70 | emit(Result.Error(e.message.toString())) 71 | } 72 | } 73 | 74 | /** 75 | * Get user detail information from API 76 | * 77 | * @param id GitHub username 78 | * @return LiveData> 79 | */ 80 | fun getUserDetail(id: String): Flow> = flow { 81 | emit(Result.Loading) 82 | try { 83 | val user = apiService.getUserDetail(token = API_TOKEN, id) 84 | emit(Result.Success(user)) 85 | } catch (e: Exception) { 86 | Log.d(TAG, "getUserDetail: ${e.message.toString()}") 87 | emit(Result.Error(e.message.toString())) 88 | } 89 | } 90 | 91 | /** 92 | * Determine this user is favorite or not 93 | * 94 | * @param id User id 95 | * @return Flow 96 | */ 97 | fun isFavoriteUser(id: String): Flow = userDao.isFavoriteUser(id) 98 | 99 | /** 100 | * Get all favorite users from database 101 | * 102 | * @return LiveData> 103 | */ 104 | fun getAllFavoriteUsers(): Flow> = userDao.getAllUsers() 105 | 106 | /** 107 | * Delete a favorite user from database 108 | * 109 | * @param user User to delete 110 | */ 111 | suspend fun deleteFromFavorite(user: UserEntity) { 112 | userDao.delete(user) 113 | } 114 | 115 | /** 116 | * Save user as favorite to database 117 | * 118 | * @param user User to save 119 | */ 120 | suspend fun saveUserAsFavorite(user: UserEntity) { 121 | userDao.insert(user) 122 | } 123 | 124 | /** 125 | * Get theme setting for dark mode state from DataStore 126 | * 127 | * @return Flow 128 | */ 129 | fun getThemeSetting(): Flow = preferences.getThemeSetting() 130 | 131 | /** 132 | * Save theme setting for dark mode state to DataStore 133 | * 134 | * @param isDarkModeActive Dark mode state to save 135 | */ 136 | suspend fun saveThemeSetting(isDarkModeActive: Boolean) { 137 | preferences.saveThemeSetting(isDarkModeActive) 138 | } 139 | 140 | companion object { 141 | private const val API_TOKEN = "Bearer ${BuildConfig.API_KEY}" 142 | private val TAG = UserRepository::class.java.simpleName 143 | } 144 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/local/entity/UserEntity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data.local.entity 2 | 3 | import android.os.Parcelable 4 | import androidx.room.ColumnInfo 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | import kotlinx.parcelize.Parcelize 8 | 9 | @Entity(tableName = "user") 10 | @Parcelize 11 | data class UserEntity( 12 | @PrimaryKey(autoGenerate = false) 13 | @ColumnInfo(name = "id") 14 | var id: String, 15 | 16 | @ColumnInfo(name = "avatar_url") 17 | var avatarUrl: String, 18 | 19 | @ColumnInfo(name = "is_favorite") 20 | var isFavorite: Boolean 21 | ) : Parcelable -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/local/room/UserDao.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data.local.room 2 | 3 | import androidx.room.* 4 | import com.artworkspace.github.data.local.entity.UserEntity 5 | import kotlinx.coroutines.flow.Flow 6 | 7 | @Dao 8 | interface UserDao { 9 | @Insert(onConflict = OnConflictStrategy.IGNORE) 10 | suspend fun insert(userEntity: UserEntity) 11 | 12 | @Update 13 | suspend fun update(userEntity: UserEntity) 14 | 15 | @Delete 16 | suspend fun delete(userEntity: UserEntity) 17 | 18 | @Query("SELECT * FROM user ORDER BY id ASC") 19 | fun getAllUsers(): Flow> 20 | 21 | @Query("SELECT EXISTS(SELECT * FROM user WHERE id = :id AND is_favorite = 1)") 22 | fun isFavoriteUser(id: String): Flow 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/local/room/UserDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data.local.room 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.artworkspace.github.data.local.entity.UserEntity 6 | 7 | @Database(entities = [UserEntity::class], version = 1, exportSchema = false) 8 | abstract class UserDatabase : RoomDatabase() { 9 | abstract fun userDao(): UserDao 10 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/remote/response/ResponseGithub.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data.remote.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class ResponseSearch( 6 | 7 | @field:SerializedName("items") 8 | val items: ArrayList 9 | ) 10 | 11 | data class User( 12 | 13 | @field:SerializedName("bio") 14 | val bio: String?, 15 | 16 | @field:SerializedName("login") 17 | val login: String, 18 | 19 | @field:SerializedName("blog") 20 | val blog: String?, 21 | 22 | @field:SerializedName("followers") 23 | val followers: Int, 24 | 25 | @field:SerializedName("avatar_url") 26 | val avatarUrl: String, 27 | 28 | @field:SerializedName("html_url") 29 | val htmlUrl: String, 30 | 31 | @field:SerializedName("following") 32 | val following: Int, 33 | 34 | @field:SerializedName("name") 35 | val name: String?, 36 | 37 | @field:SerializedName("company") 38 | val company: String?, 39 | 40 | @field:SerializedName("location") 41 | val location: String?, 42 | 43 | @field:SerializedName("public_repos") 44 | val publicRepos: Int, 45 | ) 46 | 47 | data class SimpleUser( 48 | 49 | @field:SerializedName("avatar_url") 50 | val avatarUrl: String, 51 | 52 | @field:SerializedName("login") 53 | val login: String 54 | ) 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/remote/retrofit/ApiConfig.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data.remote.retrofit 2 | 3 | import com.artworkspace.github.BuildConfig 4 | import okhttp3.OkHttpClient 5 | import okhttp3.logging.HttpLoggingInterceptor 6 | import retrofit2.Retrofit 7 | import retrofit2.converter.gson.GsonConverterFactory 8 | 9 | class ApiConfig { 10 | companion object { 11 | /** 12 | * ApiService provider 13 | * 14 | * @return ApiService 15 | */ 16 | fun getApiService(): ApiService { 17 | val loggingInterceptor = if (BuildConfig.DEBUG) { 18 | HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY) 19 | } else { 20 | HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.NONE) 21 | } 22 | 23 | val client = OkHttpClient.Builder() 24 | .addInterceptor(loggingInterceptor) 25 | .build() 26 | 27 | val retrofit = Retrofit.Builder() 28 | .baseUrl("https://api.github.com/") 29 | .addConverterFactory(GsonConverterFactory.create()) 30 | .client(client) 31 | .build() 32 | 33 | return retrofit.create(ApiService::class.java) 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/data/remote/retrofit/ApiService.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.data.remote.retrofit 2 | 3 | import com.artworkspace.github.data.remote.response.ResponseSearch 4 | import com.artworkspace.github.data.remote.response.SimpleUser 5 | import com.artworkspace.github.data.remote.response.User 6 | import retrofit2.http.GET 7 | import retrofit2.http.Header 8 | import retrofit2.http.Path 9 | import retrofit2.http.Query 10 | 11 | interface ApiService { 12 | /** 13 | * Search GitHub user with username 14 | * 15 | * @param token GitHub token auth 16 | * @param q Query 17 | * @return Call 18 | */ 19 | @GET("search/users") 20 | suspend fun searchUsername( 21 | @Header("Authorization") token: String, 22 | @Query("q") q: String 23 | ): ResponseSearch 24 | 25 | 26 | /** 27 | * Get detail information of user by username 28 | * 29 | * @param token GitHub token auth 30 | * @param username Username 31 | * @return Call 32 | */ 33 | @GET("users/{username}") 34 | suspend fun getUserDetail( 35 | @Header("Authorization") token: String, 36 | @Path("username") username: String 37 | ): User 38 | 39 | 40 | /** 41 | * Get followers information of an user 42 | * 43 | * @param token GitHub token auth 44 | * @param username Username 45 | * @return Call> 46 | */ 47 | @GET("users/{username}/followers") 48 | suspend fun getUserFollowers( 49 | @Header("Authorization") token: String, 50 | @Path("username") username: String 51 | ): ArrayList 52 | 53 | 54 | /** 55 | * Get following information of an user 56 | * 57 | * @param token GitHub token auth 58 | * @param username Username 59 | * @return Call> 60 | */ 61 | @GET("users/{username}/following") 62 | suspend fun getUserFollowing( 63 | @Header("Authorization") token: String, 64 | @Path("username") username: String 65 | ): ArrayList 66 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/di/ApiModule.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.di 2 | 3 | import com.artworkspace.github.data.remote.retrofit.ApiConfig 4 | import com.artworkspace.github.data.remote.retrofit.ApiService 5 | import dagger.Module 6 | import dagger.Provides 7 | import dagger.hilt.InstallIn 8 | import dagger.hilt.components.SingletonComponent 9 | import javax.inject.Singleton 10 | 11 | @Module 12 | @InstallIn(SingletonComponent::class) 13 | class ApiModule { 14 | 15 | @Provides 16 | @Singleton 17 | fun provideApiService(): ApiService = ApiConfig.getApiService() 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/di/DataStoreModule.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.di 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.preferencesDataStore 7 | import com.artworkspace.github.data.AppPreferences 8 | import dagger.Module 9 | import dagger.Provides 10 | import dagger.hilt.InstallIn 11 | import dagger.hilt.android.qualifiers.ApplicationContext 12 | import dagger.hilt.components.SingletonComponent 13 | import javax.inject.Singleton 14 | 15 | private val Context.dataStore: DataStore by preferencesDataStore(name = "application") 16 | 17 | @Module 18 | @InstallIn(SingletonComponent::class) 19 | class DataStoreModule { 20 | 21 | @Provides 22 | fun provideDataStore(@ApplicationContext context: Context): DataStore = 23 | context.dataStore 24 | 25 | @Provides 26 | @Singleton 27 | fun provideAppPreferences(dataStore: DataStore): AppPreferences = 28 | AppPreferences(dataStore) 29 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/di/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.di 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.artworkspace.github.data.local.room.UserDao 6 | import com.artworkspace.github.data.local.room.UserDatabase 7 | import dagger.Module 8 | import dagger.Provides 9 | import dagger.hilt.InstallIn 10 | import dagger.hilt.android.qualifiers.ApplicationContext 11 | import dagger.hilt.components.SingletonComponent 12 | import javax.inject.Singleton 13 | 14 | @Module 15 | @InstallIn(SingletonComponent::class) 16 | class DatabaseModule { 17 | 18 | @Provides 19 | fun provideUserDao(userDatabase: UserDatabase): UserDao = userDatabase.userDao() 20 | 21 | @Provides 22 | @Singleton 23 | fun provideUserDatabase(@ApplicationContext context: Context): UserDatabase { 24 | return Room.databaseBuilder( 25 | context.applicationContext, 26 | UserDatabase::class.java, 27 | "note_database" 28 | ).build() 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/view/DetailUserActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import android.content.Intent 4 | import android.net.Uri 5 | import android.os.Bundle 6 | import android.view.View 7 | import android.widget.Toast 8 | import androidx.activity.viewModels 9 | import androidx.appcompat.app.AppCompatActivity 10 | import androidx.lifecycle.Lifecycle 11 | import androidx.lifecycle.lifecycleScope 12 | import androidx.lifecycle.repeatOnLifecycle 13 | import androidx.viewpager2.widget.ViewPager2 14 | import com.artworkspace.github.R 15 | import com.artworkspace.github.adapter.SectionPagerAdapter 16 | import com.artworkspace.github.data.Result 17 | import com.artworkspace.github.data.local.entity.UserEntity 18 | import com.artworkspace.github.data.remote.response.User 19 | import com.artworkspace.github.databinding.ActivityDetailUserBinding 20 | import com.artworkspace.github.ui.viewmodel.DetailViewModel 21 | import com.artworkspace.github.utils.EspressoIdlingResource 22 | import com.artworkspace.github.utils.UIHelper.Companion.setAndVisible 23 | import com.artworkspace.github.utils.UIHelper.Companion.setImageGlide 24 | import com.google.android.material.tabs.TabLayout 25 | import com.google.android.material.tabs.TabLayoutMediator 26 | import dagger.hilt.android.AndroidEntryPoint 27 | import kotlinx.coroutines.flow.collect 28 | import kotlinx.coroutines.launch 29 | 30 | @AndroidEntryPoint 31 | class DetailUserActivity : AppCompatActivity(), View.OnClickListener { 32 | 33 | private var _binding: ActivityDetailUserBinding? = null 34 | private val binding get() = _binding!! 35 | 36 | private var username: String? = null 37 | private var profileUrl: String? = null 38 | private var userDetail: UserEntity? = null 39 | private var isFavorite: Boolean? = false 40 | 41 | private val detailViewModel: DetailViewModel by viewModels() 42 | 43 | override fun onCreate(savedInstanceState: Bundle?) { 44 | super.onCreate(savedInstanceState) 45 | 46 | _binding = ActivityDetailUserBinding.inflate(layoutInflater) 47 | username = intent.extras?.get(EXTRA_DETAIL) as String 48 | 49 | setContentView(binding.root) 50 | setViewPager() 51 | setToolbar(getString(R.string.profile)) 52 | 53 | lifecycleScope.launch { 54 | repeatOnLifecycle(Lifecycle.State.STARTED) { 55 | launch { 56 | detailViewModel.userDetail.collect { result -> 57 | onDetailUserReceived(result) 58 | } 59 | } 60 | launch { 61 | detailViewModel.isFavoriteUser(username ?: "").collect { state -> 62 | isFavoriteUser(state) 63 | isFavorite = state 64 | } 65 | } 66 | launch { 67 | detailViewModel.isLoaded.collect { loaded -> 68 | if (!loaded) detailViewModel.getDetailUser(username ?: "") 69 | } 70 | } 71 | } 72 | } 73 | 74 | binding.btnOpen.setOnClickListener(this) 75 | binding.fabFavorite.setOnClickListener(this) 76 | } 77 | 78 | override fun onStart() { 79 | super.onStart() 80 | EspressoIdlingResource.increment() 81 | } 82 | 83 | override fun onSupportNavigateUp(): Boolean { 84 | onBackPressed() 85 | return true 86 | } 87 | 88 | override fun onClick(v: View?) { 89 | when (v?.id) { 90 | R.id.btn_open -> { 91 | Intent(Intent.ACTION_VIEW).apply { 92 | data = Uri.parse(profileUrl) 93 | }.also { 94 | startActivity(it) 95 | } 96 | } 97 | R.id.fab_favorite -> { 98 | if (isFavorite == true) { 99 | userDetail?.let { detailViewModel.deleteFromFavorite(it) } 100 | isFavoriteUser(false) 101 | Toast.makeText(this, "User deleted from favorite", Toast.LENGTH_SHORT).show() 102 | } else { 103 | userDetail?.let { detailViewModel.saveAsFavorite(it) } 104 | isFavoriteUser(true) 105 | Toast.makeText(this, "User added to favorite", Toast.LENGTH_SHORT).show() 106 | } 107 | } 108 | } 109 | } 110 | 111 | override fun onDestroy() { 112 | _binding = null 113 | username = null 114 | profileUrl = null 115 | isFavorite = null 116 | super.onDestroy() 117 | } 118 | 119 | /** 120 | * Parsing data to UI based on result 121 | * 122 | * @param result Result from API 123 | */ 124 | private fun onDetailUserReceived(result: Result) { 125 | when (result) { 126 | is Result.Loading -> showLoading(true) 127 | is Result.Error -> { 128 | errorOccurred() 129 | showLoading(false) 130 | Toast.makeText(this, result.error, Toast.LENGTH_SHORT).show() 131 | } 132 | is Result.Success -> { 133 | result.data.let { user -> 134 | parseUserDetail(user) 135 | 136 | val userEntity = UserEntity( 137 | user.login, 138 | user.avatarUrl, 139 | true 140 | ) 141 | 142 | userDetail = userEntity 143 | profileUrl = user.htmlUrl 144 | } 145 | 146 | showLoading(false) 147 | EspressoIdlingResource.decrement() 148 | } 149 | } 150 | } 151 | 152 | /** 153 | * Determine which icon to display in FAB 154 | * 155 | * @param favorite Is this favorite user? 156 | */ 157 | private fun isFavoriteUser(favorite: Boolean) { 158 | if (favorite) { 159 | binding.fabFavorite.setImageResource(R.drawable.ic_baseline_favorite_24) 160 | } else { 161 | binding.fabFavorite.setImageResource(R.drawable.ic_baseline_favorite_border_24) 162 | } 163 | } 164 | 165 | /** 166 | * Setting UI when an error occurred 167 | * 168 | * @return Unit 169 | */ 170 | private fun errorOccurred() { 171 | binding.apply { 172 | userDetailContainer.visibility = View.INVISIBLE 173 | tabs.visibility = View.INVISIBLE 174 | viewPager.visibility = View.INVISIBLE 175 | } 176 | } 177 | 178 | /** 179 | * Setting up toolbar 180 | * 181 | * @param title Toolbar title 182 | * @return Unit 183 | */ 184 | private fun setToolbar(title: String) { 185 | setSupportActionBar(binding.toolbarDetail) 186 | binding.collapsingToolbar.isTitleEnabled = false 187 | supportActionBar?.apply { 188 | setDisplayShowHomeEnabled(true) 189 | setDisplayHomeAsUpEnabled(true) 190 | this.title = title 191 | } 192 | } 193 | 194 | /** 195 | * Setting up viewpager 196 | * 197 | * @return Unit 198 | */ 199 | private fun setViewPager() { 200 | val viewPager: ViewPager2 = binding.viewPager 201 | val tabs: TabLayout = binding.tabs 202 | 203 | viewPager.adapter = SectionPagerAdapter(this, username!!) 204 | 205 | TabLayoutMediator(tabs, viewPager) { tab, position -> 206 | tab.text = resources.getString(TAB_TITLES[position]) 207 | }.attach() 208 | } 209 | 210 | /** 211 | * Showing loading indicator 212 | * 213 | * @param isLoading Loading state 214 | * @return Unit 215 | */ 216 | private fun showLoading(isLoading: Boolean) { 217 | if (isLoading) { 218 | binding.apply { 219 | pbLoading.visibility = View.VISIBLE 220 | appBarLayout.visibility = View.INVISIBLE 221 | viewPager.visibility = View.INVISIBLE 222 | fabFavorite.visibility = View.GONE 223 | } 224 | } else { 225 | binding.apply { 226 | pbLoading.visibility = View.GONE 227 | appBarLayout.visibility = View.VISIBLE 228 | viewPager.visibility = View.VISIBLE 229 | fabFavorite.visibility = View.VISIBLE 230 | } 231 | } 232 | } 233 | 234 | /** 235 | * Parsing User data to it's view 236 | * 237 | * @param user User dataclass 238 | * @return Unit 239 | */ 240 | private fun parseUserDetail(user: User) { 241 | binding.apply { 242 | tvUsername.text = user.login 243 | tvRepositories.text = user.publicRepos.toString() 244 | tvFollowers.text = user.followers.toString() 245 | tvFollowing.text = user.following.toString() 246 | 247 | tvName.setAndVisible(user.name) 248 | tvBio.setAndVisible(user.bio) 249 | tvCompany.setAndVisible(user.company) 250 | tvLocation.setAndVisible(user.location) 251 | tvBlog.setAndVisible(user.blog) 252 | 253 | ivAvatar.setImageGlide(this@DetailUserActivity, user.avatarUrl) 254 | } 255 | } 256 | 257 | companion object { 258 | const val EXTRA_DETAIL = "extra_detail" 259 | private val TAB_TITLES = intArrayOf( 260 | R.string.followers, 261 | R.string.following 262 | ) 263 | } 264 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/view/FavoriteActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.View 6 | import androidx.activity.viewModels 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.lifecycle.lifecycleScope 9 | import androidx.recyclerview.widget.LinearLayoutManager 10 | import com.artworkspace.github.R 11 | import com.artworkspace.github.adapter.ListUserAdapter 12 | import com.artworkspace.github.data.local.entity.UserEntity 13 | import com.artworkspace.github.data.remote.response.SimpleUser 14 | import com.artworkspace.github.databinding.ActivityFavoriteBinding 15 | import com.artworkspace.github.ui.viewmodel.FavoriteViewModel 16 | import com.artworkspace.github.utils.EspressoIdlingResource 17 | import dagger.hilt.android.AndroidEntryPoint 18 | import kotlinx.coroutines.flow.collect 19 | import kotlinx.coroutines.launch 20 | 21 | @AndroidEntryPoint 22 | class FavoriteActivity : AppCompatActivity() { 23 | 24 | private lateinit var binding: ActivityFavoriteBinding 25 | private val favoriteViewModel: FavoriteViewModel by viewModels() 26 | 27 | override fun onCreate(savedInstanceState: Bundle?) { 28 | super.onCreate(savedInstanceState) 29 | 30 | binding = ActivityFavoriteBinding.inflate(layoutInflater) 31 | setContentView(binding.root) 32 | setToolbar(getString(R.string.favorite)) 33 | 34 | lifecycleScope.launchWhenStarted { 35 | launch { 36 | favoriteViewModel.favorite.collect { 37 | EspressoIdlingResource.increment() 38 | if (it.isNotEmpty()) showFavoriteUsers(it) 39 | else showMessage() 40 | } 41 | } 42 | } 43 | } 44 | 45 | override fun onSupportNavigateUp(): Boolean { 46 | onBackPressed() 47 | return true 48 | } 49 | 50 | private fun showMessage() { 51 | binding.tvMessage.visibility = View.VISIBLE 52 | binding.rvFavorite.visibility = View.GONE 53 | 54 | EspressoIdlingResource.decrement() 55 | } 56 | 57 | /** 58 | * Convert data type and display favorite users to the recycler view 59 | * 60 | * @param users List of favorite users 61 | * @return Unit 62 | */ 63 | private fun showFavoriteUsers(users: List) { 64 | val listUsers = ArrayList() 65 | 66 | users.forEach { user -> 67 | val data = SimpleUser( 68 | user.avatarUrl, 69 | user.id 70 | ) 71 | 72 | listUsers.add(data) 73 | } 74 | 75 | val listUserAdapter = ListUserAdapter(listUsers) 76 | 77 | binding.rvFavorite.apply { 78 | layoutManager = LinearLayoutManager(this@FavoriteActivity) 79 | adapter = listUserAdapter 80 | visibility = View.VISIBLE 81 | setHasFixedSize(true) 82 | } 83 | 84 | binding.tvMessage.visibility = View.GONE 85 | 86 | listUserAdapter.setOnItemClickCallback(object : 87 | ListUserAdapter.OnItemClickCallback { 88 | override fun onItemClicked(user: SimpleUser) { 89 | goToDetailUser(user) 90 | } 91 | }) 92 | 93 | EspressoIdlingResource.decrement() 94 | } 95 | 96 | /** 97 | * Go to detail page with selected user data 98 | * 99 | * @param user Selected user 100 | * @return Unit 101 | */ 102 | private fun goToDetailUser(user: SimpleUser) { 103 | Intent(this@FavoriteActivity, DetailUserActivity::class.java).apply { 104 | putExtra(DetailUserActivity.EXTRA_DETAIL, user.login) 105 | }.also { 106 | startActivity(it) 107 | } 108 | } 109 | 110 | /** 111 | * Setting up toolbar 112 | * 113 | * @param title Toolbar title 114 | * @return Unit 115 | */ 116 | private fun setToolbar(title: String) { 117 | setSupportActionBar(binding.toolbar) 118 | supportActionBar?.apply { 119 | setDisplayShowHomeEnabled(true) 120 | setDisplayHomeAsUpEnabled(true) 121 | this.title = title 122 | } 123 | } 124 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/view/FollowersFragment.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.viewModels 10 | import androidx.lifecycle.lifecycleScope 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import com.artworkspace.github.adapter.ListUserAdapter 13 | import com.artworkspace.github.adapter.SectionPagerAdapter.Companion.ARGS_USERNAME 14 | import com.artworkspace.github.data.Result 15 | import com.artworkspace.github.data.remote.response.SimpleUser 16 | import com.artworkspace.github.databinding.FragmentFollowersBinding 17 | import com.artworkspace.github.ui.viewmodel.FollowersViewModel 18 | import dagger.hilt.android.AndroidEntryPoint 19 | import kotlinx.coroutines.flow.collect 20 | import kotlinx.coroutines.launch 21 | 22 | @AndroidEntryPoint 23 | class FollowersFragment : Fragment() { 24 | 25 | private var _binding: FragmentFollowersBinding? = null 26 | private val binding get() = _binding!! 27 | 28 | private val followersViewModel: FollowersViewModel by viewModels() 29 | 30 | override fun onCreateView( 31 | inflater: LayoutInflater, container: ViewGroup?, 32 | savedInstanceState: Bundle? 33 | ): View { 34 | _binding = FragmentFollowersBinding.inflate(layoutInflater, container, false) 35 | return binding.root 36 | } 37 | 38 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 39 | super.onViewCreated(view, savedInstanceState) 40 | 41 | val username = arguments?.getString(ARGS_USERNAME) ?: "" 42 | viewLifecycleOwner.lifecycleScope.launchWhenStarted { 43 | launch { 44 | followersViewModel.followers.collect { result -> 45 | onFollowersResultReceived(result) 46 | } 47 | } 48 | launch { 49 | followersViewModel.isLoaded.collect { loaded -> 50 | if (!loaded) followersViewModel.getUserFollowers(username) 51 | } 52 | } 53 | } 54 | } 55 | 56 | override fun onDestroy() { 57 | _binding = null 58 | super.onDestroy() 59 | } 60 | 61 | /** 62 | * Parsing data to UI based on result 63 | * 64 | * @param result Result from API 65 | */ 66 | private fun onFollowersResultReceived(result: Result>) { 67 | when (result) { 68 | is Result.Loading -> showLoading(true) 69 | is Result.Error -> { 70 | showLoading(false) 71 | } 72 | is Result.Success -> { 73 | showFollowers(result.data) 74 | showLoading(false) 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * Showing up result, setup layout manager, adapter, and onClickItemCallback 81 | * 82 | * @param users Followers 83 | * @return Unit 84 | */ 85 | private fun showFollowers(users: ArrayList) { 86 | if (users.size > 0) { 87 | val linearLayoutManager = LinearLayoutManager(activity) 88 | val listAdapter = ListUserAdapter(users) 89 | 90 | binding.rvUsers.apply { 91 | layoutManager = linearLayoutManager 92 | adapter = listAdapter 93 | setHasFixedSize(true) 94 | } 95 | 96 | listAdapter.setOnItemClickCallback(object : 97 | ListUserAdapter.OnItemClickCallback { 98 | override fun onItemClicked(user: SimpleUser) { 99 | goToDetailUser(user) 100 | } 101 | 102 | }) 103 | } else binding.tvStatus.visibility = View.VISIBLE 104 | } 105 | 106 | /** 107 | * Showing loading indicator 108 | * 109 | * @param isLoading Loading state 110 | * @return Unit 111 | */ 112 | private fun showLoading(isLoading: Boolean) { 113 | if (isLoading) binding.pbLoading.visibility = View.VISIBLE 114 | else binding.pbLoading.visibility = View.GONE 115 | } 116 | 117 | /** 118 | * Go to detail page with selected user data 119 | * 120 | * @param user Selected user 121 | * @return Unit 122 | */ 123 | private fun goToDetailUser(user: SimpleUser) { 124 | Intent(activity, DetailUserActivity::class.java).apply { 125 | putExtra(DetailUserActivity.EXTRA_DETAIL, user.login) 126 | }.also { 127 | startActivity(it) 128 | } 129 | } 130 | 131 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/view/FollowingFragment.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.view.LayoutInflater 6 | import android.view.View 7 | import android.view.ViewGroup 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.viewModels 10 | import androidx.lifecycle.lifecycleScope 11 | import androidx.recyclerview.widget.LinearLayoutManager 12 | import com.artworkspace.github.adapter.ListUserAdapter 13 | import com.artworkspace.github.adapter.SectionPagerAdapter.Companion.ARGS_USERNAME 14 | import com.artworkspace.github.data.Result 15 | import com.artworkspace.github.data.remote.response.SimpleUser 16 | import com.artworkspace.github.databinding.FragmentFollowingBinding 17 | import com.artworkspace.github.ui.viewmodel.FollowingViewModel 18 | import dagger.hilt.android.AndroidEntryPoint 19 | import kotlinx.coroutines.flow.collect 20 | import kotlinx.coroutines.launch 21 | 22 | @AndroidEntryPoint 23 | class FollowingFragment : Fragment() { 24 | 25 | private var _binding: FragmentFollowingBinding? = null 26 | private val binding get() = _binding!! 27 | 28 | private val followingViewModel: FollowingViewModel by viewModels() 29 | 30 | override fun onCreateView( 31 | inflater: LayoutInflater, container: ViewGroup?, 32 | savedInstanceState: Bundle? 33 | ): View { 34 | _binding = FragmentFollowingBinding.inflate(layoutInflater, container, false) 35 | return binding.root 36 | } 37 | 38 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 39 | super.onViewCreated(view, savedInstanceState) 40 | 41 | val username = arguments?.getString(ARGS_USERNAME) ?: "" 42 | viewLifecycleOwner.lifecycleScope.launchWhenStarted { 43 | launch { 44 | followingViewModel.following.collect { 45 | onFollowingResultReceived(it) 46 | } 47 | } 48 | launch { 49 | followingViewModel.isLoaded.collect { loaded -> 50 | if (!loaded) followingViewModel.getUserFollowing(username) 51 | } 52 | } 53 | } 54 | } 55 | 56 | override fun onDestroy() { 57 | _binding = null 58 | super.onDestroy() 59 | } 60 | 61 | /** 62 | * Parsing data to UI based on result 63 | * 64 | * @param result Result from API 65 | */ 66 | private fun onFollowingResultReceived(result: Result>) { 67 | when (result) { 68 | is Result.Loading -> showLoading(true) 69 | is Result.Error -> { 70 | showLoading(false) 71 | } 72 | is Result.Success -> { 73 | showFollowing(result.data) 74 | showLoading(false) 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * Showing up result, setup layout manager, adapter, and onClickItemCallback 81 | * 82 | * @param users Following 83 | * @return Unit 84 | */ 85 | private fun showFollowing(users: ArrayList) { 86 | if (users.size > 0) { 87 | val linearLayoutManager = LinearLayoutManager(activity) 88 | val listAdapter = ListUserAdapter(users) 89 | 90 | binding.rvUsers.apply { 91 | layoutManager = linearLayoutManager 92 | adapter = listAdapter 93 | setHasFixedSize(true) 94 | } 95 | 96 | listAdapter.setOnItemClickCallback(object : 97 | ListUserAdapter.OnItemClickCallback { 98 | override fun onItemClicked(user: SimpleUser) { 99 | goToDetailUser(user) 100 | } 101 | 102 | }) 103 | } else binding.tvStatus.visibility = View.VISIBLE 104 | } 105 | 106 | /** 107 | * Showing loading indicator 108 | * 109 | * @param isLoading Loading state 110 | * @return Unit 111 | */ 112 | private fun showLoading(isLoading: Boolean) { 113 | if (isLoading) binding.pbLoading.visibility = View.VISIBLE 114 | else binding.pbLoading.visibility = View.GONE 115 | } 116 | 117 | /** 118 | * Go to detail page with selected user data 119 | * 120 | * @param user Selected user 121 | * @return Unit 122 | */ 123 | private fun goToDetailUser(user: SimpleUser) { 124 | Intent(activity, DetailUserActivity::class.java).apply { 125 | putExtra(DetailUserActivity.EXTRA_DETAIL, user.login) 126 | }.also { 127 | startActivity(it) 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/view/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import android.app.SearchManager 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.os.Bundle 7 | import android.view.Menu 8 | import android.view.MenuItem 9 | import android.view.View 10 | import android.widget.Toast 11 | import androidx.activity.viewModels 12 | import androidx.appcompat.app.AppCompatActivity 13 | import androidx.appcompat.app.AppCompatDelegate 14 | import androidx.appcompat.widget.SearchView 15 | import androidx.lifecycle.Lifecycle 16 | import androidx.lifecycle.lifecycleScope 17 | import androidx.lifecycle.repeatOnLifecycle 18 | import androidx.recyclerview.widget.LinearLayoutManager 19 | import com.artworkspace.github.R 20 | import com.artworkspace.github.adapter.ListUserAdapter 21 | import com.artworkspace.github.data.Result 22 | import com.artworkspace.github.data.remote.response.SimpleUser 23 | import com.artworkspace.github.databinding.ActivityMainBinding 24 | import com.artworkspace.github.ui.view.DetailUserActivity.Companion.EXTRA_DETAIL 25 | import com.artworkspace.github.ui.viewmodel.MainViewModel 26 | import com.artworkspace.github.utils.EspressoIdlingResource 27 | import dagger.hilt.android.AndroidEntryPoint 28 | import kotlinx.coroutines.flow.collect 29 | import kotlinx.coroutines.launch 30 | 31 | @AndroidEntryPoint 32 | class MainActivity : AppCompatActivity() { 33 | 34 | private var _binding: ActivityMainBinding? = null 35 | private val binding get() = _binding!! 36 | 37 | private val mainViewModel: MainViewModel by viewModels() 38 | 39 | override fun onCreate(savedInstanceState: Bundle?) { 40 | super.onCreate(savedInstanceState) 41 | 42 | _binding = ActivityMainBinding.inflate(layoutInflater) 43 | setContentView(binding.root) 44 | 45 | setSupportActionBar(binding.toolbarHome) 46 | supportActionBar?.setDisplayShowTitleEnabled(false) 47 | 48 | lifecycleScope.launch { 49 | repeatOnLifecycle(Lifecycle.State.STARTED) { 50 | launch { 51 | mainViewModel.themeSetting.collect { state -> 52 | if (state) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) 53 | else AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) 54 | } 55 | } 56 | launch { 57 | mainViewModel.users.collect { result -> 58 | showSearchingResult(result) 59 | } 60 | } 61 | } 62 | } 63 | } 64 | 65 | override fun onStart() { 66 | super.onStart() 67 | EspressoIdlingResource.increment() 68 | } 69 | 70 | override fun onCreateOptionsMenu(menu: Menu): Boolean { 71 | val inflater = menuInflater 72 | inflater.inflate(R.menu.home_menu, menu) 73 | 74 | val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager 75 | val searchView = menu.findItem(R.id.search).actionView as SearchView 76 | 77 | searchView.apply { 78 | setSearchableInfo(searchManager.getSearchableInfo(componentName)) 79 | queryHint = getString(R.string.github_username) 80 | setOnQueryTextListener(object : SearchView.OnQueryTextListener { 81 | override fun onQueryTextSubmit(query: String?): Boolean { 82 | EspressoIdlingResource.increment() 83 | mainViewModel.searchUserByUsername(query ?: "") 84 | clearFocus() 85 | return true 86 | } 87 | 88 | override fun onQueryTextChange(newText: String?): Boolean { 89 | return false 90 | } 91 | 92 | }) 93 | } 94 | return true 95 | } 96 | 97 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 98 | when (item.itemId) { 99 | R.id.favorite -> { 100 | Intent(this@MainActivity, FavoriteActivity::class.java).also { 101 | startActivity(it) 102 | } 103 | } 104 | R.id.setting -> { 105 | Intent(this@MainActivity, SettingActivity::class.java).also { 106 | startActivity(it) 107 | } 108 | } 109 | } 110 | return super.onOptionsItemSelected(item) 111 | } 112 | 113 | override fun onDestroy() { 114 | _binding = null 115 | super.onDestroy() 116 | } 117 | 118 | /** 119 | * Setting UI when an error occurred 120 | * 121 | * @return Unit 122 | */ 123 | private fun errorOccurred() { 124 | Toast.makeText(this@MainActivity, "An Error is Occurred", Toast.LENGTH_SHORT).show() 125 | } 126 | 127 | /** 128 | * Determine loading indicator is visible or not 129 | * 130 | * @param isLoading Loading state 131 | * @return Unit 132 | */ 133 | private fun showLoading(isLoading: Boolean) { 134 | if (isLoading) { 135 | binding.pbLoading.visibility = View.VISIBLE 136 | binding.rvUsers.visibility = View.GONE 137 | } else { 138 | binding.pbLoading.visibility = View.GONE 139 | binding.rvUsers.visibility = View.VISIBLE 140 | } 141 | } 142 | 143 | /** 144 | * Showing up result, setup layout manager, adapter, and onClickItemCallback 145 | * 146 | * @param result Result from viewmodel 147 | * @return Unit 148 | */ 149 | private fun showSearchingResult(result: Result>) { 150 | when (result) { 151 | is Result.Loading -> showLoading(true) 152 | is Result.Error -> { 153 | errorOccurred() 154 | showLoading(false) 155 | } 156 | is Result.Success -> { 157 | binding.tvResultCount.text = getString(R.string.showing_results, result.data.size) 158 | val listUserAdapter = ListUserAdapter(result.data) 159 | 160 | binding.rvUsers.apply { 161 | layoutManager = LinearLayoutManager(this@MainActivity) 162 | adapter = listUserAdapter 163 | setHasFixedSize(true) 164 | } 165 | 166 | listUserAdapter.setOnItemClickCallback(object : 167 | ListUserAdapter.OnItemClickCallback { 168 | override fun onItemClicked(user: SimpleUser) { 169 | goToDetailUser(user) 170 | } 171 | 172 | }) 173 | showLoading(false) 174 | EspressoIdlingResource.decrement() 175 | } 176 | } 177 | } 178 | 179 | /** 180 | * Go to detail page with selected user data 181 | * 182 | * @param user Selected user 183 | * @return Unit 184 | */ 185 | private fun goToDetailUser(user: SimpleUser) { 186 | Intent(this@MainActivity, DetailUserActivity::class.java).apply { 187 | putExtra(EXTRA_DETAIL, user.login) 188 | }.also { 189 | startActivity(it) 190 | } 191 | } 192 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/view/SettingActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import android.os.Bundle 4 | import android.widget.CompoundButton 5 | import androidx.activity.viewModels 6 | import androidx.appcompat.app.AppCompatActivity 7 | import androidx.appcompat.app.AppCompatDelegate 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.lifecycleScope 10 | import androidx.lifecycle.repeatOnLifecycle 11 | import com.artworkspace.github.R 12 | import com.artworkspace.github.databinding.ActivitySettingBinding 13 | import com.artworkspace.github.ui.viewmodel.SettingViewModel 14 | import dagger.hilt.android.AndroidEntryPoint 15 | import kotlinx.coroutines.flow.collect 16 | import kotlinx.coroutines.launch 17 | 18 | @AndroidEntryPoint 19 | class SettingActivity : AppCompatActivity(), CompoundButton.OnCheckedChangeListener { 20 | 21 | private var _binding: ActivitySettingBinding? = null 22 | private val binding get() = _binding!! 23 | 24 | private val settingViewModel: SettingViewModel by viewModels () 25 | 26 | override fun onCreate(savedInstanceState: Bundle?) { 27 | super.onCreate(savedInstanceState) 28 | 29 | _binding = ActivitySettingBinding.inflate(layoutInflater) 30 | setContentView(binding.root) 31 | setToolbar(getString(R.string.setting)) 32 | 33 | lifecycleScope.launch { 34 | repeatOnLifecycle(Lifecycle.State.STARTED) { 35 | launch { 36 | settingViewModel.getThemeSetting.collect { state -> 37 | if (state) AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) 38 | else AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) 39 | 40 | binding.switchDarkMode.isChecked = state 41 | } 42 | } 43 | } 44 | } 45 | 46 | binding.switchDarkMode.setOnCheckedChangeListener(this) 47 | } 48 | 49 | override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { 50 | when (buttonView?.id) { 51 | R.id.switch_dark_mode -> settingViewModel.saveThemeSetting(isChecked) 52 | } 53 | } 54 | 55 | override fun onSupportNavigateUp(): Boolean { 56 | onBackPressed() 57 | return true 58 | } 59 | 60 | override fun onDestroy() { 61 | _binding = null 62 | super.onDestroy() 63 | } 64 | 65 | /** 66 | * Setting up toolbar 67 | * 68 | * @param title Toolbar title 69 | * @return Unit 70 | */ 71 | private fun setToolbar(title: String) { 72 | setSupportActionBar(binding.toolbarSetting) 73 | supportActionBar?.apply { 74 | setDisplayShowHomeEnabled(true) 75 | setDisplayHomeAsUpEnabled(true) 76 | this.title = title 77 | } 78 | } 79 | 80 | 81 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/view/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.view 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.appcompat.app.AppCompatActivity 7 | import kotlinx.coroutines.delay 8 | import kotlinx.coroutines.runBlocking 9 | 10 | @SuppressLint("CustomSplashScreen") 11 | class SplashActivity : AppCompatActivity() { 12 | override fun onCreate(savedInstanceState: Bundle?) { 13 | super.onCreate(savedInstanceState) 14 | 15 | Intent(this, MainActivity::class.java).also { 16 | runBlocking { 17 | delay(500) 18 | startActivity(it) 19 | finish() 20 | } 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/viewmodel/DetailViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.artworkspace.github.data.Result 6 | import com.artworkspace.github.data.UserRepository 7 | import com.artworkspace.github.data.local.entity.UserEntity 8 | import com.artworkspace.github.data.remote.response.User 9 | import dagger.hilt.android.lifecycle.HiltViewModel 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.asStateFlow 13 | import kotlinx.coroutines.flow.collect 14 | import kotlinx.coroutines.launch 15 | import javax.inject.Inject 16 | 17 | @HiltViewModel 18 | class DetailViewModel @Inject constructor(private val repository: UserRepository) : ViewModel() { 19 | 20 | private val _userDetail = MutableStateFlow>(Result.Loading) 21 | val userDetail = _userDetail.asStateFlow() 22 | 23 | private val _isLoaded = MutableStateFlow(false) 24 | val isLoaded = _isLoaded.asStateFlow() 25 | 26 | /** 27 | * Get user detail information 28 | * 29 | * @param username GitHub username 30 | * @return Unit 31 | */ 32 | fun getDetailUser(username: String) { 33 | _userDetail.value = Result.Loading 34 | viewModelScope.launch { 35 | repository.getUserDetail(username).collect { 36 | _userDetail.value = it 37 | } 38 | } 39 | 40 | _isLoaded.value = true 41 | } 42 | 43 | /** 44 | * Save user to database as favorite user 45 | * 46 | * @param user New favorite user 47 | */ 48 | fun saveAsFavorite(user: UserEntity) { 49 | viewModelScope.launch { 50 | repository.saveUserAsFavorite(user) 51 | } 52 | } 53 | 54 | /** 55 | * Delete favorite user from database 56 | * 57 | * @param user User to delete 58 | */ 59 | fun deleteFromFavorite(user: UserEntity) { 60 | viewModelScope.launch { 61 | repository.deleteFromFavorite(user) 62 | } 63 | } 64 | 65 | /** 66 | * Determine this is favorite user or not 67 | * 68 | * @param id User id 69 | * @return LiveData 70 | */ 71 | fun isFavoriteUser(id: String): Flow = repository.isFavoriteUser(id) 72 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/viewmodel/FavoriteViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.artworkspace.github.data.UserRepository 6 | import com.artworkspace.github.data.local.entity.UserEntity 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.flow.MutableStateFlow 9 | import kotlinx.coroutines.flow.asStateFlow 10 | import kotlinx.coroutines.flow.collect 11 | import kotlinx.coroutines.launch 12 | import javax.inject.Inject 13 | 14 | @HiltViewModel 15 | class FavoriteViewModel @Inject constructor(private val repository: UserRepository) : ViewModel() { 16 | 17 | private val _favorites = MutableStateFlow(listOf()) 18 | val favorite = _favorites.asStateFlow() 19 | 20 | init { 21 | getFavoriteUsers() 22 | } 23 | 24 | /** 25 | * Get all favorite users from database 26 | * 27 | * @return LiveData> 28 | */ 29 | private fun getFavoriteUsers() { 30 | viewModelScope.launch { 31 | repository.getAllFavoriteUsers().collect { 32 | _favorites.value = it 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/viewmodel/FollowersViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.artworkspace.github.data.Result 6 | import com.artworkspace.github.data.UserRepository 7 | import com.artworkspace.github.data.remote.response.SimpleUser 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import kotlinx.coroutines.flow.collect 12 | import kotlinx.coroutines.launch 13 | import javax.inject.Inject 14 | 15 | @HiltViewModel 16 | class FollowersViewModel @Inject constructor(private val repository: UserRepository) : ViewModel() { 17 | 18 | private val _isLoaded = MutableStateFlow(false) 19 | val isLoaded = _isLoaded.asStateFlow() 20 | 21 | private val _followers = MutableStateFlow>>(Result.Loading) 22 | val followers = _followers.asStateFlow() 23 | 24 | /** 25 | * Get followers information of an user 26 | * 27 | * @param username GitHub username 28 | * @return Unit 29 | */ 30 | fun getUserFollowers(username: String) { 31 | _followers.value = Result.Loading 32 | viewModelScope.launch { 33 | repository.getUserFollowers(username).collect { 34 | _followers.value = it 35 | } 36 | } 37 | 38 | _isLoaded.value = true 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/viewmodel/FollowingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.artworkspace.github.data.Result 6 | import com.artworkspace.github.data.UserRepository 7 | import com.artworkspace.github.data.remote.response.SimpleUser 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import kotlinx.coroutines.flow.catch 12 | import kotlinx.coroutines.flow.collect 13 | import kotlinx.coroutines.launch 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class FollowingViewModel @Inject constructor(private val repository: UserRepository) : ViewModel() { 18 | 19 | private val _isLoaded = MutableStateFlow(false) 20 | val isLoaded = _isLoaded.asStateFlow() 21 | 22 | private val _following = MutableStateFlow>>(Result.Loading) 23 | val following = _following.asStateFlow() 24 | 25 | /** 26 | * Get following information of an user 27 | * 28 | * @param username GitHub username 29 | * @return Unit 30 | */ 31 | fun getUserFollowing(username: String) { 32 | _following.value = Result.Loading 33 | viewModelScope.launch { 34 | repository.getUserFollowing(username).catch { e -> 35 | _following.value = Result.Error(e.message.toString()) 36 | }.collect { 37 | _following.value = it 38 | } 39 | } 40 | 41 | _isLoaded.value = true 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/viewmodel/MainViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.artworkspace.github.data.Result 6 | import com.artworkspace.github.data.UserRepository 7 | import com.artworkspace.github.data.remote.response.SimpleUser 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.asStateFlow 12 | import kotlinx.coroutines.flow.collect 13 | import kotlinx.coroutines.launch 14 | import javax.inject.Inject 15 | 16 | @HiltViewModel 17 | class MainViewModel @Inject constructor(private val repository: UserRepository) : ViewModel() { 18 | 19 | val themeSetting: Flow = repository.getThemeSetting() 20 | 21 | private val _users = MutableStateFlow>>(Result.Loading) 22 | val users = _users.asStateFlow() 23 | 24 | init { 25 | searchUserByUsername("\"\"") 26 | } 27 | 28 | /** 29 | * Search GitHub user 30 | * 31 | * @param query GitHub username 32 | * @return LiveData 33 | */ 34 | fun searchUserByUsername(query: String) { 35 | _users.value = Result.Loading 36 | viewModelScope.launch { 37 | repository.searchUserByUsername(query).collect { 38 | _users.value = it 39 | } 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/ui/viewmodel/SettingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.ui.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.artworkspace.github.data.UserRepository 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.launch 9 | import javax.inject.Inject 10 | 11 | @HiltViewModel 12 | class SettingViewModel @Inject constructor(private val userRepository: UserRepository) : 13 | ViewModel() { 14 | 15 | /** 16 | * Get theme setting from DataStore 17 | * 18 | * @return LiveData 19 | */ 20 | val getThemeSetting: Flow = userRepository.getThemeSetting() 21 | 22 | /** 23 | * Saving dark mode state to DataStore 24 | * 25 | * @param darkModeState Dark mode state 26 | */ 27 | fun saveThemeSetting(darkModeState: Boolean) { 28 | viewModelScope.launch { 29 | userRepository.saveThemeSetting(darkModeState) 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/utils/EspressoIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.utils 2 | 3 | import androidx.test.espresso.IdlingResource 4 | import androidx.test.espresso.idling.CountingIdlingResource 5 | 6 | 7 | object EspressoIdlingResource { 8 | private val countingIdlingResource = CountingIdlingResource("GLOBAL") 9 | 10 | val idlingResource: IdlingResource 11 | get() = countingIdlingResource 12 | 13 | fun increment() = countingIdlingResource.increment() 14 | 15 | fun decrement() = countingIdlingResource.decrement() 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/github/utils/UIHelper.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.github.utils 2 | 3 | import android.content.Context 4 | import android.view.View 5 | import android.widget.TextView 6 | import com.artworkspace.github.R 7 | import com.bumptech.glide.Glide 8 | import de.hdodenhof.circleimageview.CircleImageView 9 | 10 | class UIHelper { 11 | companion object { 12 | /** 13 | * Extension function to set CircleImageView using Glide 14 | * 15 | * @param context Context 16 | * @param url Image URL 17 | * @return Unit 18 | */ 19 | fun CircleImageView.setImageGlide(context: Context, url: String) { 20 | Glide 21 | .with(context) 22 | .load(url) 23 | .placeholder(R.drawable.profile_placeholder) 24 | .into(this) 25 | } 26 | 27 | /** 28 | * Extension function to set and show hidden TextView if the text has value 29 | * 30 | * @param text Text to set 31 | * @return Unit 32 | */ 33 | fun TextView.setAndVisible(text: String?) { 34 | if (!text.isNullOrBlank()) { 35 | this.text = text 36 | this.visibility = View.VISIBLE 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/profile_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-fundamental-submission/3548de13ebdac8671b1cd96af3328f33d2aa2a28/app/src/main/res/drawable-v24/profile_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/dummy_profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-fundamental-submission/3548de13ebdac8671b1cd96af3328f33d2aa2a28/app/src/main/res/drawable/dummy_profile.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/github_logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-fundamental-submission/3548de13ebdac8671b1cd96af3328f33d2aa2a28/app/src/main/res/drawable/github_logo_black.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/github_logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-fundamental-submission/3548de13ebdac8671b1cd96af3328f33d2aa2a28/app/src/main/res/drawable/github_logo_white.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_apartment_16.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_blog_16.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_favorite_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_favorite_border_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_location_on_16.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_person_16.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_search_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_settings_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_github_mark.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_github_mark_white.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash_bg_dark.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash_bg_light.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_detail_user.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 22 | 23 | 29 | 30 | 36 | 37 | 42 | 43 | 49 | 50 | 57 | 58 | 64 | 65 | 66 | 67 | 68 | 69 | 78 | 79 | 91 | 92 | 97 | 98 | 103 | 104 | 109 | 110 | 111 | 112 | 117 | 118 | 123 | 124 | 129 | 130 | 131 | 132 | 137 | 138 | 143 | 144 | 149 | 150 | 151 | 152 | 153 | 154 | 162 | 163 | 173 | 174 | 185 | 186 | 198 | 199 | 211 | 212 | 224 | 225 | 226 | 227 |