├── .gitignore ├── .idea ├── .gitignore ├── .name ├── compiler.xml ├── discord.xml ├── gradle.xml ├── misc.xml └── vcs.xml ├── README.md ├── Skenario Pengujian.pdf ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── artworkspace │ │ └── storyapp │ │ ├── CustomTestRunner.kt │ │ ├── ui │ │ ├── home │ │ │ └── HomeFragmentTest.kt │ │ ├── location │ │ │ └── LocationFragmentTest.kt │ │ └── main │ │ │ └── MainActivityTest.kt │ │ └── utils │ │ ├── HiltExtension.kt │ │ └── JsonConverter.kt │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ ├── success_response.json │ │ └── success_response_empty.json │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── artworkspace │ │ │ └── storyapp │ │ │ ├── BaseApplication.kt │ │ │ ├── adapter │ │ │ ├── LoadingStateAdapter.kt │ │ │ └── StoryListAdapter.kt │ │ │ ├── data │ │ │ ├── AuthRepository.kt │ │ │ ├── StoryRepository.kt │ │ │ ├── local │ │ │ │ ├── AuthPreferencesDataSource.kt │ │ │ │ ├── entity │ │ │ │ │ ├── RemoteKeys.kt │ │ │ │ │ └── Story.kt │ │ │ │ └── room │ │ │ │ │ ├── RemoteKeysDao.kt │ │ │ │ │ ├── StoryDao.kt │ │ │ │ │ └── StoryDatabase.kt │ │ │ └── remote │ │ │ │ ├── StoryRemoteMediator.kt │ │ │ │ ├── response │ │ │ │ ├── FileUploadResponse.kt │ │ │ │ ├── LoginResponse.kt │ │ │ │ ├── RegisterResponse.kt │ │ │ │ └── StoriesResponse.kt │ │ │ │ └── retrofit │ │ │ │ ├── ApiConfig.kt │ │ │ │ └── ApiService.kt │ │ │ ├── di │ │ │ ├── ApiModule.kt │ │ │ ├── DataStoreModule.kt │ │ │ └── DatabaseModule.kt │ │ │ ├── ui │ │ │ ├── auth │ │ │ │ └── AuthActivity.kt │ │ │ ├── create │ │ │ │ ├── CreateStoryActivity.kt │ │ │ │ └── CreateViewModel.kt │ │ │ ├── detail │ │ │ │ └── DetailStoryActivity.kt │ │ │ ├── home │ │ │ │ ├── HomeFragment.kt │ │ │ │ └── HomeViewModel.kt │ │ │ ├── location │ │ │ │ ├── LocationFragment.kt │ │ │ │ └── LocationViewModel.kt │ │ │ ├── login │ │ │ │ ├── LoginFragment.kt │ │ │ │ └── LoginViewModel.kt │ │ │ ├── main │ │ │ │ └── MainActivity.kt │ │ │ ├── register │ │ │ │ ├── RegisterFragment.kt │ │ │ │ └── RegisterViewModel.kt │ │ │ ├── setting │ │ │ │ ├── SettingFragment.kt │ │ │ │ └── SettingViewModel.kt │ │ │ └── splash │ │ │ │ ├── SplashActivity.kt │ │ │ │ └── SplashViewModel.kt │ │ │ ├── utils │ │ │ ├── DataDummy.kt │ │ │ ├── EspressoIdlingResource.kt │ │ │ ├── Extensions.kt │ │ │ ├── HiltTestActivity.kt │ │ │ └── MediaUtility.kt │ │ │ └── views │ │ │ ├── EmailEditText.kt │ │ │ └── PasswordEditText.kt │ └── res │ │ ├── drawable-v24 │ │ ├── ic_launcher_foreground.xml │ │ ├── image_dicoding.webp │ │ ├── image_learning.png │ │ └── image_loading_placeholder.png │ │ ├── drawable │ │ ├── ic_baseline_add_24.xml │ │ ├── ic_baseline_check_24.xml │ │ ├── ic_baseline_email_24.xml │ │ ├── ic_baseline_lock_24.xml │ │ ├── ic_baseline_logout_24.xml │ │ ├── ic_baseline_my_location_24.xml │ │ ├── ic_baseline_person_24.xml │ │ ├── ic_baseline_settings_24.xml │ │ ├── ic_dashboard_black_24dp.xml │ │ ├── ic_home_black_24dp.xml │ │ ├── ic_notifications_black_24dp.xml │ │ ├── illustration_login.xml │ │ ├── illustration_no_data.xml │ │ ├── illustration_register.xml │ │ ├── illustration_upload_image.xml │ │ ├── image_load_error.png │ │ └── splash_bg.xml │ │ ├── font │ │ ├── product_sans_bold.ttf │ │ └── product_sans_regular.ttf │ │ ├── layout-land │ │ ├── fragment_login.xml │ │ └── fragment_register.xml │ │ ├── layout │ │ ├── activity_auth.xml │ │ ├── activity_create_story.xml │ │ ├── activity_detail_story.xml │ │ ├── activity_main.xml │ │ ├── fragment_home.xml │ │ ├── fragment_locations.xml │ │ ├── fragment_login.xml │ │ ├── fragment_register.xml │ │ ├── fragment_settings.xml │ │ ├── layout_story_item.xml │ │ └── layout_story_loading.xml │ │ ├── menu │ │ ├── bottom_nav_menu.xml │ │ ├── create_menu.xml │ │ └── main_menu.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ ├── ic_launcher_foreground.png │ │ └── ic_launcher_round.png │ │ ├── navigation │ │ ├── auth_nav_graph.xml │ │ └── mobile_navigation.xml │ │ ├── raw │ │ └── map_style.json │ │ ├── values-in-rID │ │ └── strings.xml │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ └── file_paths.xml │ └── test │ └── java │ └── com │ └── artworkspace │ └── storyapp │ ├── data │ ├── AuthRepositoryTest.kt │ └── StoryRepositoryTest.kt │ ├── ui │ ├── create │ │ └── CreateViewModelTest.kt │ ├── home │ │ └── HomeViewModelTest.kt │ ├── location │ │ └── LocationViewModelTest.kt │ ├── login │ │ └── LoginViewModelTest.kt │ ├── register │ │ └── RegisterViewModelTest.kt │ ├── setting │ │ └── SettingViewModelTest.kt │ └── splash │ │ └── SplashViewModelTest.kt │ └── utils │ ├── CoroutineTestRule.kt │ ├── LiveDataTestUtil.kt │ └── PagedTestDataSource.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── skenatio-pengujian.txt /.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 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | Story App -------------------------------------------------------------------------------- /.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 | 21 | 22 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 33 | 34 | 35 | 36 | 37 | 38 | 40 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dicoding: Android Intermediate Submission 📱 2 |

This is a repository that contains the source code of my submissions project at Dicoding "Belajar Pengembangan Aplikasi Android Intermediate" 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 | -------------------------------------------------------------------------------- /Skenario Pengujian.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/Skenario Pengujian.pdf -------------------------------------------------------------------------------- /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 'dagger.hilt.android.plugin' 6 | id 'kotlin-parcelize' 7 | id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' 8 | } 9 | 10 | android { 11 | compileSdk 32 12 | 13 | defaultConfig { 14 | applicationId "com.artworkspace.storyapp" 15 | minSdk 27 16 | targetSdk 32 17 | versionCode 1 18 | versionName "1.0" 19 | 20 | testInstrumentationRunner "com.artworkspace.storyapp.CustomTestRunner" 21 | 22 | buildConfigField 'String', 'API_BASE_URL', '"https://story-api.dicoding.dev/v1/"' 23 | } 24 | buildTypes { 25 | release { 26 | minifyEnabled false 27 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 28 | } 29 | } 30 | viewBinding { 31 | enabled true 32 | } 33 | compileOptions { 34 | sourceCompatibility JavaVersion.VERSION_1_8 35 | targetCompatibility JavaVersion.VERSION_1_8 36 | } 37 | kotlinOptions { 38 | jvmTarget = '1.8' 39 | } 40 | testOptions { 41 | animationsDisabled = true 42 | } 43 | buildFeatures { 44 | viewBinding true 45 | } 46 | } 47 | 48 | dependencies { 49 | 50 | implementation 'androidx.core:core-ktx:1.7.0' 51 | implementation 'androidx.appcompat:appcompat:1.4.1' 52 | implementation 'com.google.android.material:material:1.5.0' 53 | implementation 'androidx.constraintlayout:constraintlayout:2.1.3' 54 | implementation 'androidx.legacy:legacy-support-v4:1.0.0' 55 | implementation 'com.google.android.gms:play-services-maps:18.0.2' 56 | 57 | // KTX 58 | implementation 'androidx.activity:activity-ktx:1.4.0' 59 | implementation 'androidx.fragment:fragment-ktx:1.4.1' 60 | implementation "androidx.lifecycle:lifecycle-livedata-core-ktx:2.5.0-alpha06" 61 | implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.5.0-alpha06" 62 | implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.5.0-alpha06" 63 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.0-alpha06" 64 | 65 | // Room 66 | implementation "androidx.room:room-runtime:2.4.2" 67 | implementation "androidx.room:room-ktx:2.4.2" 68 | implementation 'androidx.room:room-paging:2.5.0-alpha01' 69 | kapt "androidx.room:room-compiler:2.4.2" 70 | 71 | // Navigation 72 | implementation "androidx.navigation:navigation-runtime-ktx:2.4.2" 73 | implementation "androidx.navigation:navigation-fragment-ktx:2.4.2" 74 | implementation "androidx.navigation:navigation-ui-ktx:2.4.2" 75 | 76 | // Retrofit 77 | implementation 'com.squareup.retrofit2:retrofit:2.9.0' 78 | implementation "com.squareup.retrofit2:converter-gson:2.9.0" 79 | implementation "com.squareup.okhttp3:logging-interceptor:4.9.0" 80 | 81 | // Hilt 82 | implementation "com.google.dagger:hilt-android:2.41" 83 | kapt "com.google.dagger:hilt-android-compiler:2.41" 84 | 85 | // DataStore 86 | implementation "androidx.datastore:datastore-preferences:1.0.0" 87 | implementation "androidx.datastore:datastore-preferences-core:1.0.0" 88 | 89 | // Glide 90 | implementation 'com.github.bumptech.glide:glide:4.13.1' 91 | annotationProcessor 'com.github.bumptech.glide:compiler:4.13.0' 92 | 93 | // Location 94 | implementation 'com.google.android.gms:play-services-location:19.0.1' 95 | 96 | // Paging3 97 | implementation "androidx.paging:paging-runtime-ktx:3.1.1" 98 | 99 | // IdlingResource 100 | implementation 'androidx.test.espresso:espresso-idling-resource:3.4.0' 101 | androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' 102 | 103 | // Mockito 104 | testImplementation 'org.mockito:mockito-core:3.12.4' 105 | testImplementation 'org.mockito:mockito-inline:3.12.4' 106 | 107 | testImplementation 'junit:junit:4.13.2' 108 | testImplementation "androidx.arch.core:core-testing:2.1.0" 109 | testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2" 110 | 111 | // MockWebServer 112 | androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.9.3" 113 | androidTestImplementation "com.squareup.okhttp3:okhttp-tls:4.9.3" 114 | 115 | // Hilt Testing 116 | kaptTest 'com.google.dagger:hilt-android-compiler:2.41' 117 | testImplementation 'com.google.dagger:hilt-android-testing:2.41' 118 | 119 | kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.41' 120 | androidTestImplementation 'com.google.dagger:hilt-android-testing:2.41' 121 | 122 | androidTestImplementation 'androidx.test.ext:junit:1.1.3' 123 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' 124 | androidTestImplementation 'androidx.test.espresso:espresso-intents:3.4.0' 125 | androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.4.0' 126 | 127 | debugImplementation "androidx.fragment:fragment-testing:1.4.1" 128 | } -------------------------------------------------------------------------------- /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/storyapp/CustomTestRunner.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp 2 | 3 | import android.app.Application 4 | import android.content.Context 5 | import androidx.test.runner.AndroidJUnitRunner 6 | import dagger.hilt.android.testing.HiltTestApplication 7 | 8 | class CustomTestRunner : AndroidJUnitRunner() { 9 | override fun newApplication( 10 | cl: ClassLoader?, 11 | className: String?, 12 | context: Context? 13 | ): Application { 14 | return super.newApplication(cl, HiltTestApplication::class.java.name, context) 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/artworkspace/storyapp/ui/home/HomeFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.home 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.test.espresso.Espresso.onView 5 | import androidx.test.espresso.IdlingRegistry 6 | import androidx.test.espresso.assertion.ViewAssertions.matches 7 | import androidx.test.espresso.matcher.ViewMatchers.* 8 | import androidx.test.filters.MediumTest 9 | import com.artworkspace.storyapp.R 10 | import com.artworkspace.storyapp.data.remote.retrofit.ApiConfig.Companion.API_BASE_URL_MOCK 11 | import com.artworkspace.storyapp.utils.EspressoIdlingResource 12 | import com.artworkspace.storyapp.utils.JsonConverter 13 | import com.artworkspace.storyapp.utils.launchFragmentInHiltContainer 14 | import dagger.hilt.android.testing.HiltAndroidRule 15 | import dagger.hilt.android.testing.HiltAndroidTest 16 | import okhttp3.mockwebserver.MockResponse 17 | import okhttp3.mockwebserver.MockWebServer 18 | import org.junit.After 19 | import org.junit.Before 20 | import org.junit.Rule 21 | import org.junit.Test 22 | 23 | @MediumTest 24 | @ExperimentalPagingApi 25 | @HiltAndroidTest 26 | class HomeFragmentTest { 27 | 28 | @get:Rule 29 | var hiltRule = HiltAndroidRule(this) 30 | 31 | private val mockWebServer = MockWebServer() 32 | 33 | @Before 34 | fun setup() { 35 | mockWebServer.start(8080) 36 | API_BASE_URL_MOCK = "http://127.0.0.1:8080/" 37 | IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource) 38 | } 39 | 40 | @After 41 | fun teardown() { 42 | mockWebServer.shutdown() 43 | IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource) 44 | } 45 | 46 | @Test 47 | fun launchHomeFragment_Success() { 48 | launchFragmentInHiltContainer() 49 | 50 | val mockResponse = MockResponse() 51 | .setResponseCode(200) 52 | .setBody(JsonConverter.readStringFromFile("success_response.json")) 53 | mockWebServer.enqueue(mockResponse) 54 | 55 | onView(withId(R.id.toolbar)).check(matches(isDisplayed())) 56 | onView(withId(R.id.rv_stories)).check(matches(isDisplayed())) 57 | 58 | onView(withText("Dimas")).check(matches(isDisplayed())) 59 | } 60 | 61 | @Test 62 | fun launchHomeFragment_Empty() { 63 | launchFragmentInHiltContainer() 64 | 65 | val mockResponse = MockResponse() 66 | .setResponseCode(200) 67 | .setBody(JsonConverter.readStringFromFile("success_response_empty.json")) 68 | mockWebServer.enqueue(mockResponse) 69 | 70 | onView(withId(R.id.iv_not_found_error)).check(matches(isDisplayed())) 71 | onView(withId(R.id.tv_not_found_error)).check(matches(isDisplayed())) 72 | } 73 | 74 | @Test 75 | fun launchHomeFragment_Failed() { 76 | launchFragmentInHiltContainer() 77 | 78 | val mockResponse = MockResponse() 79 | .setResponseCode(500) 80 | mockWebServer.enqueue(mockResponse) 81 | 82 | onView(withId(R.id.iv_not_found_error)).check(matches(isDisplayed())) 83 | onView(withId(R.id.tv_not_found_error)).check(matches(isDisplayed())) 84 | } 85 | 86 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/artworkspace/storyapp/ui/location/LocationFragmentTest.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.location 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.test.espresso.Espresso 5 | import androidx.test.espresso.assertion.ViewAssertions 6 | import androidx.test.espresso.matcher.ViewMatchers 7 | import androidx.test.filters.MediumTest 8 | import com.artworkspace.storyapp.R 9 | import com.artworkspace.storyapp.data.remote.retrofit.ApiConfig 10 | import com.artworkspace.storyapp.utils.JsonConverter 11 | import com.artworkspace.storyapp.utils.launchFragmentInHiltContainer 12 | import dagger.hilt.android.testing.HiltAndroidRule 13 | import dagger.hilt.android.testing.HiltAndroidTest 14 | import okhttp3.mockwebserver.MockResponse 15 | import okhttp3.mockwebserver.MockWebServer 16 | import org.junit.After 17 | import org.junit.Before 18 | import org.junit.Rule 19 | import org.junit.Test 20 | 21 | @ExperimentalPagingApi 22 | @MediumTest 23 | @HiltAndroidTest 24 | class LocationFragmentTest { 25 | @get:Rule 26 | var hiltRule = HiltAndroidRule(this) 27 | 28 | private val mockWebServer = MockWebServer() 29 | 30 | @Before 31 | fun setup() { 32 | mockWebServer.start(8080) 33 | ApiConfig.API_BASE_URL_MOCK = "http://127.0.0.1:8080/" 34 | } 35 | 36 | @After 37 | fun teardown() { 38 | mockWebServer.shutdown() 39 | } 40 | 41 | @Test 42 | fun launchLocationFragment_Success() { 43 | launchFragmentInHiltContainer() 44 | 45 | val mockResponse = MockResponse() 46 | .setResponseCode(200) 47 | .setBody(JsonConverter.readStringFromFile("success_response.json")) 48 | mockWebServer.enqueue(mockResponse) 49 | 50 | Espresso.onView(ViewMatchers.withId(R.id.toolbar)) 51 | .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 52 | Espresso.onView(ViewMatchers.withId(R.id.map)) 53 | .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/artworkspace/storyapp/ui/main/MainActivityTest.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.main 2 | 3 | import androidx.recyclerview.widget.RecyclerView 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.action.ViewActions.swipeUp 9 | import androidx.test.espresso.assertion.ViewAssertions.matches 10 | import androidx.test.espresso.contrib.RecyclerViewActions 11 | import androidx.test.espresso.intent.Intents 12 | import androidx.test.espresso.intent.Intents.intended 13 | import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent 14 | import androidx.test.espresso.intent.matcher.IntentMatchers.hasExtraWithKey 15 | import androidx.test.espresso.matcher.ViewMatchers.isDisplayed 16 | import androidx.test.espresso.matcher.ViewMatchers.withId 17 | import androidx.test.ext.junit.rules.ActivityScenarioRule 18 | import androidx.test.ext.junit.runners.AndroidJUnit4 19 | import androidx.test.filters.LargeTest 20 | import com.artworkspace.storyapp.R 21 | import com.artworkspace.storyapp.ui.detail.DetailStoryActivity 22 | import com.artworkspace.storyapp.ui.detail.DetailStoryActivity.Companion.EXTRA_DETAIL 23 | import com.artworkspace.storyapp.ui.splash.SplashActivity 24 | import com.artworkspace.storyapp.utils.EspressoIdlingResource 25 | import dagger.hilt.android.testing.HiltAndroidRule 26 | import dagger.hilt.android.testing.HiltAndroidTest 27 | import org.junit.After 28 | import org.junit.Before 29 | import org.junit.Rule 30 | import org.junit.Test 31 | import org.junit.runner.RunWith 32 | 33 | /** 34 | * UI Testing in MainActivityTest 35 | * Initial condition: Must already logged in 36 | */ 37 | @RunWith(AndroidJUnit4::class) 38 | @LargeTest 39 | @HiltAndroidTest 40 | class MainActivityTest { 41 | @get:Rule 42 | var hiltRule = HiltAndroidRule(this) 43 | 44 | @get:Rule 45 | val activity = ActivityScenarioRule(SplashActivity::class.java) 46 | 47 | @Before 48 | fun setup() { 49 | IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource) 50 | Intents.init() 51 | } 52 | 53 | @After 54 | fun teardown() { 55 | IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource) 56 | Intents.release() 57 | } 58 | 59 | 60 | @Test 61 | fun loadStoryDetailInformation() { 62 | intended(hasComponent(MainActivity::class.java.name)) 63 | onView(withId(R.id.rv_stories)).check(matches(isDisplayed())) 64 | onView(withId(R.id.rv_stories)).perform( 65 | RecyclerViewActions.actionOnItemAtPosition( 66 | 0, 67 | click() 68 | ) 69 | ) 70 | 71 | intended(hasComponent(DetailStoryActivity::class.java.name)) 72 | intended(hasExtraWithKey(EXTRA_DETAIL)) 73 | onView(withId(R.id.toolbar)).check(matches(isDisplayed())) 74 | onView(withId(R.id.tv_story_username)).check(matches(isDisplayed())) 75 | onView(withId(R.id.tv_story_date)).check(matches(isDisplayed())) 76 | onView(withId(R.id.iv_story_image)).check(matches(isDisplayed())) 77 | onView(withId(R.id.scroll_view)).perform(swipeUp()) 78 | onView(withId(R.id.tv_story_description)).check(matches(isDisplayed())) 79 | 80 | pressBack() 81 | 82 | } 83 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/artworkspace/storyapp/utils/HiltExtension.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.utils 2 | 3 | import android.content.ComponentName 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.annotation.StyleRes 7 | import androidx.core.util.Preconditions 8 | import androidx.fragment.app.Fragment 9 | import androidx.test.core.app.ActivityScenario 10 | import androidx.test.core.app.ApplicationProvider 11 | import com.artworkspace.storyapp.R 12 | 13 | /** 14 | * launchFragmentInContainer from the androidx.fragment:fragment-testing library 15 | * is NOT possible to use right now as it uses a hardcoded Activity under the hood 16 | * 17 | * As a workaround, use this function that is equivalent. It requires you to add 18 | * [HiltTestActivity] in the debug folder and include it in the debug AndroidManifest.xml file 19 | * as can be found in this project. 20 | */ 21 | inline fun launchFragmentInHiltContainer( 22 | fragmentArgs: Bundle? = null, 23 | @StyleRes themeResId: Int = R.style.Theme_StoryApp, 24 | crossinline action: Fragment.() -> Unit = {} 25 | ) { 26 | val startActivityIntent = Intent.makeMainActivity( 27 | ComponentName( 28 | ApplicationProvider.getApplicationContext(), 29 | HiltTestActivity::class.java 30 | ) 31 | ).putExtra( 32 | "androidx.fragment.app.testing.FragmentScenario.EmptyFragmentActivity.THEME_EXTRAS_BUNDLE_KEY", 33 | themeResId 34 | ) 35 | 36 | ActivityScenario.launch(startActivityIntent).onActivity { activity -> 37 | val fragment: Fragment = activity.supportFragmentManager.fragmentFactory.instantiate( 38 | Preconditions.checkNotNull(T::class.java.classLoader), 39 | T::class.java.name 40 | ) 41 | fragment.arguments = fragmentArgs 42 | activity.supportFragmentManager 43 | .beginTransaction() 44 | .add(android.R.id.content, fragment, "") 45 | .commitNow() 46 | 47 | fragment.action() 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/androidTest/java/com/artworkspace/storyapp/utils/JsonConverter.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.utils 2 | 3 | import android.content.Context 4 | import androidx.test.core.app.ApplicationProvider 5 | import java.io.InputStreamReader 6 | 7 | object JsonConverter { 8 | 9 | fun readStringFromFile(filename: String): String { 10 | try { 11 | val applicationContext = ApplicationProvider.getApplicationContext() 12 | val inputStream = applicationContext.assets.open(filename) 13 | val builder = StringBuilder() 14 | val reader = InputStreamReader(inputStream, "UTF-8") 15 | reader.readLines().forEach { 16 | builder.append(it) 17 | } 18 | 19 | return builder.toString() 20 | } catch (e: Exception) { 21 | throw e 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 31 | 34 | 35 | 38 | 39 | 43 | 46 | 51 | 54 | 55 | 58 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 74 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /app/src/main/assets/success_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": false, 3 | "message": "Stories fetched successfully", 4 | "listStory": [ 5 | { 6 | "id": "story-FvU4u0Vp2S3PMsFg", 7 | "name": "Dimas", 8 | "description": "Lorem Ipsum", 9 | "photoUrl": "https://story-api.dicoding.dev/images/stories/photos-1641623658595_dummy-pic.png", 10 | "createdAt": "2022-01-08T06:34:18.598Z", 11 | "lat": -10.212, 12 | "lon": -16.002 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /app/src/main/assets/success_response_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": false, 3 | "message": "Stories fetched successfully", 4 | "listStory": [ 5 | ] 6 | } -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/BaseApplication.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class BaseApplication : Application() -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/adapter/LoadingStateAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.adapter 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.core.view.isVisible 6 | import androidx.paging.LoadState 7 | import androidx.paging.LoadStateAdapter 8 | import androidx.recyclerview.widget.RecyclerView 9 | import com.artworkspace.storyapp.R 10 | import com.artworkspace.storyapp.databinding.LayoutStoryLoadingBinding 11 | 12 | /** 13 | * Custom LoadStateAdapter for Paging3 14 | */ 15 | class LoadingStateAdapter(private val retry: () -> Unit) : 16 | LoadStateAdapter() { 17 | override fun onCreateViewHolder( 18 | parent: ViewGroup, 19 | loadState: LoadState 20 | ): LoadingStateViewHolder { 21 | val binding = 22 | LayoutStoryLoadingBinding.inflate(LayoutInflater.from(parent.context), parent, false) 23 | return LoadingStateViewHolder(binding, retry) 24 | } 25 | 26 | override fun onBindViewHolder(holder: LoadingStateViewHolder, loadState: LoadState) { 27 | holder.bind(loadState) 28 | } 29 | 30 | class LoadingStateViewHolder( 31 | private val binding: LayoutStoryLoadingBinding, 32 | retry: () -> Unit 33 | ) : 34 | RecyclerView.ViewHolder(binding.root) { 35 | 36 | init { 37 | binding.retryButton.setOnClickListener { retry.invoke() } 38 | } 39 | 40 | fun bind(loadState: LoadState) { 41 | if (loadState is LoadState.Error) { 42 | binding.errorMsg.text = 43 | binding.root.context.getString(R.string.loading_error_message) 44 | } 45 | 46 | binding.progressBar.isVisible = loadState is LoadState.Loading 47 | binding.retryButton.isVisible = loadState is LoadState.Error 48 | binding.errorMsg.isVisible = loadState is LoadState.Error 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/adapter/StoryListAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.adapter 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.view.LayoutInflater 7 | import android.view.ViewGroup 8 | import androidx.core.app.ActivityOptionsCompat 9 | import androidx.core.util.Pair 10 | import androidx.paging.PagingDataAdapter 11 | import androidx.recyclerview.widget.DiffUtil 12 | import androidx.recyclerview.widget.RecyclerView 13 | import com.artworkspace.storyapp.data.local.entity.Story 14 | import com.artworkspace.storyapp.databinding.LayoutStoryItemBinding 15 | import com.artworkspace.storyapp.ui.detail.DetailStoryActivity 16 | import com.artworkspace.storyapp.ui.detail.DetailStoryActivity.Companion.EXTRA_DETAIL 17 | import com.artworkspace.storyapp.utils.setImageFromUrl 18 | import com.artworkspace.storyapp.utils.setLocalDateFormat 19 | 20 | 21 | class StoryListAdapter : 22 | PagingDataAdapter(DiffCallback) { 23 | 24 | class ViewHolder(private val binding: LayoutStoryItemBinding) : 25 | RecyclerView.ViewHolder(binding.root) { 26 | fun bind(context: Context, story: Story) { 27 | binding.apply { 28 | tvStoryUsername.text = story.name 29 | tvStoryDescription.text = story.description 30 | ivStoryImage.setImageFromUrl(context, story.photoUrl) 31 | tvStoryDate.setLocalDateFormat(story.createdAt) 32 | 33 | // On item clicked 34 | root.setOnClickListener { 35 | // Set ActivityOptionsCompat for SharedElement 36 | val optionsCompat: ActivityOptionsCompat = 37 | ActivityOptionsCompat.makeSceneTransitionAnimation( 38 | root.context as Activity, 39 | Pair(ivStoryImage, "story_image"), 40 | Pair(tvStoryUsername, "username"), 41 | Pair(tvStoryDate, "date"), 42 | Pair(tvStoryDescription, "description") 43 | ) 44 | 45 | Intent(context, DetailStoryActivity::class.java).also { intent -> 46 | intent.putExtra(EXTRA_DETAIL, story) 47 | context.startActivity(intent, optionsCompat.toBundle()) 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { 55 | val binding = 56 | LayoutStoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) 57 | return ViewHolder(binding) 58 | } 59 | 60 | override fun onBindViewHolder(holder: ViewHolder, position: Int) { 61 | val story = getItem(position) 62 | if (story != null) { 63 | holder.bind(holder.itemView.context, story) 64 | } 65 | } 66 | 67 | companion object { 68 | val DiffCallback = object : DiffUtil.ItemCallback() { 69 | override fun areItemsTheSame(oldItem: Story, newItem: Story): Boolean { 70 | return oldItem.id == newItem.id 71 | } 72 | 73 | override fun areContentsTheSame(oldItem: Story, newItem: Story): Boolean { 74 | return oldItem == newItem 75 | } 76 | } 77 | } 78 | 79 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/AuthRepository.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data 2 | 3 | import com.artworkspace.storyapp.data.local.AuthPreferencesDataSource 4 | import com.artworkspace.storyapp.data.remote.response.LoginResponse 5 | import com.artworkspace.storyapp.data.remote.response.RegisterResponse 6 | import com.artworkspace.storyapp.data.remote.retrofit.ApiService 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.flow.flow 10 | import kotlinx.coroutines.flow.flowOn 11 | import javax.inject.Inject 12 | 13 | class AuthRepository @Inject constructor( 14 | private val apiService: ApiService, 15 | private val preferencesDataSource: AuthPreferencesDataSource 16 | ) { 17 | 18 | /** 19 | * Handle login operation for the users by calling the related API 20 | * 21 | * @param email User's email 22 | * @param password User's password 23 | * @return Flow 24 | */ 25 | suspend fun userLogin(email: String, password: String): Flow> = flow { 26 | try { 27 | val response = apiService.userLogin(email, password) 28 | emit(Result.success(response)) 29 | } catch (e: Exception) { 30 | e.printStackTrace() 31 | emit(Result.failure(e)) 32 | } 33 | }.flowOn(Dispatchers.IO) 34 | 35 | /** 36 | * Handle registration process for the users by calling the related API 37 | * 38 | * @param name User's full name 39 | * @param email User's email 40 | * @param password User's password 41 | * @return Flow 42 | */ 43 | suspend fun userRegister( 44 | name: String, 45 | email: String, 46 | password: String 47 | ): Flow> = flow { 48 | try { 49 | val response = apiService.userRegister(name, email, password) 50 | emit(Result.success(response)) 51 | } catch (e: Exception) { 52 | e.printStackTrace() 53 | emit(Result.failure(e)) 54 | } 55 | }.flowOn(Dispatchers.IO) 56 | 57 | /** 58 | * Save user's authentication token to the preferences 59 | * 60 | * @param token User's authentication token 61 | */ 62 | suspend fun saveAuthToken(token: String) { 63 | preferencesDataSource.saveAuthToken(token) 64 | } 65 | 66 | /** 67 | * Get the user's authentication token from preferences 68 | * 69 | * @return Flow 70 | */ 71 | fun getAuthToken(): Flow = preferencesDataSource.getAuthToken() 72 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/StoryRepository.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.paging.Pager 5 | import androidx.paging.PagingConfig 6 | import androidx.paging.PagingData 7 | import com.artworkspace.storyapp.data.local.entity.Story 8 | import com.artworkspace.storyapp.data.local.room.StoryDatabase 9 | import com.artworkspace.storyapp.data.remote.StoryRemoteMediator 10 | import com.artworkspace.storyapp.data.remote.response.FileUploadResponse 11 | import com.artworkspace.storyapp.data.remote.response.StoriesResponse 12 | import com.artworkspace.storyapp.data.remote.retrofit.ApiService 13 | import com.artworkspace.storyapp.utils.wrapEspressoIdlingResource 14 | import kotlinx.coroutines.flow.Flow 15 | import kotlinx.coroutines.flow.flow 16 | import okhttp3.MultipartBody 17 | import okhttp3.RequestBody 18 | import javax.inject.Inject 19 | 20 | 21 | @ExperimentalPagingApi 22 | class StoryRepository @Inject constructor( 23 | private val storyDatabase: StoryDatabase, 24 | private val apiService: ApiService, 25 | ) { 26 | 27 | /** 28 | * Provide all stories data from the data source 29 | * 30 | * @param token User's authentication token 31 | * @return Flow 32 | */ 33 | fun getAllStories(token: String): Flow> { 34 | return Pager( 35 | config = PagingConfig( 36 | pageSize = 10, 37 | ), 38 | remoteMediator = StoryRemoteMediator( 39 | storyDatabase, 40 | apiService, 41 | generateBearerToken(token) 42 | ), 43 | pagingSourceFactory = { 44 | storyDatabase.storyDao().getAllStories() 45 | } 46 | ).flow 47 | } 48 | 49 | /** 50 | * Provide latest story with its location 51 | * 52 | * @param token User's authentication token 53 | * @return Flow 54 | */ 55 | fun getAllStoriesWithLocation(token: String): Flow> = flow { 56 | wrapEspressoIdlingResource { 57 | try { 58 | val bearerToken = generateBearerToken(token) 59 | val response = apiService.getAllStories(bearerToken, size = 30, location = 1) 60 | emit(Result.success(response)) 61 | } catch (e: Exception) { 62 | e.printStackTrace() 63 | emit(Result.failure(e)) 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Handle image uploading process to the server 70 | * 71 | * @param token User's authentication token 72 | * @param file Image file 73 | * @param description Image description 74 | * @param lat Latitude 75 | * @param lon Longitude 76 | */ 77 | suspend fun uploadImage( 78 | token: String, 79 | file: MultipartBody.Part, 80 | description: RequestBody, 81 | lat: RequestBody? = null, 82 | lon: RequestBody? = null 83 | ): Flow> = flow { 84 | try { 85 | val bearerToken = generateBearerToken(token) 86 | val response = apiService.uploadImage(bearerToken, file, description, lat, lon) 87 | emit(Result.success(response)) 88 | } catch (e: Exception) { 89 | e.printStackTrace() 90 | emit(Result.failure(e)) 91 | } 92 | } 93 | 94 | /** 95 | * Give `Bearer` prefix to the given user's token 96 | * 97 | * @param token User's authentication token 98 | * @return Bearer token 99 | */ 100 | private fun generateBearerToken(token: String): String { 101 | return "Bearer $token" 102 | } 103 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/local/AuthPreferencesDataSource.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.local 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.edit 6 | import androidx.datastore.preferences.core.stringPreferencesKey 7 | import kotlinx.coroutines.flow.Flow 8 | import kotlinx.coroutines.flow.map 9 | import javax.inject.Inject 10 | 11 | class AuthPreferencesDataSource @Inject constructor(private val dataStore: DataStore) { 12 | 13 | /** 14 | * Get user's authentication token 15 | * 16 | * @return Flow 17 | */ 18 | fun getAuthToken(): Flow { 19 | return dataStore.data.map { preferences -> 20 | preferences[TOKEN_KEY] 21 | } 22 | } 23 | 24 | /** 25 | * Save the user's authentication token to preferences 26 | * 27 | * @param token Authentication token 28 | */ 29 | suspend fun saveAuthToken(token: String) { 30 | dataStore.edit { preferences -> 31 | preferences[TOKEN_KEY] = token 32 | } 33 | } 34 | 35 | companion object { 36 | private val TOKEN_KEY = stringPreferencesKey("token_data") 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/local/entity/RemoteKeys.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.local.entity 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "remote_keys") 7 | data class RemoteKeys( 8 | @PrimaryKey 9 | val id: String, 10 | val prevKey: Int?, 11 | val nextKey: Int? 12 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/local/entity/Story.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.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 | @Parcelize 10 | @Entity(tableName = "story") 11 | data class Story( 12 | @PrimaryKey 13 | val id: String, 14 | 15 | val name: String, 16 | 17 | val description: String, 18 | 19 | @ColumnInfo(name = "created_at") 20 | val createdAt: String, 21 | 22 | @ColumnInfo(name = "photo_url") 23 | val photoUrl: String, 24 | 25 | val lon: Double?, 26 | 27 | val lat: Double? 28 | ) : Parcelable 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/local/room/RemoteKeysDao.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.local.room 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | import com.artworkspace.storyapp.data.local.entity.RemoteKeys 8 | 9 | @Dao 10 | interface RemoteKeysDao { 11 | 12 | @Insert(onConflict = OnConflictStrategy.REPLACE) 13 | suspend fun insertAll(remoteKey: List) 14 | 15 | @Query("SELECT * FROM remote_keys WHERE id = :id") 16 | suspend fun getRemoteKeysId(id: String): RemoteKeys? 17 | 18 | @Query("DELETE FROM remote_keys") 19 | suspend fun deleteRemoteKeys() 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/local/room/StoryDao.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.local.room 2 | 3 | import androidx.paging.PagingSource 4 | import androidx.room.Dao 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import com.artworkspace.storyapp.data.local.entity.Story 9 | 10 | @Dao 11 | interface StoryDao { 12 | 13 | /** 14 | * Insert story to local database 15 | * 16 | * @param story Story to save 17 | */ 18 | @Insert(onConflict = OnConflictStrategy.REPLACE) 19 | suspend fun insertStory(vararg story: Story) 20 | 21 | /** 22 | * Get all stories from database 23 | * 24 | * @return PagingSource 25 | */ 26 | @Query("SELECT * FROM story") 27 | fun getAllStories(): PagingSource 28 | 29 | 30 | /** 31 | * Delete all saved stories from database 32 | */ 33 | @Query("DELETE FROM story") 34 | fun deleteAll() 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/local/room/StoryDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.local.room 2 | 3 | import androidx.room.Database 4 | import androidx.room.RoomDatabase 5 | import com.artworkspace.storyapp.data.local.entity.RemoteKeys 6 | import com.artworkspace.storyapp.data.local.entity.Story 7 | 8 | @Database( 9 | entities = [Story::class, RemoteKeys::class], 10 | version = 1, 11 | exportSchema = false 12 | ) 13 | abstract class StoryDatabase : RoomDatabase() { 14 | abstract fun storyDao(): StoryDao 15 | abstract fun remoteKeysDao(): RemoteKeysDao 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/remote/StoryRemoteMediator.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.remote 2 | 3 | import androidx.paging.ExperimentalPagingApi 4 | import androidx.paging.LoadType 5 | import androidx.paging.PagingState 6 | import androidx.paging.RemoteMediator 7 | import androidx.room.withTransaction 8 | import com.artworkspace.storyapp.data.local.entity.RemoteKeys 9 | import com.artworkspace.storyapp.data.local.entity.Story 10 | import com.artworkspace.storyapp.data.local.room.StoryDatabase 11 | import com.artworkspace.storyapp.data.remote.retrofit.ApiService 12 | import com.artworkspace.storyapp.utils.wrapEspressoIdlingResource 13 | 14 | @ExperimentalPagingApi 15 | class StoryRemoteMediator( 16 | private val database: StoryDatabase, 17 | private val apiService: ApiService, 18 | private val token: String 19 | ) : RemoteMediator() { 20 | 21 | override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { 22 | 23 | // Determine page value based on LoadType 24 | val page = when (loadType) { 25 | LoadType.REFRESH -> { 26 | val remoteKeys = getRemoteKeyClosestToCurrentPosition(state) 27 | remoteKeys?.nextKey?.minus(1) ?: INITIAL_PAGE_INDEX 28 | } 29 | LoadType.PREPEND -> { 30 | val remoteKeys = getRemoteKeyForFirstItem(state) 31 | val prevKey = remoteKeys?.prevKey ?: return MediatorResult.Success( 32 | endOfPaginationReached = remoteKeys != null 33 | ) 34 | prevKey 35 | } 36 | LoadType.APPEND -> { 37 | val remoteKeys = getRemoteKeysForLastItem(state) 38 | val nextKey = remoteKeys?.nextKey ?: return MediatorResult.Success( 39 | endOfPaginationReached = remoteKeys != null 40 | ) 41 | nextKey 42 | } 43 | } 44 | 45 | wrapEspressoIdlingResource { 46 | try { 47 | val responseData = apiService.getAllStories(token, page, state.config.pageSize) 48 | val endOfPaginationReached = responseData.storyResponseItems.isEmpty() 49 | 50 | database.withTransaction { 51 | if (loadType == LoadType.REFRESH) { 52 | database.remoteKeysDao().deleteRemoteKeys() 53 | database.storyDao().deleteAll() 54 | } 55 | 56 | val prevKey = if (page == 1) null else page - 1 57 | val nextKey = if (endOfPaginationReached) null else page + 1 58 | val keys = responseData.storyResponseItems.map { 59 | RemoteKeys(id = it.id, prevKey = prevKey, nextKey = nextKey) 60 | } 61 | 62 | // Save RemoteKeys information to database 63 | database.remoteKeysDao().insertAll(keys) 64 | 65 | // Convert StoryResponseItem class to Story class 66 | // We need to convert because the response from API is different from local database Entity 67 | responseData.storyResponseItems.forEach { storyResponseItem -> 68 | val story = Story( 69 | storyResponseItem.id, 70 | storyResponseItem.name, 71 | storyResponseItem.description, 72 | storyResponseItem.createdAt, 73 | storyResponseItem.photoUrl, 74 | storyResponseItem.lon, 75 | storyResponseItem.lat 76 | ) 77 | 78 | // Save Story to the local database 79 | database.storyDao().insertStory(story) 80 | } 81 | } 82 | 83 | return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) 84 | 85 | } catch (e: Exception) { 86 | return MediatorResult.Error(e) 87 | } 88 | } 89 | } 90 | 91 | override suspend fun initialize(): InitializeAction { 92 | return InitializeAction.LAUNCH_INITIAL_REFRESH 93 | } 94 | 95 | /** 96 | * Get RemoteKeys for last item from local database 97 | * 98 | * @param state PagingState 99 | */ 100 | private suspend fun getRemoteKeysForLastItem(state: PagingState): RemoteKeys? { 101 | return state.pages.lastOrNull { it.data.isNotEmpty() }?.data?.lastOrNull()?.let { data -> 102 | database.remoteKeysDao().getRemoteKeysId(data.id) 103 | } 104 | } 105 | 106 | /** 107 | * Get RemoteKeys for first item from local database 108 | * 109 | * @param state PagingState 110 | */ 111 | private suspend fun getRemoteKeyForFirstItem(state: PagingState): RemoteKeys? { 112 | return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()?.let { data -> 113 | database.remoteKeysDao().getRemoteKeysId(data.id) 114 | } 115 | } 116 | 117 | /** 118 | * Get RemoteKeys for closest to current position from local database 119 | * 120 | * @param state PagingState 121 | */ 122 | private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState): RemoteKeys? { 123 | return state.anchorPosition?.let { position -> 124 | state.closestItemToPosition(position)?.id?.let { id -> 125 | database.remoteKeysDao().getRemoteKeysId(id) 126 | } 127 | } 128 | } 129 | 130 | private companion object { 131 | const val INITIAL_PAGE_INDEX = 1 132 | } 133 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/remote/response/FileUploadResponse.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.remote.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class FileUploadResponse( 6 | 7 | @field:SerializedName("error") 8 | val error: Boolean? = null, 9 | 10 | @field:SerializedName("message") 11 | val message: String? = null 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/remote/response/LoginResponse.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.remote.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class LoginResponse( 6 | 7 | @field:SerializedName("loginResult") 8 | val loginResult: LoginResult? = null, 9 | 10 | @field:SerializedName("error") 11 | val error: Boolean? = null, 12 | 13 | @field:SerializedName("message") 14 | val message: String? = null 15 | ) 16 | 17 | data class LoginResult( 18 | 19 | @field:SerializedName("name") 20 | val name: String? = null, 21 | 22 | @field:SerializedName("userId") 23 | val userId: String? = null, 24 | 25 | @field:SerializedName("token") 26 | val token: String? = null 27 | ) 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/remote/response/RegisterResponse.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.remote.response 2 | 3 | import com.google.gson.annotations.SerializedName 4 | 5 | data class RegisterResponse( 6 | 7 | @field:SerializedName("error") 8 | val error: Boolean, 9 | 10 | @field:SerializedName("message") 11 | val message: String 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/remote/response/StoriesResponse.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.remote.response 2 | 3 | import android.os.Parcelable 4 | import com.google.gson.annotations.SerializedName 5 | import kotlinx.parcelize.Parcelize 6 | 7 | data class StoriesResponse( 8 | 9 | @field:SerializedName("listStory") 10 | val storyResponseItems: List, 11 | 12 | @field:SerializedName("error") 13 | val error: Boolean, 14 | 15 | @field:SerializedName("message") 16 | val message: String 17 | ) 18 | 19 | @Parcelize 20 | data class StoryResponseItem( 21 | 22 | @field:SerializedName("photoUrl") 23 | val photoUrl: String, 24 | 25 | @field:SerializedName("createdAt") 26 | val createdAt: String, 27 | 28 | @field:SerializedName("name") 29 | val name: String, 30 | 31 | @field:SerializedName("description") 32 | val description: String, 33 | 34 | @field:SerializedName("lon") 35 | val lon: Double?, 36 | 37 | @field:SerializedName("id") 38 | val id: String, 39 | 40 | @field:SerializedName("lat") 41 | val lat: Double? 42 | ) : Parcelable 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/remote/retrofit/ApiConfig.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.remote.retrofit 2 | 3 | import com.artworkspace.storyapp.BuildConfig 4 | import com.artworkspace.storyapp.BuildConfig.API_BASE_URL 5 | import okhttp3.OkHttpClient 6 | import okhttp3.logging.HttpLoggingInterceptor 7 | import retrofit2.Retrofit 8 | import retrofit2.converter.gson.GsonConverterFactory 9 | 10 | class ApiConfig { 11 | companion object { 12 | 13 | var API_BASE_URL_MOCK: String? = null 14 | 15 | /** 16 | * API service provider 17 | * 18 | * @return ApiService 19 | */ 20 | fun getApiService(): ApiService { 21 | val loggingInterceptor = if (BuildConfig.DEBUG) { 22 | HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY) 23 | } else { 24 | HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.NONE) 25 | } 26 | 27 | val client = OkHttpClient.Builder() 28 | .addInterceptor(loggingInterceptor) 29 | .build() 30 | 31 | val retrofit = Retrofit.Builder() 32 | .baseUrl(API_BASE_URL_MOCK ?: API_BASE_URL) 33 | .addConverterFactory(GsonConverterFactory.create()) 34 | .client(client) 35 | .build() 36 | 37 | return retrofit.create(ApiService::class.java) 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/data/remote/retrofit/ApiService.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.data.remote.retrofit 2 | 3 | import com.artworkspace.storyapp.data.remote.response.FileUploadResponse 4 | import com.artworkspace.storyapp.data.remote.response.LoginResponse 5 | import com.artworkspace.storyapp.data.remote.response.RegisterResponse 6 | import com.artworkspace.storyapp.data.remote.response.StoriesResponse 7 | import okhttp3.MultipartBody 8 | import okhttp3.RequestBody 9 | import retrofit2.http.* 10 | 11 | interface ApiService { 12 | 13 | /** 14 | * Call the API that handle a login process 15 | * 16 | * @param email User's email 17 | * @param password User's password 18 | */ 19 | @FormUrlEncoded 20 | @POST("login") 21 | suspend fun userLogin( 22 | @Field("email") email: String, 23 | @Field("password") password: String 24 | ): LoginResponse 25 | 26 | 27 | /** 28 | * Call the API that handle a registration process 29 | * 30 | * @param name User's name 31 | * @param email User's email 32 | * @param password User's password 33 | */ 34 | @FormUrlEncoded 35 | @POST("register") 36 | suspend fun userRegister( 37 | @Field("name") name: String, 38 | @Field("email") email: String, 39 | @Field("password") password: String 40 | ): RegisterResponse 41 | 42 | 43 | /** 44 | * Call the API to provide all stories data 45 | * 46 | * @param token User's authentication token 47 | * @param page 48 | * @param size 49 | */ 50 | @GET("stories") 51 | suspend fun getAllStories( 52 | @Header("Authorization") token: String, 53 | @Query("page") page: Int? = null, 54 | @Query("size") size: Int? = null, 55 | @Query("location") location: Int? = null 56 | ): StoriesResponse 57 | 58 | /** 59 | * Call the API that handle uploading image process 60 | * 61 | * @param token User's authentication token 62 | * @param file Multipart image 63 | * @param description Image description 64 | * @param lat Latitude 65 | * @param lon Longitude 66 | */ 67 | @Multipart 68 | @POST("stories") 69 | suspend fun uploadImage( 70 | @Header("Authorization") token: String, 71 | @Part file: MultipartBody.Part, 72 | @Part("description") description: RequestBody, 73 | @Part("lat") lat: RequestBody?, 74 | @Part("lon") lon: RequestBody? 75 | ): FileUploadResponse 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/di/ApiModule.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.di 2 | 3 | import com.artworkspace.storyapp.data.remote.retrofit.ApiConfig 4 | import com.artworkspace.storyapp.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 | /** 16 | * Provide API Service instance for Hilt 17 | * 18 | * @return ApiService 19 | */ 20 | @Provides 21 | @Singleton 22 | fun provideApiService(): ApiService = ApiConfig.getApiService() 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/di/DataStoreModule.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.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.storyapp.data.local.AuthPreferencesDataSource 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 | /** 22 | * Provide datastore instance for Hilt 23 | * 24 | * @param context Application context 25 | * @return DataStore 26 | */ 27 | @Provides 28 | fun provideDataStore(@ApplicationContext context: Context): DataStore = 29 | context.dataStore 30 | 31 | /** 32 | * Provide `AuthPreferencesDataSource` instance for Hilt 33 | * 34 | * @param dataStore DataStore 35 | * @return AuthPreferencesDataSource 36 | */ 37 | @Provides 38 | @Singleton 39 | fun provideAuthPreferences(dataStore: DataStore): AuthPreferencesDataSource = 40 | AuthPreferencesDataSource(dataStore) 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/di/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.di 2 | 3 | import android.content.Context 4 | import androidx.room.Room 5 | import com.artworkspace.storyapp.data.local.room.RemoteKeysDao 6 | import com.artworkspace.storyapp.data.local.room.StoryDao 7 | import com.artworkspace.storyapp.data.local.room.StoryDatabase 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 | @Module 16 | @InstallIn(SingletonComponent::class) 17 | class DatabaseModule { 18 | 19 | /** 20 | * Provide StoryDao instance for Hilt 21 | * 22 | * @param storyDatabase Local StoryDatabase 23 | */ 24 | @Provides 25 | fun provideStoryDao(storyDatabase: StoryDatabase): StoryDao = storyDatabase.storyDao() 26 | 27 | /** 28 | * Provide RemoteKeysDao instance for Hilt 29 | * 30 | * @param storyDatabase Local StoryDatabase 31 | */ 32 | @Provides 33 | fun provideRemoteKeysDao(storyDatabase: StoryDatabase): RemoteKeysDao = 34 | storyDatabase.remoteKeysDao() 35 | 36 | 37 | /** 38 | * Provide StoryDatabase instance for Hilt 39 | * 40 | * @param context Context 41 | */ 42 | @Provides 43 | @Singleton 44 | fun provideStoryDatabase(@ApplicationContext context: Context): StoryDatabase { 45 | return Room.databaseBuilder( 46 | context.applicationContext, 47 | StoryDatabase::class.java, 48 | "story_database" 49 | ).build() 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/auth/AuthActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.auth 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.artworkspace.storyapp.databinding.ActivityAuthBinding 6 | import dagger.hilt.android.AndroidEntryPoint 7 | 8 | @AndroidEntryPoint 9 | class AuthActivity : AppCompatActivity() { 10 | 11 | private lateinit var binding: ActivityAuthBinding 12 | 13 | override fun onCreate(savedInstanceState: Bundle?) { 14 | super.onCreate(savedInstanceState) 15 | binding = ActivityAuthBinding.inflate(layoutInflater) 16 | setContentView(binding.root) 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/create/CreateViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.create 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.paging.ExperimentalPagingApi 5 | import com.artworkspace.storyapp.data.AuthRepository 6 | import com.artworkspace.storyapp.data.StoryRepository 7 | import com.artworkspace.storyapp.data.remote.response.FileUploadResponse 8 | import dagger.hilt.android.lifecycle.HiltViewModel 9 | import kotlinx.coroutines.flow.Flow 10 | import okhttp3.MultipartBody 11 | import okhttp3.RequestBody 12 | import javax.inject.Inject 13 | 14 | @ExperimentalPagingApi 15 | @HiltViewModel 16 | class CreateViewModel @Inject constructor( 17 | private val authRepository: AuthRepository, 18 | private val storyRepository: StoryRepository 19 | ) : ViewModel() { 20 | 21 | /** 22 | * Get user's authentication token 23 | * 24 | * @return Flow 25 | */ 26 | fun getAuthToken(): Flow = authRepository.getAuthToken() 27 | 28 | /** 29 | * Handle image uploading process to the server 30 | * 31 | * @param token User's authentication token 32 | * @param file Image file 33 | * @param description Image description 34 | * @param lat Latitude 35 | * @param lon Longitude 36 | */ 37 | suspend fun uploadImage( 38 | token: String, 39 | file: MultipartBody.Part, 40 | description: RequestBody, 41 | lat: RequestBody?, 42 | lon: RequestBody? 43 | ): Flow> = 44 | storyRepository.uploadImage(token, file, description, lat, lon) 45 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/detail/DetailStoryActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.detail 2 | 3 | import android.graphics.drawable.Drawable 4 | import android.os.Bundle 5 | import androidx.appcompat.app.AppCompatActivity 6 | import com.artworkspace.storyapp.R 7 | import com.artworkspace.storyapp.data.local.entity.Story 8 | import com.artworkspace.storyapp.databinding.ActivityDetailStoryBinding 9 | import com.artworkspace.storyapp.utils.setLocalDateFormat 10 | import com.bumptech.glide.Glide 11 | import com.bumptech.glide.load.DataSource 12 | import com.bumptech.glide.load.engine.GlideException 13 | import com.bumptech.glide.request.RequestListener 14 | import com.bumptech.glide.request.target.Target 15 | 16 | class DetailStoryActivity : AppCompatActivity() { 17 | 18 | private lateinit var binding: ActivityDetailStoryBinding 19 | 20 | override fun onCreate(savedInstanceState: Bundle?) { 21 | super.onCreate(savedInstanceState) 22 | binding = ActivityDetailStoryBinding.inflate(layoutInflater) 23 | setContentView(binding.root) 24 | 25 | // Wait until all resource is already loaded 26 | supportPostponeEnterTransition() 27 | 28 | val story = intent.getParcelableExtra(EXTRA_DETAIL) 29 | parseStoryData(story) 30 | 31 | setSupportActionBar(binding.toolbar) 32 | supportActionBar?.setDisplayHomeAsUpEnabled(true) 33 | supportActionBar?.setDisplayShowHomeEnabled(true) 34 | } 35 | 36 | override fun onSupportNavigateUp(): Boolean { 37 | onBackPressed() 38 | return true 39 | } 40 | 41 | /** 42 | * Parse story data to related views 43 | * 44 | * @param story Story data to parse 45 | */ 46 | private fun parseStoryData(story: Story?) { 47 | if (story != null) { 48 | binding.apply { 49 | tvStoryUsername.text = story.name 50 | tvStoryDescription.text = story.description 51 | toolbar.title = getString(R.string.detail_toolbar_title, story.name) 52 | tvStoryDate.setLocalDateFormat(story.createdAt) 53 | 54 | // Parse image to ImageView 55 | // Using listener for make sure the enter transition only called when loading completed 56 | Glide 57 | .with(this@DetailStoryActivity) 58 | .load(story.photoUrl) 59 | .listener(object : RequestListener { 60 | override fun onLoadFailed( 61 | e: GlideException?, 62 | model: Any?, 63 | target: Target?, 64 | isFirstResource: Boolean 65 | ): Boolean { 66 | // Continue enter animation after image loaded 67 | supportStartPostponedEnterTransition() 68 | return false 69 | } 70 | 71 | override fun onResourceReady( 72 | resource: Drawable?, 73 | model: Any?, 74 | target: Target?, 75 | dataSource: DataSource?, 76 | isFirstResource: Boolean 77 | ): Boolean { 78 | supportStartPostponedEnterTransition() 79 | return false 80 | } 81 | }) 82 | .placeholder(R.drawable.image_loading_placeholder) 83 | .error(R.drawable.image_load_error) 84 | .into(ivStoryImage) 85 | } 86 | } 87 | } 88 | 89 | companion object { 90 | const val EXTRA_DETAIL = "extra_detail" 91 | } 92 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/home/HomeFragment.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.home 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.paging.ExperimentalPagingApi 11 | import androidx.paging.LoadState 12 | import androidx.paging.PagingData 13 | import androidx.recyclerview.widget.LinearLayoutManager 14 | import androidx.recyclerview.widget.RecyclerView 15 | import com.artworkspace.storyapp.adapter.LoadingStateAdapter 16 | import com.artworkspace.storyapp.adapter.StoryListAdapter 17 | import com.artworkspace.storyapp.data.local.entity.Story 18 | import com.artworkspace.storyapp.databinding.FragmentHomeBinding 19 | import com.artworkspace.storyapp.ui.create.CreateStoryActivity 20 | import com.artworkspace.storyapp.ui.main.MainActivity 21 | import com.artworkspace.storyapp.utils.animateVisibility 22 | import dagger.hilt.android.AndroidEntryPoint 23 | 24 | @AndroidEntryPoint 25 | @ExperimentalPagingApi 26 | class HomeFragment : Fragment() { 27 | 28 | private var _binding: FragmentHomeBinding? = null 29 | private val binding get() = _binding 30 | 31 | private lateinit var recyclerView: RecyclerView 32 | private lateinit var listAdapter: StoryListAdapter 33 | 34 | private var token: String = "" 35 | private val homeViewModel: HomeViewModel by viewModels() 36 | 37 | override fun onCreateView( 38 | inflater: LayoutInflater, 39 | container: ViewGroup?, 40 | savedInstanceState: Bundle? 41 | ): View? { 42 | // Using layoutInflater from activity 43 | // So, the SharedElement transition can works 44 | _binding = FragmentHomeBinding.inflate(LayoutInflater.from(requireActivity())) 45 | return binding?.root 46 | } 47 | 48 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 49 | super.onViewCreated(view, savedInstanceState) 50 | 51 | token = requireActivity().intent.getStringExtra(MainActivity.EXTRA_TOKEN) ?: "" 52 | 53 | setSwipeRefreshLayout() 54 | setRecyclerView() 55 | getAllStories() 56 | 57 | binding?.fabCreateStory?.setOnClickListener { 58 | Intent(requireContext(), CreateStoryActivity::class.java).also { intent -> 59 | startActivity(intent) 60 | } 61 | } 62 | } 63 | 64 | override fun onDestroyView() { 65 | super.onDestroyView() 66 | _binding = null 67 | } 68 | 69 | /** 70 | * Get all stories data and set the related views state 71 | */ 72 | private fun getAllStories() { 73 | homeViewModel.getAllStories(token).observe(viewLifecycleOwner) { result -> 74 | updateRecyclerViewData(result) 75 | } 76 | } 77 | 78 | /** 79 | * Set the SwipeRefreshLayout state 80 | */ 81 | private fun setSwipeRefreshLayout() { 82 | binding?.swipeRefresh?.setOnRefreshListener { 83 | getAllStories() 84 | } 85 | } 86 | 87 | /** 88 | * Set the RecyclerView UI state 89 | * 90 | */ 91 | private fun setRecyclerView() { 92 | val linearLayoutManager = LinearLayoutManager(requireContext()) 93 | listAdapter = StoryListAdapter() 94 | 95 | // Pager LoadState listener 96 | listAdapter.addLoadStateListener { loadState -> 97 | if ((loadState.source.refresh is LoadState.NotLoading && loadState.append.endOfPaginationReached && listAdapter.itemCount < 1) || loadState.source.refresh is LoadState.Error) { 98 | // List empty or error 99 | binding?.apply { 100 | tvNotFoundError.animateVisibility(true) 101 | ivNotFoundError.animateVisibility(true) 102 | rvStories.animateVisibility(false) 103 | } 104 | } else { 105 | // List not empty 106 | binding?.apply { 107 | tvNotFoundError.animateVisibility(false) 108 | ivNotFoundError.animateVisibility(false) 109 | rvStories.animateVisibility(true) 110 | } 111 | } 112 | 113 | // SwipeRefresh status based on LoadState 114 | binding?.swipeRefresh?.isRefreshing = loadState.source.refresh is LoadState.Loading 115 | } 116 | 117 | try { 118 | recyclerView = binding?.rvStories!! 119 | recyclerView.apply { 120 | adapter = listAdapter.withLoadStateFooter( 121 | footer = LoadingStateAdapter { 122 | listAdapter.retry() 123 | } 124 | ) 125 | layoutManager = linearLayoutManager 126 | } 127 | } catch (e: NullPointerException) { 128 | e.printStackTrace() 129 | } 130 | } 131 | 132 | /** 133 | * Update RecyclerView adapter data 134 | * 135 | * @param stories New data 136 | */ 137 | private fun updateRecyclerViewData(stories: PagingData) { 138 | // SaveInstanceState of recyclerview before add new data 139 | // It's prevent the recyclerview to scroll again to the top when load new data 140 | val recyclerViewState = recyclerView.layoutManager?.onSaveInstanceState() 141 | 142 | // Add data to the adapter 143 | listAdapter.submitData(lifecycle, stories) 144 | 145 | // Restore last recyclerview state 146 | recyclerView.layoutManager?.onRestoreInstanceState(recyclerViewState) 147 | } 148 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.home 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.asLiveData 6 | import androidx.lifecycle.viewModelScope 7 | import androidx.paging.ExperimentalPagingApi 8 | import androidx.paging.PagingData 9 | import androidx.paging.cachedIn 10 | import com.artworkspace.storyapp.data.AuthRepository 11 | import com.artworkspace.storyapp.data.StoryRepository 12 | import com.artworkspace.storyapp.data.local.entity.Story 13 | import dagger.hilt.android.lifecycle.HiltViewModel 14 | import javax.inject.Inject 15 | 16 | @ExperimentalPagingApi 17 | @HiltViewModel 18 | class HomeViewModel @Inject constructor( 19 | private val storyRepository: StoryRepository, 20 | private val authRepository: AuthRepository 21 | ) : ViewModel() { 22 | 23 | /** 24 | * Get all user stories from data source 25 | * 26 | * @param token User's authentication token 27 | * @return LiveData 28 | */ 29 | fun getAllStories(token: String): LiveData> = 30 | storyRepository.getAllStories(token).cachedIn(viewModelScope).asLiveData() 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/location/LocationViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.location 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.paging.ExperimentalPagingApi 5 | import com.artworkspace.storyapp.data.StoryRepository 6 | import com.artworkspace.storyapp.data.remote.response.StoriesResponse 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.flow.Flow 9 | import javax.inject.Inject 10 | 11 | @ExperimentalPagingApi 12 | @HiltViewModel 13 | class LocationViewModel @Inject constructor(private val storyRepository: StoryRepository) : 14 | ViewModel() { 15 | 16 | /** 17 | * Get all stories with location available 18 | * 19 | * @param token User's authentication token 20 | */ 21 | fun getAllStories(token: String): Flow> = 22 | storyRepository.getAllStoriesWithLocation(token) 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/login/LoginFragment.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.login 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 android.widget.Toast 9 | import androidx.fragment.app.Fragment 10 | import androidx.fragment.app.viewModels 11 | import androidx.lifecycle.lifecycleScope 12 | import androidx.navigation.Navigation 13 | import com.artworkspace.storyapp.R 14 | import com.artworkspace.storyapp.databinding.FragmentLoginBinding 15 | import com.artworkspace.storyapp.ui.main.MainActivity 16 | import com.artworkspace.storyapp.ui.main.MainActivity.Companion.EXTRA_TOKEN 17 | import com.artworkspace.storyapp.utils.animateVisibility 18 | import com.google.android.material.snackbar.Snackbar 19 | import dagger.hilt.android.AndroidEntryPoint 20 | import kotlinx.coroutines.Job 21 | import kotlinx.coroutines.launch 22 | 23 | @AndroidEntryPoint 24 | class LoginFragment : Fragment() { 25 | 26 | private var _binding: FragmentLoginBinding? = null 27 | private val binding get() = _binding!! 28 | 29 | private var loginJob: Job = Job() 30 | private val viewModel: LoginViewModel by viewModels() 31 | 32 | override fun onCreateView( 33 | inflater: LayoutInflater, container: ViewGroup?, 34 | savedInstanceState: Bundle? 35 | ): View { 36 | _binding = FragmentLoginBinding.inflate(inflater, container, false) 37 | return binding.root 38 | } 39 | 40 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 41 | super.onViewCreated(view, savedInstanceState) 42 | setActions() 43 | } 44 | 45 | override fun onDestroyView() { 46 | super.onDestroyView() 47 | _binding = null 48 | } 49 | 50 | /** 51 | * Set actions listener on the UI 52 | */ 53 | private fun setActions() { 54 | binding.apply { 55 | btnRegister.setOnClickListener( 56 | Navigation.createNavigateOnClickListener(R.id.action_loginFragment_to_registerFragment) 57 | ) 58 | 59 | btnLogin.setOnClickListener { 60 | handleLogin() 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Handle login process for users 67 | */ 68 | private fun handleLogin() { 69 | val email = binding.etEmail.text.toString().trim() 70 | val password = binding.etPassword.text.toString() 71 | setLoadingState(true) 72 | 73 | lifecycleScope.launchWhenResumed { 74 | // Make sure only one job that handle the login process 75 | if (loginJob.isActive) loginJob.cancel() 76 | 77 | loginJob = launch { 78 | viewModel.userLogin(email, password).collect { result -> 79 | result.onSuccess { credentials -> 80 | 81 | // Save token to the preferences 82 | // And direct user to the MainActivity 83 | credentials.loginResult?.token?.let { token -> 84 | viewModel.saveAuthToken(token) 85 | Intent(requireContext(), MainActivity::class.java).also { intent -> 86 | intent.putExtra(EXTRA_TOKEN, token) 87 | startActivity(intent) 88 | requireActivity().finish() 89 | } 90 | } 91 | 92 | Toast.makeText( 93 | requireContext(), 94 | getString(R.string.login_success_message), 95 | Toast.LENGTH_SHORT 96 | ).show() 97 | } 98 | 99 | result.onFailure { 100 | Snackbar.make( 101 | binding.root, 102 | getString(R.string.login_error_message), 103 | Snackbar.LENGTH_SHORT 104 | ).show() 105 | 106 | setLoadingState(false) 107 | } 108 | } 109 | } 110 | } 111 | 112 | } 113 | 114 | /** 115 | * Set related views state based on the loading value 116 | * 117 | * @param isLoading Loading state 118 | */ 119 | private fun setLoadingState(isLoading: Boolean) { 120 | binding.apply { 121 | etEmail.isEnabled = !isLoading 122 | etPassword.isEnabled = !isLoading 123 | btnLogin.isEnabled = !isLoading 124 | 125 | // Animate views alpha 126 | if (isLoading) { 127 | viewLoading.animateVisibility(true) 128 | } else { 129 | viewLoading.animateVisibility(false) 130 | } 131 | } 132 | } 133 | 134 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/login/LoginViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.login 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.artworkspace.storyapp.data.AuthRepository 6 | import com.artworkspace.storyapp.data.remote.response.LoginResponse 7 | import dagger.hilt.android.lifecycle.HiltViewModel 8 | import kotlinx.coroutines.flow.Flow 9 | import kotlinx.coroutines.launch 10 | import javax.inject.Inject 11 | 12 | @HiltViewModel 13 | class LoginViewModel @Inject constructor( 14 | private val authRepository: AuthRepository 15 | ) : ViewModel() { 16 | 17 | /** 18 | * Do authentication for the users 19 | * 20 | * @param email User's email 21 | * @param password User's password 22 | * @return Flow 23 | */ 24 | suspend fun userLogin(email: String, password: String): Flow> = 25 | authRepository.userLogin(email, password) 26 | 27 | 28 | /** 29 | * Save user's authentication token 30 | * 31 | * @param token User's authentication token 32 | */ 33 | fun saveAuthToken(token: String) { 34 | viewModelScope.launch { 35 | authRepository.saveAuthToken(token) 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/main/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.main 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import androidx.navigation.fragment.NavHostFragment 6 | import androidx.navigation.ui.setupWithNavController 7 | import com.artworkspace.storyapp.R 8 | import com.artworkspace.storyapp.databinding.ActivityMainBinding 9 | import com.google.android.material.bottomnavigation.BottomNavigationView 10 | import dagger.hilt.android.AndroidEntryPoint 11 | 12 | @AndroidEntryPoint 13 | class MainActivity : AppCompatActivity() { 14 | 15 | private lateinit var binding: ActivityMainBinding 16 | 17 | override fun onCreate(savedInstanceState: Bundle?) { 18 | super.onCreate(savedInstanceState) 19 | 20 | binding = ActivityMainBinding.inflate(layoutInflater) 21 | setContentView(binding.root) 22 | 23 | val navView: BottomNavigationView = binding.navView 24 | 25 | val navHostFragment = 26 | supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_home) as NavHostFragment 27 | val navController = navHostFragment.navController 28 | navView.setupWithNavController(navController) 29 | } 30 | 31 | companion object { 32 | const val EXTRA_TOKEN = "extra_token" 33 | } 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/register/RegisterFragment.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.register 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import android.widget.Toast 8 | import androidx.fragment.app.Fragment 9 | import androidx.fragment.app.viewModels 10 | import androidx.lifecycle.lifecycleScope 11 | import androidx.navigation.Navigation 12 | import androidx.navigation.fragment.findNavController 13 | import com.artworkspace.storyapp.R 14 | import com.artworkspace.storyapp.databinding.FragmentRegisterBinding 15 | import com.artworkspace.storyapp.utils.animateVisibility 16 | import com.google.android.material.snackbar.Snackbar 17 | import dagger.hilt.android.AndroidEntryPoint 18 | import kotlinx.coroutines.Job 19 | import kotlinx.coroutines.launch 20 | 21 | @AndroidEntryPoint 22 | class RegisterFragment : Fragment() { 23 | 24 | private var _binding: FragmentRegisterBinding? = null 25 | private val binding get() = _binding!! 26 | 27 | private var registerJob: Job = Job() 28 | private val viewModel: RegisterViewModel by viewModels() 29 | 30 | override fun onCreateView( 31 | inflater: LayoutInflater, container: ViewGroup?, 32 | savedInstanceState: Bundle? 33 | ): View { 34 | _binding = FragmentRegisterBinding.inflate(inflater, container, false) 35 | return binding.root 36 | } 37 | 38 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 39 | super.onViewCreated(view, savedInstanceState) 40 | setActions() 41 | } 42 | 43 | override fun onDestroyView() { 44 | super.onDestroyView() 45 | _binding = null 46 | } 47 | 48 | /** 49 | * Set actions listener on the UI 50 | */ 51 | private fun setActions() { 52 | binding.apply { 53 | btnLogin.setOnClickListener( 54 | Navigation.createNavigateOnClickListener(R.id.action_registerFragment_to_loginFragment) 55 | ) 56 | 57 | btnRegister.setOnClickListener { 58 | handleRegister() 59 | } 60 | } 61 | } 62 | 63 | /** 64 | * Handle register process for users 65 | */ 66 | private fun handleRegister() { 67 | val name = binding.etFullName.text.toString().trim() 68 | val email = binding.etEmail.text.toString().trim() 69 | val password = binding.etPassword.text.toString() 70 | setLoadingState(true) 71 | 72 | lifecycleScope.launchWhenResumed { 73 | // Make sure only one job that handle the registration process 74 | if (registerJob.isActive) registerJob.cancel() 75 | 76 | registerJob = launch { 77 | viewModel.userRegister(name, email, password).collect { result -> 78 | result.onSuccess { 79 | Toast.makeText( 80 | requireContext(), 81 | getString(R.string.registration_success), 82 | Toast.LENGTH_SHORT 83 | ).show() 84 | 85 | // Automatically navigate user back to the login page 86 | findNavController().navigate(R.id.action_registerFragment_to_loginFragment) 87 | } 88 | 89 | result.onFailure { 90 | Snackbar.make( 91 | binding.root, 92 | getString(R.string.registration_error_message), 93 | Snackbar.LENGTH_SHORT 94 | ).show() 95 | setLoadingState(false) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * Set related views state based on the loading value 104 | * 105 | * @param isLoading Loading state 106 | */ 107 | private fun setLoadingState(isLoading: Boolean) { 108 | binding.apply { 109 | etEmail.isEnabled = !isLoading 110 | etPassword.isEnabled = !isLoading 111 | etFullName.isEnabled = !isLoading 112 | btnRegister.isEnabled = !isLoading 113 | 114 | if (isLoading) { 115 | viewLoading.animateVisibility(true) 116 | } else { 117 | viewLoading.animateVisibility(false) 118 | } 119 | } 120 | } 121 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/register/RegisterViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.register 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.artworkspace.storyapp.data.AuthRepository 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import javax.inject.Inject 7 | 8 | @HiltViewModel 9 | class RegisterViewModel @Inject constructor( 10 | private val authRepository: AuthRepository 11 | ) : ViewModel() { 12 | 13 | /** 14 | * Handle registration process for the users 15 | * 16 | * @param name User's name 17 | * @param email User's email 18 | * @param password User's password 19 | * @return Flow 20 | */ 21 | suspend fun userRegister(name: String, email: String, password: String) = 22 | authRepository.userRegister(name, email, password) 23 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/setting/SettingFragment.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.setting 2 | 3 | import android.content.Intent 4 | import android.os.Bundle 5 | import android.provider.Settings 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import android.widget.Toast 10 | import androidx.fragment.app.Fragment 11 | import androidx.fragment.app.viewModels 12 | import com.artworkspace.storyapp.R 13 | import com.artworkspace.storyapp.databinding.FragmentSettingsBinding 14 | import com.artworkspace.storyapp.ui.auth.AuthActivity 15 | import com.google.android.material.dialog.MaterialAlertDialogBuilder 16 | import dagger.hilt.android.AndroidEntryPoint 17 | 18 | @AndroidEntryPoint 19 | class SettingFragment : Fragment() { 20 | 21 | private var _binding: FragmentSettingsBinding? = null 22 | private val binding get() = _binding!! 23 | 24 | private val settingViewModel: SettingViewModel by viewModels() 25 | 26 | override fun onCreateView( 27 | inflater: LayoutInflater, 28 | container: ViewGroup?, 29 | savedInstanceState: Bundle? 30 | ): View { 31 | _binding = FragmentSettingsBinding.inflate(inflater, container, false) 32 | return binding.root 33 | } 34 | 35 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 36 | super.onViewCreated(view, savedInstanceState) 37 | 38 | binding.apply { 39 | btnLanguageSetting.setOnClickListener { 40 | startActivity(Intent(Settings.ACTION_LOCALE_SETTINGS)) 41 | } 42 | 43 | btnLogout.setOnClickListener { 44 | showLogoutDialog() 45 | } 46 | } 47 | } 48 | 49 | override fun onDestroyView() { 50 | super.onDestroyView() 51 | _binding = null 52 | } 53 | 54 | /** 55 | * Show logout alert dialog before user logout 56 | */ 57 | private fun showLogoutDialog() { 58 | MaterialAlertDialogBuilder(requireContext()) 59 | .setTitle(getString(R.string.logout_dialog_title)) 60 | .setMessage(getString(R.string.logout_dialog_message)) 61 | .setNegativeButton(getString(R.string.cancel)) { dialog, _ -> 62 | dialog.dismiss() 63 | } 64 | .setPositiveButton(getString(R.string.logout)) { _, _ -> 65 | settingViewModel.saveAuthToken("") 66 | Intent(requireContext(), AuthActivity::class.java).also { intent -> 67 | startActivity(intent) 68 | requireActivity().finish() 69 | } 70 | Toast.makeText( 71 | requireContext(), 72 | getString(R.string.logout_message_success), 73 | Toast.LENGTH_SHORT 74 | ) 75 | .show() 76 | } 77 | .show() 78 | } 79 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/setting/SettingViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.setting 2 | 3 | import androidx.lifecycle.ViewModel 4 | import androidx.lifecycle.viewModelScope 5 | import com.artworkspace.storyapp.data.AuthRepository 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import kotlinx.coroutines.launch 8 | import javax.inject.Inject 9 | 10 | @HiltViewModel 11 | class SettingViewModel @Inject constructor(private val authRepository: AuthRepository) : 12 | ViewModel() { 13 | 14 | /** 15 | * Save user's authentication token 16 | * 17 | * @param token User's authentication token 18 | */ 19 | fun saveAuthToken(token: String) { 20 | viewModelScope.launch { 21 | authRepository.saveAuthToken(token) 22 | } 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/splash/SplashActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.splash 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Intent 5 | import android.os.Bundle 6 | import androidx.activity.viewModels 7 | import androidx.appcompat.app.AppCompatActivity 8 | import androidx.lifecycle.lifecycleScope 9 | import com.artworkspace.storyapp.ui.auth.AuthActivity 10 | import com.artworkspace.storyapp.ui.main.MainActivity 11 | import com.artworkspace.storyapp.ui.main.MainActivity.Companion.EXTRA_TOKEN 12 | import dagger.hilt.android.AndroidEntryPoint 13 | import kotlinx.coroutines.launch 14 | 15 | @AndroidEntryPoint 16 | @SuppressLint("CustomSplashScreen") 17 | class SplashActivity : AppCompatActivity() { 18 | 19 | private val viewModel: SplashViewModel by viewModels() 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | determineUserDirection() 24 | } 25 | 26 | /** 27 | * Decide which activity to display based on authentication token availability 28 | */ 29 | private fun determineUserDirection() { 30 | lifecycleScope.launchWhenCreated { 31 | launch { 32 | viewModel.getAuthToken().collect { token -> 33 | if (token.isNullOrEmpty()) { 34 | // No token in data source, go to AuthActivity 35 | Intent(this@SplashActivity, AuthActivity::class.java).also { intent -> 36 | startActivity(intent) 37 | finish() 38 | } 39 | } else { 40 | // Token detected on data source, go to MainActivity 41 | Intent(this@SplashActivity, MainActivity::class.java).also { intent -> 42 | intent.putExtra(EXTRA_TOKEN, token) 43 | startActivity(intent) 44 | finish() 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/ui/splash/SplashViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.ui.splash 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.artworkspace.storyapp.data.AuthRepository 5 | import dagger.hilt.android.lifecycle.HiltViewModel 6 | import kotlinx.coroutines.flow.Flow 7 | import javax.inject.Inject 8 | 9 | @HiltViewModel 10 | class SplashViewModel @Inject constructor(private val authRepository: AuthRepository) : 11 | ViewModel() { 12 | 13 | /** 14 | * Get user's authentication token from data source 15 | * 16 | * @return Flow 17 | */ 18 | fun getAuthToken(): Flow = authRepository.getAuthToken() 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/utils/DataDummy.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.utils 2 | 3 | import com.artworkspace.storyapp.data.local.entity.Story 4 | import com.artworkspace.storyapp.data.remote.response.* 5 | import okhttp3.MultipartBody 6 | import okhttp3.RequestBody 7 | import okhttp3.RequestBody.Companion.toRequestBody 8 | 9 | object DataDummy { 10 | fun generateDummyStoriesResponse(): StoriesResponse { 11 | val error = false 12 | val message = "Stories fetched successfully" 13 | val listStory = mutableListOf() 14 | 15 | for (i in 0 until 10) { 16 | val story = StoryResponseItem( 17 | id = "story-FvU4u0Vp2S3PMsFg", 18 | photoUrl = "https://story-api.dicoding.dev/images/stories/photos-1641623658595_dummy-pic.png", 19 | createdAt = "2022-01-08T06:34:18.598Z", 20 | name = "Dimas", 21 | description = "Lorem Ipsum", 22 | lon = -16.002, 23 | lat = -10.212 24 | ) 25 | 26 | listStory.add(story) 27 | } 28 | 29 | return StoriesResponse(listStory, error, message) 30 | } 31 | 32 | fun generateDummyListStory(): List { 33 | val items = arrayListOf() 34 | 35 | for (i in 0 until 10) { 36 | val story = Story( 37 | id = "story-FvU4u0Vp2S3PMsFg", 38 | photoUrl = "https://story-api.dicoding.dev/images/stories/photos-1641623658595_dummy-pic.png", 39 | createdAt = "2022-01-08T06:34:18.598Z", 40 | name = "Dimas", 41 | description = "Lorem Ipsum", 42 | lon = -16.002, 43 | lat = -10.212 44 | ) 45 | 46 | items.add(story) 47 | } 48 | 49 | return items 50 | } 51 | 52 | 53 | fun generateDummyLoginResponse(): LoginResponse { 54 | val loginResult = LoginResult( 55 | userId = "user-yj5pc_LARC_AgK61", 56 | name = "Arif Faizin", 57 | token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ1c2VyLXlqNXBjX0xBUkNfQWdLNjEiLCJpYXQiOjE2NDE3OTk5NDl9.flEMaQ7zsdYkxuyGbiXjEDXO8kuDTcI__3UjCwt6R_I" 58 | ) 59 | 60 | return LoginResponse( 61 | loginResult = loginResult, 62 | error = false, 63 | message = "success" 64 | ) 65 | } 66 | 67 | fun generateDummyRegisterResponse(): RegisterResponse { 68 | return RegisterResponse( 69 | error = false, 70 | message = "success" 71 | ) 72 | } 73 | 74 | fun generateDummyMultipartFile(): MultipartBody.Part { 75 | val dummyText = "text" 76 | return MultipartBody.Part.create(dummyText.toRequestBody()) 77 | } 78 | 79 | fun generateDummyRequestBody(): RequestBody { 80 | val dummyText = "text" 81 | return dummyText.toRequestBody() 82 | } 83 | 84 | fun generateDummyFileUploadResponse(): FileUploadResponse { 85 | return FileUploadResponse( 86 | error = false, 87 | message = "success" 88 | ) 89 | } 90 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/utils/EspressoIdlingResource.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.utils 2 | 3 | import androidx.test.espresso.idling.CountingIdlingResource 4 | 5 | object EspressoIdlingResource { 6 | 7 | private const val RESOURCE = "GLOBAL" 8 | 9 | @JvmField 10 | val countingIdlingResource = CountingIdlingResource(RESOURCE) 11 | 12 | fun increment() { 13 | countingIdlingResource.increment() 14 | } 15 | 16 | fun decrement() { 17 | if (!countingIdlingResource.isIdleNow) { 18 | countingIdlingResource.decrement() 19 | } 20 | } 21 | } 22 | 23 | inline fun wrapEspressoIdlingResource(function: () -> T): T { 24 | EspressoIdlingResource.increment() // Set app as busy. 25 | return try { 26 | function() 27 | } finally { 28 | EspressoIdlingResource.decrement() // Set app as idle. 29 | } 30 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/utils/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.utils 2 | 3 | import android.animation.ObjectAnimator 4 | import android.content.Context 5 | import android.view.View 6 | import android.widget.ImageView 7 | import android.widget.TextView 8 | import com.artworkspace.storyapp.R 9 | import com.bumptech.glide.Glide 10 | import java.text.DateFormat 11 | import java.text.SimpleDateFormat 12 | import java.util.* 13 | 14 | /** 15 | * Extension function for set the ImageView's src with Glide library 16 | * 17 | * @param context Context 18 | * @param url Image's URL 19 | */ 20 | fun ImageView.setImageFromUrl(context: Context, url: String) { 21 | Glide 22 | .with(context) 23 | .load(url) 24 | .placeholder(R.drawable.image_loading_placeholder) 25 | .error(R.drawable.image_load_error) 26 | .into(this) 27 | } 28 | 29 | /** 30 | * Set TextView text attribute to locale date format 31 | * 32 | * @param timestamp Timestamp 33 | */ 34 | fun TextView.setLocalDateFormat(timestamp: String) { 35 | val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) 36 | val date = sdf.parse(timestamp) as Date 37 | 38 | val formattedDate = DateFormat.getDateInstance(DateFormat.FULL).format(date) 39 | this.text = formattedDate 40 | } 41 | 42 | /** 43 | * Animate visibility by transforming alpha value of a view 44 | * 45 | * @param isVisible View visibility 46 | * @param duration Animation duration, default 400 47 | */ 48 | fun View.animateVisibility(isVisible: Boolean, duration: Long = 400) { 49 | ObjectAnimator 50 | .ofFloat(this, View.ALPHA, if (isVisible) 1f else 0f) 51 | .setDuration(duration) 52 | .start() 53 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/utils/HiltTestActivity.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.utils 2 | 3 | import androidx.appcompat.app.AppCompatActivity 4 | import dagger.hilt.android.AndroidEntryPoint 5 | 6 | @AndroidEntryPoint 7 | class HiltTestActivity : AppCompatActivity() -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/utils/MediaUtility.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.utils 2 | 3 | import android.content.ContentResolver 4 | import android.content.Context 5 | import android.graphics.Bitmap 6 | import android.graphics.BitmapFactory 7 | import android.net.Uri 8 | import android.os.Environment 9 | import java.io.* 10 | import java.text.SimpleDateFormat 11 | import java.util.* 12 | 13 | object MediaUtility { 14 | private const val FILENAME_FORMAT = "dd-MMM-yyyy" 15 | 16 | private val timeStamp: String = SimpleDateFormat( 17 | FILENAME_FORMAT, 18 | Locale.US 19 | ).format(System.currentTimeMillis()) 20 | 21 | /** 22 | * Create image temporary file 23 | * 24 | * @param context 25 | */ 26 | fun createTempFile(context: Context): File { 27 | val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) 28 | return File.createTempFile(timeStamp, ".jpg", storageDir) 29 | } 30 | 31 | /** 32 | * Convert resource URI to file 33 | * 34 | * @param selectedImg Original resource URI 35 | * @param context 36 | * @return File 37 | */ 38 | fun uriToFile(selectedImg: Uri, context: Context): File { 39 | val contentResolver: ContentResolver = context.contentResolver 40 | val myFile = createTempFile(context) 41 | 42 | val inputStream = contentResolver.openInputStream(selectedImg) as InputStream 43 | val outputStream: OutputStream = FileOutputStream(myFile) 44 | val buf = ByteArray(1024) 45 | var len: Int 46 | while (inputStream.read(buf).also { len = it } > 0) outputStream.write(buf, 0, len) 47 | outputStream.close() 48 | inputStream.close() 49 | 50 | return myFile 51 | } 52 | 53 | /** 54 | * Compress image file size until under 1MB 55 | * 56 | * @param file Original file 57 | * @return File - Compressed file 58 | */ 59 | fun reduceFileImage(file: File): File { 60 | val bitmap = BitmapFactory.decodeFile(file.path) 61 | 62 | var compressQuality = 100 63 | var streamLength: Int 64 | 65 | // Repeat until the file size under 1MB 66 | do { 67 | val bmpStream = ByteArrayOutputStream() 68 | bitmap.compress(Bitmap.CompressFormat.JPEG, compressQuality, bmpStream) 69 | val bmpPicByteArray = bmpStream.toByteArray() 70 | streamLength = bmpPicByteArray.size 71 | compressQuality -= 5 72 | } while (streamLength > 1000000) 73 | 74 | bitmap.compress(Bitmap.CompressFormat.JPEG, compressQuality, FileOutputStream(file)) 75 | 76 | return file 77 | } 78 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/views/EmailEditText.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.views 2 | 3 | import android.content.Context 4 | import android.graphics.drawable.Drawable 5 | import android.text.Editable 6 | import android.text.InputType 7 | import android.text.TextWatcher 8 | import android.util.AttributeSet 9 | import android.util.Patterns 10 | import androidx.appcompat.widget.AppCompatEditText 11 | import androidx.core.content.ContextCompat 12 | import com.artworkspace.storyapp.R 13 | 14 | class EmailEditText : AppCompatEditText { 15 | 16 | private lateinit var emailIconDrawable: Drawable 17 | 18 | constructor(context: Context) : super(context) { 19 | init() 20 | } 21 | 22 | constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { 23 | init() 24 | } 25 | 26 | constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( 27 | context, 28 | attrs, 29 | defStyleAttr 30 | ) { 31 | init() 32 | } 33 | 34 | private fun init() { 35 | emailIconDrawable = 36 | ContextCompat.getDrawable(context, R.drawable.ic_baseline_email_24) as Drawable 37 | inputType = InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS 38 | compoundDrawablePadding = 16 39 | 40 | setHint(R.string.email) 41 | setAutofillHints(AUTOFILL_HINT_EMAIL_ADDRESS) 42 | setDrawable(emailIconDrawable) 43 | 44 | addTextChangedListener(object : TextWatcher { 45 | override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} 46 | override fun afterTextChanged(p0: Editable?) {} 47 | 48 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { 49 | // Email validation 50 | // Display error automatically if the email is not valid 51 | if (!s.isNullOrEmpty() && !Patterns.EMAIL_ADDRESS.matcher(s).matches()) 52 | error = context.getString(R.string.et_email_error_message) 53 | } 54 | }) 55 | } 56 | 57 | private fun setDrawable( 58 | start: Drawable? = null, 59 | top: Drawable? = null, 60 | end: Drawable? = null, 61 | bottom: Drawable? = null 62 | ) { 63 | setCompoundDrawablesWithIntrinsicBounds(start, top, end, bottom) 64 | } 65 | } -------------------------------------------------------------------------------- /app/src/main/java/com/artworkspace/storyapp/views/PasswordEditText.kt: -------------------------------------------------------------------------------- 1 | package com.artworkspace.storyapp.views 2 | 3 | import android.content.Context 4 | import android.graphics.Canvas 5 | import android.graphics.drawable.Drawable 6 | import android.text.Editable 7 | import android.text.InputType 8 | import android.text.TextWatcher 9 | import android.text.method.PasswordTransformationMethod 10 | import android.util.AttributeSet 11 | import androidx.appcompat.widget.AppCompatEditText 12 | import androidx.core.content.ContextCompat 13 | import com.artworkspace.storyapp.R 14 | 15 | class PasswordEditText : AppCompatEditText { 16 | 17 | private lateinit var passwordIconDrawable: Drawable 18 | 19 | constructor(context: Context) : super(context) { 20 | init() 21 | } 22 | 23 | constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { 24 | init() 25 | } 26 | 27 | constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( 28 | context, 29 | attrs, 30 | defStyleAttr 31 | ) { 32 | init() 33 | } 34 | 35 | override fun onDraw(canvas: Canvas?) { 36 | super.onDraw(canvas) 37 | transformationMethod = PasswordTransformationMethod.getInstance() 38 | } 39 | 40 | private fun init() { 41 | passwordIconDrawable = 42 | ContextCompat.getDrawable(context, R.drawable.ic_baseline_lock_24) as Drawable 43 | inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD 44 | compoundDrawablePadding = 16 45 | 46 | setHint(R.string.password) 47 | setAutofillHints(AUTOFILL_HINT_PASSWORD) 48 | setDrawable(passwordIconDrawable) 49 | 50 | addTextChangedListener(object : TextWatcher { 51 | override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {} 52 | override fun afterTextChanged(p0: Editable?) {} 53 | 54 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { 55 | // Password validation 56 | // Display error automatically if the password doesn't meet certain criteria 57 | if (!s.isNullOrEmpty() && s.length < 6) 58 | error = context.getString(R.string.et_password_error_message) 59 | } 60 | }) 61 | } 62 | 63 | private fun setDrawable( 64 | start: Drawable? = null, 65 | top: Drawable? = null, 66 | end: Drawable? = null, 67 | bottom: Drawable? = null 68 | ) { 69 | setCompoundDrawablesWithIntrinsicBounds(start, top, end, bottom) 70 | } 71 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/image_dicoding.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/drawable-v24/image_dicoding.webp -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/image_learning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/drawable-v24/image_learning.png -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/image_loading_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/drawable-v24/image_loading_placeholder.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_add_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_check_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_email_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_lock_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_logout_24.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_my_location_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_person_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_dashboard_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_home_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_notifications_black_24dp.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/image_load_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/drawable/image_load_error.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/splash_bg.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/font/product_sans_bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/font/product_sans_bold.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/product_sans_regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/font/product_sans_regular.ttf -------------------------------------------------------------------------------- /app/src/main/res/layout-land/fragment_login.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 17 | 18 | 28 | 29 | 38 | 39 | 49 | 50 | 60 | 61 | 69 | 70 |