├── .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 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
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 |
80 |
81 |
88 |
89 |
94 |
95 |
101 |
102 |
103 |
113 |
114 |
118 |
119 |
120 |
121 |
122 |
130 |
131 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_auth.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
20 |
21 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_detail_story.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
18 |
19 |
27 |
28 |
32 |
33 |
46 |
47 |
60 |
61 |
73 |
74 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
19 |
20 |
31 |
32 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_home.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
18 |
19 |
25 |
26 |
27 |
28 |
36 |
37 |
42 |
43 |
44 |
45 |
56 |
57 |
66 |
67 |
71 |
72 |
73 |
74 |
85 |
86 |
96 |
97 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_locations.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
18 |
19 |
25 |
26 |
27 |
28 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_login.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
17 |
18 |
28 |
29 |
38 |
39 |
50 |
51 |
61 |
62 |
71 |
72 |
83 |
84 |
93 |
94 |
99 |
100 |
106 |
107 |
108 |
118 |
119 |
123 |
124 |
125 |
126 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_register.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
17 |
18 |
28 |
29 |
39 |
40 |
51 |
52 |
61 |
62 |
75 |
76 |
84 |
85 |
95 |
96 |
105 |
106 |
111 |
112 |
118 |
119 |
120 |
121 |
131 |
132 |
136 |
137 |
138 |
139 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
18 |
19 |
25 |
26 |
27 |
28 |
47 |
48 |
66 |
67 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/layout_story_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
16 |
17 |
22 |
23 |
35 |
36 |
47 |
48 |
60 |
61 |
74 |
75 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/layout_story_loading.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
17 |
18 |
25 |
26 |
33 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/bottom_nav_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
9 |
13 |
14 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/create_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/menu/main_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
8 |
9 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/app/src/main/res/navigation/auth_nav_graph.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
22 |
23 |
28 |
38 |
39 |
--------------------------------------------------------------------------------
/app/src/main/res/navigation/mobile_navigation.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
14 |
19 |
20 |
25 |
--------------------------------------------------------------------------------
/app/src/main/res/raw/map_style.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "elementType": "geometry",
4 | "stylers": [
5 | {
6 | "color": "#1d2c4d"
7 | }
8 | ]
9 | },
10 | {
11 | "elementType": "labels.text.fill",
12 | "stylers": [
13 | {
14 | "color": "#8ec3b9"
15 | }
16 | ]
17 | },
18 | {
19 | "elementType": "labels.text.stroke",
20 | "stylers": [
21 | {
22 | "color": "#1a3646"
23 | }
24 | ]
25 | },
26 | {
27 | "featureType": "administrative.country",
28 | "elementType": "geometry.stroke",
29 | "stylers": [
30 | {
31 | "color": "#4b6878"
32 | }
33 | ]
34 | },
35 | {
36 | "featureType": "administrative.land_parcel",
37 | "elementType": "labels.text.fill",
38 | "stylers": [
39 | {
40 | "color": "#64779e"
41 | }
42 | ]
43 | },
44 | {
45 | "featureType": "administrative.province",
46 | "elementType": "geometry.stroke",
47 | "stylers": [
48 | {
49 | "color": "#4b6878"
50 | }
51 | ]
52 | },
53 | {
54 | "featureType": "landscape.man_made",
55 | "elementType": "geometry.stroke",
56 | "stylers": [
57 | {
58 | "color": "#334e87"
59 | }
60 | ]
61 | },
62 | {
63 | "featureType": "landscape.natural",
64 | "elementType": "geometry",
65 | "stylers": [
66 | {
67 | "color": "#023e58"
68 | }
69 | ]
70 | },
71 | {
72 | "featureType": "poi",
73 | "elementType": "geometry",
74 | "stylers": [
75 | {
76 | "color": "#283d6a"
77 | }
78 | ]
79 | },
80 | {
81 | "featureType": "poi",
82 | "elementType": "labels.text.fill",
83 | "stylers": [
84 | {
85 | "color": "#6f9ba5"
86 | }
87 | ]
88 | },
89 | {
90 | "featureType": "poi",
91 | "elementType": "labels.text.stroke",
92 | "stylers": [
93 | {
94 | "color": "#1d2c4d"
95 | }
96 | ]
97 | },
98 | {
99 | "featureType": "poi.park",
100 | "elementType": "geometry.fill",
101 | "stylers": [
102 | {
103 | "color": "#023e58"
104 | }
105 | ]
106 | },
107 | {
108 | "featureType": "poi.park",
109 | "elementType": "labels.text.fill",
110 | "stylers": [
111 | {
112 | "color": "#3C7680"
113 | }
114 | ]
115 | },
116 | {
117 | "featureType": "road",
118 | "elementType": "geometry",
119 | "stylers": [
120 | {
121 | "color": "#304a7d"
122 | }
123 | ]
124 | },
125 | {
126 | "featureType": "road",
127 | "elementType": "labels.text.fill",
128 | "stylers": [
129 | {
130 | "color": "#98a5be"
131 | }
132 | ]
133 | },
134 | {
135 | "featureType": "road",
136 | "elementType": "labels.text.stroke",
137 | "stylers": [
138 | {
139 | "color": "#1d2c4d"
140 | }
141 | ]
142 | },
143 | {
144 | "featureType": "road.highway",
145 | "elementType": "geometry",
146 | "stylers": [
147 | {
148 | "color": "#2c6675"
149 | }
150 | ]
151 | },
152 | {
153 | "featureType": "road.highway",
154 | "elementType": "geometry.stroke",
155 | "stylers": [
156 | {
157 | "color": "#255763"
158 | }
159 | ]
160 | },
161 | {
162 | "featureType": "road.highway",
163 | "elementType": "labels.text.fill",
164 | "stylers": [
165 | {
166 | "color": "#b0d5ce"
167 | }
168 | ]
169 | },
170 | {
171 | "featureType": "road.highway",
172 | "elementType": "labels.text.stroke",
173 | "stylers": [
174 | {
175 | "color": "#023e58"
176 | }
177 | ]
178 | },
179 | {
180 | "featureType": "transit",
181 | "elementType": "labels.text.fill",
182 | "stylers": [
183 | {
184 | "color": "#98a5be"
185 | }
186 | ]
187 | },
188 | {
189 | "featureType": "transit",
190 | "elementType": "labels.text.stroke",
191 | "stylers": [
192 | {
193 | "color": "#1d2c4d"
194 | }
195 | ]
196 | },
197 | {
198 | "featureType": "transit.line",
199 | "elementType": "geometry.fill",
200 | "stylers": [
201 | {
202 | "color": "#283d6a"
203 | }
204 | ]
205 | },
206 | {
207 | "featureType": "transit.station",
208 | "elementType": "geometry",
209 | "stylers": [
210 | {
211 | "color": "#3a4762"
212 | }
213 | ]
214 | },
215 | {
216 | "featureType": "water",
217 | "elementType": "geometry",
218 | "stylers": [
219 | {
220 | "color": "#0e1626"
221 | }
222 | ]
223 | },
224 | {
225 | "featureType": "water",
226 | "elementType": "labels.text.fill",
227 | "stylers": [
228 | {
229 | "color": "#4e6d70"
230 | }
231 | ]
232 | }
233 | ]
--------------------------------------------------------------------------------
/app/src/main/res/values-in-rID/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Pengguna baru Story?
4 | Daftar
5 | Sudah memiliki akun?
6 | Masuk
7 | Tekan kembali untuk keluar
8 | Seseorang dengan kertas
9 | Halo, mari masuk
10 | Dapatkan seluruh cerita pengalaman siswa selama belajar di Dicoding, dan kamu juga dapat membagikan ceritamu ✍️
11 | Email
12 | Kata sandi
13 | Ayo buat akun
14 | Baru di aplikasi ini? Ayo buat akun baru untuk kamu✨
15 | Nama lengkap
16 | Alamat email tidak sesuai
17 | Kata sandi harus lebih dari 6 karakter
18 | Registrasi gagal, harap coba kembali
19 | Registrasi berhasil
20 | Login gagal, silakan coba kembali
21 | Login berhasil
22 | Gambar cerita
23 | Cerita
24 | Keluar
25 | Buat cerita
26 | Terjadi kesalahan
27 | Gambar cerita
28 | Cerita %s
29 | Tidak ada data
30 | Cerita tidak ditemukan
31 | Tambahkan cerita
32 | Unggah cerita
33 | Seseorang dengan komputer
34 | Pilih gambar melalui
35 | Kamera
36 | Galeri
37 | Tuliskan sebuah deskripsi
38 | Tolong masukkan deskripsi
39 | Tolong pilih sebuah gambar
40 | Unggah cerita gagal
41 | Cerita berhasil diupload
42 | Pengaturan bahasa
43 | Beranda
44 | Pengaturan
45 | Lokasi
46 | Bagikan lokasi
47 | Izin lokasi ditolak
48 | Ubah pengaturan
49 | Coba lagi
50 | Tidak dapat mengambil cerita
51 | Apakah kamu yakin?
52 | Setelah logout, anda harus login kembali
53 | Batal
54 | Berhasil logout
55 | Tolong aktifkan layanan lokasi
56 |
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #0165FF
4 | #2D3E50
5 | #212F3E
6 | #EFF1F3
7 | #4D2D3E50
8 |
9 | #F43F5E
10 | #C32D46
11 | #F9F9F9
12 |
13 | #FF000000
14 | #FFFFFFFF
15 |
16 | #2D3E52
17 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Story App
3 | New to Story?
4 | Register
5 | Already have an account?
6 | Login
7 | Press again to exit
8 | Person with paper illustration
9 | Hello, let\'s sign in
10 | Explore all of student\'s experience and story when learning in Dicoding, and of course you can also share your story too✍️
11 | Email
12 | Password
13 | Let\'s create an account
14 | New to this application? \nLet\'s create an account for you ✨
15 | Full name
16 | Email address is not valid
17 | Passwords must be at least 6 characters
18 | Registration failed, please try again.
19 | Registration success
20 | Login failed, please try again.
21 | Login success
22 | Fikri Yusrihan
23 | Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsum dolor sit amet Lorem ipsu....
24 | April 6, 2022
25 | Story\'s illustration
26 | Stories
27 | Logout
28 | Create story
29 | An error occurred
30 | Story illustration
31 | %s\'s story
32 | No data
33 | Stories not found
34 | Add Story
35 | Upload Story
36 | Person with computer
37 | Select image from
38 | Camera
39 | Gallery
40 | Write a description
41 | Please fill the description field
42 | Please select an image
43 | Story upload failed
44 | Story uploaded
45 | Language Setting
46 | HomeActivity
47 | Home
48 | Setting
49 | Location
50 | Share location
51 | Location permission not allowed
52 | Change Setting
53 | Retry
54 | Unable to fetch stories data
55 | Are you sure?
56 | You need to login again after logout
57 | Cancel
58 | Logged out successfully
59 | Please activate your location services
60 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
20 |
21 |
25 |
26 |
29 |
30 |
34 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/file_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/data/AuthRepositoryTest.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.data
2 |
3 | import com.artworkspace.storyapp.data.local.AuthPreferencesDataSource
4 | import com.artworkspace.storyapp.data.remote.retrofit.ApiService
5 | import com.artworkspace.storyapp.utils.CoroutinesTestRule
6 | import com.artworkspace.storyapp.utils.DataDummy
7 | import kotlinx.coroutines.ExperimentalCoroutinesApi
8 | import kotlinx.coroutines.flow.flowOf
9 | import kotlinx.coroutines.test.runTest
10 | import org.junit.Assert
11 | import org.junit.Before
12 | import org.junit.Rule
13 | import org.junit.Test
14 | import org.junit.runner.RunWith
15 | import org.mockito.Mock
16 | import org.mockito.Mockito
17 | import org.mockito.Mockito.`when`
18 | import org.mockito.junit.MockitoJUnitRunner
19 |
20 | @ExperimentalCoroutinesApi
21 | @RunWith(MockitoJUnitRunner::class)
22 | class AuthRepositoryTest {
23 |
24 | @get:Rule
25 | var coroutinesTestRule = CoroutinesTestRule()
26 |
27 | @Mock
28 | private lateinit var preferencesDataSource: AuthPreferencesDataSource
29 |
30 | @Mock
31 | private lateinit var apiService: ApiService
32 | private lateinit var authRepository: AuthRepository
33 |
34 | private val dummyName = "Name"
35 | private val dummyEmail = "mail@mail.com"
36 | private val dummyPassword = "password"
37 | private val dummyToken = "authentication_token"
38 |
39 | @Before
40 | fun setup() {
41 | authRepository = AuthRepository(apiService, preferencesDataSource)
42 | }
43 |
44 | @Test
45 | fun `User login successfully`(): Unit = runTest {
46 | val expectedResponse = DataDummy.generateDummyLoginResponse()
47 |
48 | `when`(apiService.userLogin(dummyEmail, dummyPassword)).thenReturn(expectedResponse)
49 |
50 | authRepository.userLogin(dummyEmail, dummyPassword).collect { result ->
51 | Assert.assertTrue(result.isSuccess)
52 | Assert.assertFalse(result.isFailure)
53 |
54 | result.onSuccess { actualResponse ->
55 | Assert.assertNotNull(actualResponse)
56 | Assert.assertEquals(expectedResponse, actualResponse)
57 | }
58 |
59 | result.onFailure {
60 | Assert.assertNull(it)
61 | }
62 | }
63 |
64 | }
65 |
66 | @Test
67 | fun `User login failed - throw exception`(): Unit = runTest {
68 | `when`(apiService.userLogin(dummyEmail, dummyPassword)).then { throw Exception() }
69 |
70 | authRepository.userLogin(dummyEmail, dummyPassword).collect { result ->
71 | Assert.assertFalse(result.isSuccess)
72 | Assert.assertTrue(result.isFailure)
73 |
74 | result.onFailure {
75 | Assert.assertNotNull(it)
76 | }
77 | }
78 | }
79 |
80 | @Test
81 | fun `User register successfully`(): Unit = runTest {
82 | val expectedResponse = DataDummy.generateDummyRegisterResponse()
83 |
84 | `when`(apiService.userRegister(dummyName, dummyEmail, dummyPassword)).thenReturn(
85 | expectedResponse
86 | )
87 |
88 | authRepository.userRegister(dummyName, dummyEmail, dummyPassword).collect { result ->
89 | Assert.assertTrue(result.isSuccess)
90 | Assert.assertFalse(result.isFailure)
91 |
92 | result.onSuccess { actualResponse ->
93 | Assert.assertNotNull(actualResponse)
94 | Assert.assertEquals(expectedResponse, actualResponse)
95 | }
96 |
97 | result.onFailure {
98 | Assert.assertNull(it)
99 | }
100 | }
101 | }
102 |
103 | @Test
104 | fun `User register failed - throw exception`(): Unit = runTest {
105 | `when`(
106 | apiService.userRegister(
107 | dummyName,
108 | dummyEmail,
109 | dummyPassword
110 | )
111 | ).then { throw Exception() }
112 |
113 | authRepository.userRegister(dummyName, dummyEmail, dummyPassword).collect { result ->
114 | Assert.assertFalse(result.isSuccess)
115 | Assert.assertTrue(result.isFailure)
116 |
117 | result.onFailure {
118 | Assert.assertNotNull(it)
119 | }
120 | }
121 | }
122 |
123 | @Test
124 | fun `Save auth token successfully`() = runTest {
125 | authRepository.saveAuthToken(dummyToken)
126 | Mockito.verify(preferencesDataSource).saveAuthToken(dummyToken)
127 | }
128 |
129 | @Test
130 | fun `Get authentication token successfully`() = runTest {
131 | val expectedToken = flowOf(dummyToken)
132 |
133 | `when`(preferencesDataSource.getAuthToken()).thenReturn(expectedToken)
134 |
135 | authRepository.getAuthToken().collect { actualToken ->
136 | Assert.assertNotNull(actualToken)
137 | Assert.assertEquals(dummyToken, actualToken)
138 | }
139 |
140 | Mockito.verify(preferencesDataSource).getAuthToken()
141 | }
142 |
143 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/ui/create/CreateViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.ui.create
2 |
3 | import androidx.paging.ExperimentalPagingApi
4 | import com.artworkspace.storyapp.data.AuthRepository
5 | import com.artworkspace.storyapp.data.StoryRepository
6 | import com.artworkspace.storyapp.data.remote.response.FileUploadResponse
7 | import com.artworkspace.storyapp.utils.DataDummy
8 | import kotlinx.coroutines.ExperimentalCoroutinesApi
9 | import kotlinx.coroutines.flow.Flow
10 | import kotlinx.coroutines.flow.flowOf
11 | import kotlinx.coroutines.test.runTest
12 | import org.junit.Assert
13 | import org.junit.Before
14 | import org.junit.Test
15 | import org.junit.runner.RunWith
16 | import org.mockito.Mock
17 | import org.mockito.Mockito
18 | import org.mockito.Mockito.`when`
19 | import org.mockito.junit.MockitoJUnitRunner
20 |
21 | @ExperimentalPagingApi
22 | @ExperimentalCoroutinesApi
23 | @RunWith(MockitoJUnitRunner::class)
24 | class CreateViewModelTest {
25 |
26 | @Mock
27 | private lateinit var authRepository: AuthRepository
28 |
29 | @Mock
30 | private lateinit var storyRepository: StoryRepository
31 | private lateinit var createViewModel: CreateViewModel
32 |
33 | private val dummyToken = "authentication_token"
34 | private val dummyUploadResponse = DataDummy.generateDummyFileUploadResponse()
35 | private val dummyMultipart = DataDummy.generateDummyMultipartFile()
36 | private val dummyDescription = DataDummy.generateDummyRequestBody()
37 |
38 | @Before
39 | fun setup() {
40 | createViewModel = CreateViewModel(authRepository, storyRepository)
41 | }
42 |
43 | @Test
44 | fun `Get authentication token successfully`() = runTest {
45 | val expectedToken = flowOf(dummyToken)
46 |
47 | `when`(createViewModel.getAuthToken()).thenReturn(expectedToken)
48 |
49 | createViewModel.getAuthToken().collect { actualToken ->
50 | Assert.assertNotNull(actualToken)
51 | Assert.assertEquals(dummyToken, actualToken)
52 | }
53 |
54 | Mockito.verify(authRepository).getAuthToken()
55 | Mockito.verifyNoInteractions(storyRepository)
56 | }
57 |
58 | @Test
59 | fun `Get authentication token successfully but null`() = runTest {
60 | val expectedToken = flowOf(null)
61 |
62 | `when`(createViewModel.getAuthToken()).thenReturn(expectedToken)
63 |
64 | createViewModel.getAuthToken().collect { actualToken ->
65 | Assert.assertNull(actualToken)
66 | }
67 |
68 | Mockito.verify(authRepository).getAuthToken()
69 | Mockito.verifyNoInteractions(storyRepository)
70 | }
71 |
72 | @Test
73 | fun `Upload file successfully`() = runTest {
74 | val expectedResponse = flowOf(Result.success(dummyUploadResponse))
75 |
76 | `when`(
77 | createViewModel.uploadImage(
78 | dummyToken,
79 | dummyMultipart,
80 | dummyDescription,
81 | null,
82 | null
83 | )
84 | ).thenReturn(expectedResponse)
85 |
86 | createViewModel.uploadImage(dummyToken, dummyMultipart, dummyDescription, null, null)
87 | .collect { result ->
88 |
89 | Assert.assertTrue(result.isSuccess)
90 | Assert.assertFalse(result.isFailure)
91 |
92 | result.onSuccess { actualResponse ->
93 | Assert.assertNotNull(actualResponse)
94 | Assert.assertSame(dummyUploadResponse, actualResponse)
95 | }
96 | }
97 |
98 | Mockito.verify(storyRepository)
99 | .uploadImage(dummyToken, dummyMultipart, dummyDescription, null, null)
100 | Mockito.verifyNoInteractions(authRepository)
101 | }
102 |
103 | @Test
104 | fun `Upload file failed`(): Unit = runTest {
105 | val expectedResponse: Flow> =
106 | flowOf(Result.failure(Exception("failed")))
107 |
108 | `when`(
109 | createViewModel.uploadImage(
110 | dummyToken,
111 | dummyMultipart,
112 | dummyDescription,
113 | null,
114 | null
115 | )
116 | ).thenReturn(expectedResponse)
117 |
118 | createViewModel.uploadImage(dummyToken, dummyMultipart, dummyDescription, null, null)
119 | .collect { result ->
120 | Assert.assertFalse(result.isSuccess)
121 | Assert.assertTrue(result.isFailure)
122 |
123 | result.onFailure { actualResponse ->
124 | Assert.assertNotNull(actualResponse)
125 | }
126 | }
127 |
128 | }
129 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/ui/home/HomeViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.ui.home
2 |
3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule
4 | import androidx.lifecycle.MutableLiveData
5 | import androidx.paging.AsyncPagingDataDiffer
6 | import androidx.paging.ExperimentalPagingApi
7 | import androidx.paging.PagingData
8 | import androidx.recyclerview.widget.ListUpdateCallback
9 | import com.artworkspace.storyapp.adapter.StoryListAdapter
10 | import com.artworkspace.storyapp.data.local.entity.Story
11 | import com.artworkspace.storyapp.utils.CoroutinesTestRule
12 | import com.artworkspace.storyapp.utils.DataDummy
13 | import com.artworkspace.storyapp.utils.PagedTestDataSource
14 | import com.artworkspace.storyapp.utils.getOrAwaitValue
15 | import kotlinx.coroutines.ExperimentalCoroutinesApi
16 | import kotlinx.coroutines.test.advanceUntilIdle
17 | import kotlinx.coroutines.test.runTest
18 | import org.junit.Assert
19 | import org.junit.Rule
20 | import org.junit.Test
21 | import org.junit.runner.RunWith
22 | import org.mockito.Mock
23 | import org.mockito.Mockito
24 | import org.mockito.Mockito.`when`
25 | import org.mockito.junit.MockitoJUnitRunner
26 |
27 | @ExperimentalCoroutinesApi
28 | @ExperimentalPagingApi
29 | @RunWith(MockitoJUnitRunner::class)
30 | class HomeViewModelTest {
31 | @get:Rule
32 | var instantExecutorRule = InstantTaskExecutorRule()
33 |
34 | @get:Rule
35 | var coroutinesTestRule = CoroutinesTestRule()
36 |
37 | @Mock
38 | private lateinit var homeViewModel: HomeViewModel
39 |
40 | private val dummyToken = "authentication_token"
41 |
42 | @Test
43 | fun `Get all stories successfully`() = runTest {
44 | val dummyStories = DataDummy.generateDummyListStory()
45 | val data = PagedTestDataSource.snapshot(dummyStories)
46 |
47 | val stories = MutableLiveData>()
48 | stories.value = data
49 |
50 | `when`(homeViewModel.getAllStories(dummyToken)).thenReturn(stories)
51 |
52 | val actualStories = homeViewModel.getAllStories(dummyToken).getOrAwaitValue()
53 | val differ = AsyncPagingDataDiffer(
54 | diffCallback = StoryListAdapter.DiffCallback,
55 | updateCallback = noopListUpdateCallback,
56 | mainDispatcher = coroutinesTestRule.testDispatcher,
57 | workerDispatcher = coroutinesTestRule.testDispatcher
58 | )
59 | differ.submitData(actualStories)
60 |
61 | advanceUntilIdle()
62 |
63 | Mockito.verify(homeViewModel).getAllStories(dummyToken)
64 | Assert.assertNotNull(differ.snapshot())
65 | Assert.assertEquals(dummyStories.size, differ.snapshot().size)
66 | }
67 |
68 |
69 | private val noopListUpdateCallback = object : ListUpdateCallback {
70 | override fun onInserted(position: Int, count: Int) {}
71 | override fun onRemoved(position: Int, count: Int) {}
72 | override fun onMoved(fromPosition: Int, toPosition: Int) {}
73 | override fun onChanged(position: Int, count: Int, payload: Any?) {}
74 | }
75 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/ui/location/LocationViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.ui.location
2 |
3 | import androidx.paging.ExperimentalPagingApi
4 | import com.artworkspace.storyapp.data.StoryRepository
5 | import com.artworkspace.storyapp.data.remote.response.StoriesResponse
6 | import com.artworkspace.storyapp.utils.DataDummy
7 | import kotlinx.coroutines.ExperimentalCoroutinesApi
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.flowOf
10 | import kotlinx.coroutines.test.runTest
11 | import org.junit.Assert
12 | import org.junit.Before
13 | import org.junit.Test
14 | import org.junit.runner.RunWith
15 | import org.mockito.Mock
16 | import org.mockito.Mockito
17 | import org.mockito.Mockito.`when`
18 | import org.mockito.junit.MockitoJUnitRunner
19 |
20 | @ExperimentalPagingApi
21 | @ExperimentalCoroutinesApi
22 | @RunWith(MockitoJUnitRunner::class)
23 | class LocationViewModelTest {
24 |
25 | @Mock
26 | private lateinit var storyRepository: StoryRepository
27 | private lateinit var locationViewModel: LocationViewModel
28 |
29 | private val dummyStoriesResponse = DataDummy.generateDummyStoriesResponse()
30 | private val dummyToken = "AUTH_TOKEN"
31 |
32 | @Before
33 | fun setup() {
34 | locationViewModel = LocationViewModel(storyRepository)
35 | }
36 |
37 | @Test
38 | fun `Get story with location successfully - result success`(): Unit = runTest {
39 |
40 | val expectedResponse = flowOf(Result.success(dummyStoriesResponse))
41 |
42 | `when`(locationViewModel.getAllStories(dummyToken)).thenReturn(expectedResponse)
43 |
44 | locationViewModel.getAllStories(dummyToken).collect { actualResponse ->
45 |
46 | Assert.assertTrue(actualResponse.isSuccess)
47 | Assert.assertFalse(actualResponse.isFailure)
48 |
49 | actualResponse.onSuccess { storiesResponse ->
50 | Assert.assertNotNull(storiesResponse)
51 | Assert.assertSame(storiesResponse, dummyStoriesResponse)
52 | }
53 | }
54 |
55 | Mockito.verify(storyRepository).getAllStoriesWithLocation(dummyToken)
56 | }
57 |
58 | @Test
59 | fun `Get story with location failed - result failure with exception`(): Unit = runTest {
60 |
61 | val expectedResponse: Flow> =
62 | flowOf(Result.failure(Exception("Failed")))
63 |
64 | `when`(locationViewModel.getAllStories(dummyToken)).thenReturn(expectedResponse)
65 |
66 | locationViewModel.getAllStories(dummyToken).collect { actualResponse ->
67 |
68 | Assert.assertFalse(actualResponse.isSuccess)
69 | Assert.assertTrue(actualResponse.isFailure)
70 |
71 | actualResponse.onFailure {
72 | Assert.assertNotNull(it)
73 | }
74 | }
75 |
76 | Mockito.verify(storyRepository).getAllStoriesWithLocation(dummyToken)
77 | }
78 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/ui/login/LoginViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.ui.login
2 |
3 | import com.artworkspace.storyapp.data.AuthRepository
4 | import com.artworkspace.storyapp.data.remote.response.LoginResponse
5 | import com.artworkspace.storyapp.utils.CoroutinesTestRule
6 | import com.artworkspace.storyapp.utils.DataDummy
7 | import kotlinx.coroutines.ExperimentalCoroutinesApi
8 | import kotlinx.coroutines.flow.Flow
9 | import kotlinx.coroutines.flow.flow
10 | import kotlinx.coroutines.flow.flowOf
11 | import kotlinx.coroutines.test.runTest
12 | import org.junit.Assert
13 | import org.junit.Before
14 | import org.junit.Rule
15 | import org.junit.Test
16 | import org.junit.runner.RunWith
17 | import org.mockito.Mock
18 | import org.mockito.Mockito
19 | import org.mockito.Mockito.`when`
20 | import org.mockito.junit.MockitoJUnitRunner
21 |
22 | @ExperimentalCoroutinesApi
23 | @RunWith(MockitoJUnitRunner::class)
24 | class LoginViewModelTest {
25 |
26 | @get:Rule
27 | var coroutinesTestRule = CoroutinesTestRule()
28 |
29 | @Mock
30 | private lateinit var authRepository: AuthRepository
31 | private lateinit var loginViewModel: LoginViewModel
32 |
33 | private val dummyLoginResponse = DataDummy.generateDummyLoginResponse()
34 | private val dummyToken = "authentication_token"
35 | private val dummyEmail = "email@mail.com"
36 | private val dummyPassword = "password"
37 |
38 | @Before
39 | fun setup() {
40 | loginViewModel = LoginViewModel(authRepository)
41 | }
42 |
43 | @Test
44 | fun `Login successfully - result success`(): Unit = runTest {
45 | val expectedResponse = flow {
46 | emit(Result.success(dummyLoginResponse))
47 | }
48 |
49 | `when`(loginViewModel.userLogin(dummyEmail, dummyPassword)).thenReturn(expectedResponse)
50 |
51 | loginViewModel.userLogin(dummyEmail, dummyPassword).collect { result ->
52 |
53 | Assert.assertTrue(result.isSuccess)
54 | Assert.assertFalse(result.isFailure)
55 |
56 | result.onSuccess { actualResponse ->
57 | Assert.assertNotNull(actualResponse)
58 | Assert.assertSame(dummyLoginResponse, actualResponse)
59 | }
60 | }
61 |
62 | Mockito.verify(authRepository).userLogin(dummyEmail, dummyPassword)
63 | }
64 |
65 | @Test
66 | fun `Login failed - result failure with exception`(): Unit = runTest {
67 | val expectedResponse: Flow> =
68 | flowOf(Result.failure(Exception("login failed")))
69 |
70 | `when`(loginViewModel.userLogin(dummyEmail, dummyPassword)).thenReturn(expectedResponse)
71 |
72 | loginViewModel.userLogin(dummyEmail, dummyPassword).collect { result ->
73 |
74 | Assert.assertFalse(result.isSuccess)
75 | Assert.assertTrue(result.isFailure)
76 |
77 | result.onFailure {
78 | Assert.assertNotNull(it)
79 | }
80 | }
81 |
82 | Mockito.verify(authRepository).userLogin(dummyEmail, dummyPassword)
83 | }
84 |
85 | @Test
86 | fun `Save authentication token successfully`(): Unit = runTest {
87 | loginViewModel.saveAuthToken(dummyToken)
88 | Mockito.verify(authRepository).saveAuthToken(dummyToken)
89 | }
90 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/ui/register/RegisterViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.ui.register
2 |
3 | import com.artworkspace.storyapp.data.AuthRepository
4 | import com.artworkspace.storyapp.data.remote.response.RegisterResponse
5 | import com.artworkspace.storyapp.utils.DataDummy
6 | import kotlinx.coroutines.ExperimentalCoroutinesApi
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlinx.coroutines.flow.flowOf
9 | import kotlinx.coroutines.test.runTest
10 | import org.junit.Assert
11 | import org.junit.Before
12 | import org.junit.Test
13 | import org.junit.runner.RunWith
14 | import org.mockito.Mock
15 | import org.mockito.Mockito
16 | import org.mockito.Mockito.`when`
17 | import org.mockito.junit.MockitoJUnitRunner
18 |
19 | @ExperimentalCoroutinesApi
20 | @RunWith(MockitoJUnitRunner::class)
21 | class RegisterViewModelTest {
22 |
23 | @Mock
24 | private lateinit var authRepository: AuthRepository
25 | private lateinit var registerViewModel: RegisterViewModel
26 |
27 | private val dummyRegisterResponse = DataDummy.generateDummyRegisterResponse()
28 | private val dummyName = "Full Name"
29 | private val dummyEmail = "email@mail.com"
30 | private val dummyPassword = "password"
31 |
32 | @Before
33 | fun setup() {
34 | registerViewModel = RegisterViewModel(authRepository)
35 | }
36 |
37 | @Test
38 | fun `Registration successfully - result success`(): Unit = runTest {
39 | val expectedResponse = flowOf(Result.success(dummyRegisterResponse))
40 |
41 | `when`(registerViewModel.userRegister(dummyName, dummyEmail, dummyPassword)).thenReturn(
42 | expectedResponse
43 | )
44 |
45 | registerViewModel.userRegister(dummyName, dummyEmail, dummyPassword).collect { response ->
46 |
47 | Assert.assertTrue(response.isSuccess)
48 | Assert.assertFalse(response.isFailure)
49 |
50 | response.onSuccess { actualResponse ->
51 | Assert.assertNotNull(actualResponse)
52 | Assert.assertSame(dummyRegisterResponse, actualResponse)
53 | }
54 | }
55 |
56 | Mockito.verify(authRepository).userRegister(dummyName, dummyEmail, dummyPassword)
57 | }
58 |
59 | @Test
60 | fun `Registration failed - result with exception`(): Unit = runTest {
61 | val expectedResponse: Flow> =
62 | flowOf(Result.failure(Exception("failed")))
63 |
64 | `when`(registerViewModel.userRegister(dummyName, dummyEmail, dummyPassword)).thenReturn(
65 | expectedResponse
66 | )
67 |
68 | registerViewModel.userRegister(dummyName, dummyEmail, dummyPassword).collect { response ->
69 |
70 | Assert.assertFalse(response.isSuccess)
71 | Assert.assertTrue(response.isFailure)
72 |
73 | response.onFailure {
74 | Assert.assertNotNull(it)
75 | }
76 | }
77 | }
78 |
79 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/ui/setting/SettingViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.ui.setting
2 |
3 | import com.artworkspace.storyapp.data.AuthRepository
4 | import com.artworkspace.storyapp.utils.CoroutinesTestRule
5 | import kotlinx.coroutines.ExperimentalCoroutinesApi
6 | import kotlinx.coroutines.test.runTest
7 | import org.junit.Before
8 | import org.junit.Rule
9 | import org.junit.Test
10 | import org.junit.runner.RunWith
11 | import org.mockito.Mock
12 | import org.mockito.Mockito
13 | import org.mockito.junit.MockitoJUnitRunner
14 |
15 | @ExperimentalCoroutinesApi
16 | @RunWith(MockitoJUnitRunner::class)
17 | class SettingViewModelTest {
18 |
19 | @get:Rule
20 | var coroutinesTestRule = CoroutinesTestRule()
21 |
22 | @Mock
23 | private lateinit var authRepository: AuthRepository
24 | private lateinit var settingViewModel: SettingViewModel
25 |
26 | private val dummyToken = "authentication_token"
27 |
28 | @Before
29 | fun setup() {
30 | settingViewModel = SettingViewModel(authRepository)
31 | }
32 |
33 | @Test
34 | fun `Save authentication token successfully`(): Unit = runTest {
35 | settingViewModel.saveAuthToken(dummyToken)
36 | Mockito.verify(authRepository).saveAuthToken(dummyToken)
37 | }
38 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/ui/splash/SplashViewModelTest.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.ui.splash
2 |
3 | import com.artworkspace.storyapp.data.AuthRepository
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.flow.flowOf
6 | import kotlinx.coroutines.test.runTest
7 | import org.junit.Assert
8 | import org.junit.Before
9 | import org.junit.Test
10 | import org.junit.runner.RunWith
11 | import org.mockito.Mock
12 | import org.mockito.Mockito
13 | import org.mockito.Mockito.`when`
14 | import org.mockito.junit.MockitoJUnitRunner
15 |
16 | @ExperimentalCoroutinesApi
17 | @RunWith(MockitoJUnitRunner::class)
18 | class SplashViewModelTest {
19 |
20 |
21 | @Mock
22 | private lateinit var authRepository: AuthRepository
23 | private lateinit var splashViewModel: SplashViewModel
24 |
25 | private val dummyToken = "authentication_token"
26 |
27 | @Before
28 | fun setup() {
29 | splashViewModel = SplashViewModel(authRepository)
30 | }
31 |
32 | @Test
33 | fun `Get authentication token successfully`() = runTest {
34 | val expectedToken = flowOf(dummyToken)
35 |
36 | `when`(splashViewModel.getAuthToken()).thenReturn(expectedToken)
37 |
38 | splashViewModel.getAuthToken().collect { actualToken ->
39 | Assert.assertNotNull(actualToken)
40 | Assert.assertEquals(dummyToken, actualToken)
41 | }
42 |
43 | Mockito.verify(authRepository).getAuthToken()
44 | }
45 |
46 | @Test
47 | fun `Get authentication token empty`() = runTest {
48 | val expectedToken = flowOf(null)
49 |
50 | `when`(splashViewModel.getAuthToken()).thenReturn(expectedToken)
51 |
52 | splashViewModel.getAuthToken().collect { actualToken ->
53 | Assert.assertNull(actualToken)
54 | }
55 |
56 | Mockito.verify(authRepository).getAuthToken()
57 | }
58 |
59 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/utils/CoroutineTestRule.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.utils
2 |
3 | import kotlinx.coroutines.Dispatchers
4 | import kotlinx.coroutines.ExperimentalCoroutinesApi
5 | import kotlinx.coroutines.test.TestDispatcher
6 | import kotlinx.coroutines.test.UnconfinedTestDispatcher
7 | import kotlinx.coroutines.test.resetMain
8 | import kotlinx.coroutines.test.setMain
9 | import org.junit.rules.TestWatcher
10 | import org.junit.runner.Description
11 |
12 | @ExperimentalCoroutinesApi
13 | class CoroutinesTestRule(
14 | val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
15 | ) : TestWatcher() {
16 |
17 | override fun starting(description: Description?) {
18 | super.starting(description)
19 | Dispatchers.setMain(testDispatcher)
20 | }
21 |
22 | override fun finished(description: Description?) {
23 | super.finished(description)
24 | Dispatchers.resetMain()
25 | }
26 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/utils/LiveDataTestUtil.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.utils
2 |
3 | import androidx.annotation.VisibleForTesting
4 | import androidx.lifecycle.LiveData
5 | import androidx.lifecycle.Observer
6 | import java.util.concurrent.CountDownLatch
7 | import java.util.concurrent.TimeUnit
8 | import java.util.concurrent.TimeoutException
9 |
10 | @VisibleForTesting(otherwise = VisibleForTesting.NONE)
11 | fun LiveData.getOrAwaitValue(
12 | time: Long = 2,
13 | timeUnit: TimeUnit = TimeUnit.SECONDS,
14 | afterObserve: () -> Unit = {}
15 | ): T {
16 | var data: T? = null
17 | val latch = CountDownLatch(1)
18 | val observer = object : Observer {
19 | override fun onChanged(o: T?) {
20 | data = o
21 | latch.countDown()
22 | this@getOrAwaitValue.removeObserver(this)
23 | }
24 | }
25 | this.observeForever(observer)
26 |
27 | try {
28 | afterObserve.invoke()
29 |
30 | // Don't wait indefinitely if the LiveData is not set.
31 | if (!latch.await(time, timeUnit)) {
32 | throw TimeoutException("LiveData value was never set.")
33 | }
34 |
35 | } finally {
36 | this.removeObserver(observer)
37 | }
38 |
39 | @Suppress("UNCHECKED_CAST")
40 | return data as T
41 | }
--------------------------------------------------------------------------------
/app/src/test/java/com/artworkspace/storyapp/utils/PagedTestDataSource.kt:
--------------------------------------------------------------------------------
1 | package com.artworkspace.storyapp.utils
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.paging.PagingData
5 | import androidx.paging.PagingSource
6 | import androidx.paging.PagingState
7 | import com.artworkspace.storyapp.data.local.entity.Story
8 |
9 | class PagedTestDataSource :
10 | PagingSource>>() {
11 |
12 | companion object {
13 | fun snapshot(items: List): PagingData {
14 | return PagingData.from(items)
15 | }
16 | }
17 |
18 | override fun getRefreshKey(state: PagingState>>): Int {
19 | return 0
20 | }
21 |
22 | override suspend fun load(params: LoadParams): LoadResult>> {
23 | return LoadResult.Page(emptyList(), 0, 1)
24 | }
25 |
26 | }
--------------------------------------------------------------------------------
/build.gradle:
--------------------------------------------------------------------------------
1 |
2 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
3 | buildscript {
4 | dependencies {
5 | classpath 'com.google.dagger:hilt-android-gradle-plugin:2.41'
6 | }
7 | }
8 | plugins {
9 | id 'com.android.application' version '7.1.2' apply false
10 | id 'com.android.library' version '7.1.2' apply false
11 | id 'org.jetbrains.kotlin.android' version '1.6.20' apply false
12 | id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin' version '2.0.1' apply false
13 | }
14 |
15 | task clean(type: Delete) {
16 | delete rootProject.buildDir
17 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fikriyusrihan/dicoding-android-intermediate-submission/e070ebd7fa8d4f3129829ec688e21a3ca9b0218e/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu Apr 07 17:09:44 ICT 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "Story App"
16 | include ':app'
17 |
--------------------------------------------------------------------------------