diff --git a/.gitignore b/.gitignore index 2b75303ac..e1921bfc6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,8 @@ *.iml .gradle /local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild +/.idea/* diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 45b565415..000000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
- - -
-
\ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 7ac24c777..000000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 703e5d4b8..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 7f68460d8..000000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index a7fbdc0e9..1d7d1ab59 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,23 +1,21 @@ -apply plugin: 'com.android.application' - -apply plugin: 'kotlin-android' - -apply plugin: 'kotlin-android-extensions' - -apply plugin: 'kotlin-kapt' +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' +} android { - compileSdkVersion 29 + compileSdkVersion 31 defaultConfig { applicationId "com.picpay.desafio.android" minSdkVersion 21 - targetSdkVersion 29 + targetSdkVersion 31 versionCode 1 versionName "1.0" vectorDrawables.useSupportLibrary = true - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner "com.picpay.desafio.android.PicPayAppTestRunner" } buildTypes { debug {} @@ -29,9 +27,13 @@ android { } } + buildFeatures { + viewBinding true + } + compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { @@ -42,51 +44,54 @@ android { dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "androidx.core:core-ktx:$core_ktx_version" - implementation "androidx.appcompat:appcompat:$appcompat_version" + implementation "androidx.fragment:fragment-ktx:$fragment_ktx_version" + implementation "androidx.legacy:legacy-support-v4:$legacy_support_version" implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version" - implementation "com.google.android.material:material:$material_version" - - implementation "org.koin:koin-core:$koin_version" - implementation "org.koin:koin-android:$koin_version" - implementation "org.koin:koin-androidx-viewmodel:$koin_version" + // Lifecycle dependencies + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" - implementation "com.google.dagger:dagger:$dagger_version" - kapt "com.google.dagger:dagger-compiler:$dagger_version" + implementation "com.google.code.gson:gson:$gson_version" + implementation "com.google.android.material:material:$material_version" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" + // Koin dependencies + implementation "io.insert-koin:koin-core:$koin_version" + implementation "io.insert-koin:koin-android:$koin_version" + implementation "io.insert-koin:koin-androidx-viewmodel:$koin_version" + testImplementation "io.insert-koin:koin-test:$koin_version" + // Coroutines dependencies implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" - implementation "io.reactivex.rxjava2:rxjava:$rxjava_version" - implementation "io.reactivex.rxjava2:rxandroid:$rxandroid_version" - - implementation 'com.google.code.gson:gson:2.8.6' - + // Retrofit/Network dependencies implementation "com.squareup.retrofit2:retrofit:$retrofit_version" implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit_version" implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" implementation "com.squareup.okhttp3:okhttp:$okhttp_version" implementation "com.squareup.okhttp3:mockwebserver:$okhttp_version" + // Utils dependencies implementation "com.squareup.picasso:picasso:$picasso_version" implementation "de.hdodenhof:circleimageview:$circleimageview_version" + // Tests dependencies testImplementation "junit:junit:$junit_version" - testImplementation "org.mockito:mockito-core:$mockito_version" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" + testImplementation "io.mockk:mockk:$mockk_version" + + debugImplementation "androidx.fragment:fragment-testing:$fragment_ktx_version" testImplementation "androidx.arch.core:core-testing:$core_testing_version" - implementation "org.koin:koin-test:$koin_version" - androidTestImplementation "androidx.test:runner:$test_runner_version" + androidTestImplementation "androidx.test.ext:junit-ktx:$test_ext_junit_ktx_version" androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" androidTestImplementation "androidx.test:core-ktx:$core_ktx_test_version" + androidTestImplementation "androidx.test:runner:$test_runner_version" + androidTestImplementation "androidx.test:rules:$test_rules_version" + } diff --git a/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt index e4a4978eb..394fc27fd 100644 --- a/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt +++ b/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt @@ -1,68 +1,31 @@ package com.picpay.desafio.android -import androidx.lifecycle.Lifecycle -import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.platform.app.InstrumentationRegistry -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.rules.activityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith - +@RunWith(AndroidJUnit4::class) class MainActivityTest { - private val server = MockWebServer() - - private val context = InstrumentationRegistry.getInstrumentation().targetContext + @get: Rule + val rule = activityScenarioRule() @Test fun shouldDisplayTitle() { - launchActivity().apply { - val expectedTitle = context.getString(R.string.title) - - moveToState(Lifecycle.State.RESUMED) - - onView(withText(expectedTitle)).check(matches(isDisplayed())) - } + onView(withId(R.id.title)).check(matches(isDisplayed())) + onView(ViewMatchers.withText(R.string.title)).check(matches(isDisplayed())) } @Test - fun shouldDisplayListItem() { - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - return when (request.path) { - "/users" -> successResponse - else -> errorResponse - } - } - } - - server.start(serverPort) - - launchActivity().apply { - // TODO("validate if list displays items returned by server") - } - - server.close() + fun shouldDisplayFragmentContainer() { + onView(withId(R.id.fcvContainer)).check(matches(isDisplayed())) } - companion object { - private const val serverPort = 8080 - - private val successResponse by lazy { - val body = - "[{\"id\":1001,\"name\":\"Eduardo Santos\",\"img\":\"https://randomuser.me/api/portraits/men/9.jpg\",\"username\":\"@eduardo.santos\"}]" - - MockResponse() - .setResponseCode(200) - .setBody(body) - } - - private val errorResponse by lazy { MockResponse().setResponseCode(404) } - } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/PicPayAppTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/PicPayAppTest.kt new file mode 100644 index 000000000..48e89886a --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/PicPayAppTest.kt @@ -0,0 +1,20 @@ +package com.picpay.desafio.android + +import android.app.Application +import com.picpay.desafio.android.di.TestModuleInitializer +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin + +class PicPayAppTest : Application() { + + override fun onCreate() { + super.onCreate() + startKoin { + androidLogger() + androidContext(this@PicPayAppTest) + } + TestModuleInitializer.init() + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/PicPayAppTestRunner.kt b/app/src/androidTest/java/com/picpay/desafio/android/PicPayAppTestRunner.kt new file mode 100644 index 000000000..4482909b1 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/PicPayAppTestRunner.kt @@ -0,0 +1,14 @@ +package com.picpay.desafio.android + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner + +class PicPayAppTestRunner : AndroidJUnitRunner() { + + override fun newApplication( + classLoader: ClassLoader?, name: String?, context: Context? + ): Application { + return super.newApplication(classLoader, PicPayAppTest::class.java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/data/server/MockWebServerRule.kt b/app/src/androidTest/java/com/picpay/desafio/android/data/server/MockWebServerRule.kt new file mode 100644 index 000000000..bbe70721d --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/data/server/MockWebServerRule.kt @@ -0,0 +1,19 @@ +package com.picpay.desafio.android.data.server + +import okhttp3.mockwebserver.MockWebServer +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class MockWebServerRule : TestWatcher() { + + lateinit var server: MockWebServer + + override fun starting(description: Description) { + server = MockWebServer() + server.start(port = 8080) + } + + override fun finished(description: Description) { + server.shutdown() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/di/TestModule.kt b/app/src/androidTest/java/com/picpay/desafio/android/di/TestModule.kt new file mode 100644 index 000000000..8d2fe80f1 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/di/TestModule.kt @@ -0,0 +1,56 @@ +package com.picpay.desafio.android.di + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.picpay.desafio.android.data.remote.PicPayApi +import com.picpay.desafio.android.data.remote.UserRemoteDataSource +import com.picpay.desafio.android.data.repository.ContactDataRepository +import com.picpay.desafio.android.domain.repository.ContactRepository +import com.picpay.desafio.android.domain.useCases.ListContactsUseCase +import com.picpay.desafio.android.domain.useCases.ListContactsUseCaseImpl +import com.picpay.desafio.android.presentation.viewModels.ContactViewModel +import okhttp3.OkHttpClient +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.context.loadKoinModules +import org.koin.core.scope.Scope +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +fun Scope.getRetrofit() = get() + +object TestModuleInitializer { + + private val networkModule = module { + fun provideBaseUrl() = "http://localhost:8080" + fun provideGson() = GsonBuilder().create() + fun provideOkHttp() = OkHttpClient.Builder().build() + fun provideRetrofit(baseUrl: String, okHttp: OkHttpClient, gson: Gson) = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttp) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + single { provideBaseUrl() } + single { provideGson() } + single { provideOkHttp() } + single { provideRetrofit(get(), get(), get()) } + single { getRetrofit().create(PicPayApi::class.java) } + } + + private val dataModule = module { + single { UserRemoteDataSource(get()) } + single { ContactDataRepository(get()) } + } + + private val useCasesModule = module { + factory { ListContactsUseCaseImpl(get()) } + } + + private val viewModelsModule = module { + viewModel { ContactViewModel(get()) } + } + + fun init() = loadKoinModules( + listOf(networkModule, dataModule, useCasesModule, viewModelsModule) + ) +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/extensions/AnyExtensions.kt b/app/src/androidTest/java/com/picpay/desafio/android/extensions/AnyExtensions.kt new file mode 100644 index 000000000..790599c3f --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/extensions/AnyExtensions.kt @@ -0,0 +1,12 @@ +package com.picpay.desafio.android.extensions + +import com.google.gson.Gson +import java.lang.reflect.ParameterizedType + +internal fun T.toJson() = Gson().toJson(this) + +@Suppress("UNCHECKED_CAST") +internal fun Any.toViewModelClass(): Class { + val type = javaClass.genericSuperclass as ParameterizedType + return type.actualTypeArguments[0] as Class +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/presentation/fragments/ContactFragmentTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/presentation/fragments/ContactFragmentTest.kt new file mode 100644 index 000000000..a74b3462c --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/presentation/fragments/ContactFragmentTest.kt @@ -0,0 +1,42 @@ +package com.picpay.desafio.android.presentation.fragments + +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.lifecycle.Lifecycle +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.picpay.desafio.android.R +import com.picpay.desafio.android.data.server.MockWebServerRule +import com.picpay.desafio.android.providers.MockAndroidContactProvider +import com.picpay.desafio.android.providers.MockResponseProvider +import com.picpay.desafio.android.providers.RecyclerViewMatcherProvider +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ContactFragmentTest { + + private lateinit var scenario: FragmentScenario + + @get:Rule + val mockWebServerRule = MockWebServerRule() + + @Before + fun setup() { + mockWebServerRule.server.enqueue(MockResponseProvider.usersMockResponse()) + scenario = launchFragmentInContainer() + scenario.moveToState(Lifecycle.State.RESUMED) + } + + @Test + fun shouldDisplayListItemText() { + RecyclerViewMatcherProvider.checkRecyclerViewItem( + R.id.recyclerView, + position = 0, + withText(MockAndroidContactProvider.mockedContact().name) + ) + } + +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/providers/MockAndroidContactProvider.kt b/app/src/androidTest/java/com/picpay/desafio/android/providers/MockAndroidContactProvider.kt new file mode 100644 index 000000000..bbcf1ab61 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/providers/MockAndroidContactProvider.kt @@ -0,0 +1,30 @@ +package com.picpay.desafio.android.providers + +import com.picpay.desafio.android.domain.model.ContactModel +import kotlinx.coroutines.flow.flowOf + +object MockAndroidContactProvider { + + fun mockedContact() = internalMockedContact() + private fun internalMockedContact() = ContactModel( + image = "https://randomuser.me/api/portraits/men/1.jpg", + name = "Eduardo Santos", + username = "@eduardo.santos" + ) + + fun mockedFlowContacts() = flowOf(mockedContacts()) + fun mockedContacts() = internalMockedContacts() + private fun internalMockedContacts() = listOf( + internalMockedContact(), + ContactModel( + image = "https://randomuser.me/api/portraits/women/2.jpg", + name = "Marina Coelho", + username = "@marina.coelho" + ), + ContactModel( + image = "https://randomuser.me/api/portraits/women/3.jpg", + name = "Márcia Silva", + username = "@marcia.silva" + ) + ) +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/providers/MockAndroidUserProvider.kt b/app/src/androidTest/java/com/picpay/desafio/android/providers/MockAndroidUserProvider.kt new file mode 100644 index 000000000..904acf9d1 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/providers/MockAndroidUserProvider.kt @@ -0,0 +1,31 @@ +package com.picpay.desafio.android.providers + +import com.picpay.desafio.android.data.entities.UserEntity + +object MockAndroidUserProvider { + + fun mockedUser() = internalMockedUser() + private fun internalMockedUser() = UserEntity( + id = 1, + img = "https://randomuser.me/api/portraits/men/1.jpg", + name = "Eduardo Santos", + username = "@eduardo.santos" + ) + + fun mockedUsers() = internalMockedUsers() + private fun internalMockedUsers() = listOf( + internalMockedUser(), + UserEntity( + id = 2, + img = "https://randomuser.me/api/portraits/women/2.jpg", + name = "Marina Coelho", + username = "@marina.coelho" + ), + UserEntity( + id = 3, + img = "https://randomuser.me/api/portraits/women/3.jpg", + name = "Márcia Silva", + username = "@marcia.silva" + ) + ) +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/providers/MockResponseProvider.kt b/app/src/androidTest/java/com/picpay/desafio/android/providers/MockResponseProvider.kt new file mode 100644 index 000000000..816c6389b --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/providers/MockResponseProvider.kt @@ -0,0 +1,15 @@ +package com.picpay.desafio.android.providers + +import com.picpay.desafio.android.extensions.toJson +import okhttp3.mockwebserver.MockResponse + +object MockResponseProvider { + + private const val SUCCESS_RESPONSE_CODE = 200 + private const val ERROR_RESPONSE_CODE = 404 + + fun usersMockResponse() = MockResponse().setResponseCode(SUCCESS_RESPONSE_CODE) + .setBody(MockAndroidUserProvider.mockedUsers().toJson()) + + fun errorMockResponse() = MockResponse().setResponseCode(ERROR_RESPONSE_CODE) +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/RecyclerViewMatchers.kt b/app/src/androidTest/java/com/picpay/desafio/android/providers/RecyclerViewMatcherProvider.kt similarity index 75% rename from app/src/androidTest/java/com/picpay/desafio/android/RecyclerViewMatchers.kt rename to app/src/androidTest/java/com/picpay/desafio/android/providers/RecyclerViewMatcherProvider.kt index 62be92ebd..a4c9ccb9e 100644 --- a/app/src/androidTest/java/com/picpay/desafio/android/RecyclerViewMatchers.kt +++ b/app/src/androidTest/java/com/picpay/desafio/android/providers/RecyclerViewMatcherProvider.kt @@ -1,4 +1,4 @@ -package com.picpay.desafio.android +package com.picpay.desafio.android.providers import android.view.View import androidx.recyclerview.widget.RecyclerView @@ -9,11 +9,10 @@ import androidx.test.espresso.matcher.ViewMatchers import org.hamcrest.Description import org.hamcrest.Matcher -object RecyclerViewMatchers { +object RecyclerViewMatcherProvider { - fun atPosition( - position: Int, - itemMatcher: Matcher + private fun atPosition( + position: Int, itemMatcher: Matcher ) = object : BoundedMatcher(RecyclerView::class.java) { override fun describeTo(description: Description?) { description?.appendText("has item at position $position: ") @@ -28,12 +27,7 @@ object RecyclerViewMatchers { fun checkRecyclerViewItem(resId: Int, position: Int, withMatcher: Matcher) { Espresso.onView(ViewMatchers.withId(resId)).check( - ViewAssertions.matches( - atPosition( - position, - ViewMatchers.hasDescendant(withMatcher) - ) - ) + ViewAssertions.matches(atPosition(position, ViewMatchers.hasDescendant(withMatcher))) ) } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7bdf2ce38..d1433da20 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ - + diff --git a/app/src/main/java/com/picpay/desafio/android/MainActivity.kt b/app/src/main/java/com/picpay/desafio/android/MainActivity.kt index 2447de98d..76e9e2b30 100644 --- a/app/src/main/java/com/picpay/desafio/android/MainActivity.kt +++ b/app/src/main/java/com/picpay/desafio/android/MainActivity.kt @@ -1,75 +1,9 @@ package com.picpay.desafio.android -import android.view.View -import android.widget.ProgressBar -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import okhttp3.OkHttpClient -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import com.picpay.desafio.android.bases.BaseActivity +import com.picpay.desafio.android.databinding.ActivityMainBinding +import com.picpay.desafio.android.extensions.viewBinding -class MainActivity : AppCompatActivity(R.layout.activity_main) { - - private lateinit var recyclerView: RecyclerView - private lateinit var progressBar: ProgressBar - private lateinit var adapter: UserListAdapter - - private val url = "https://609a908e0f5a13001721b74e.mockapi.io/picpay/api/" - - private val gson: Gson by lazy { GsonBuilder().create() } - - private val okHttp: OkHttpClient by lazy { - OkHttpClient.Builder() - .build() - } - - private val retrofit: Retrofit by lazy { - Retrofit.Builder() - .baseUrl(url) - .client(okHttp) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - } - - private val service: PicPayService by lazy { - retrofit.create(PicPayService::class.java) - } - - override fun onResume() { - super.onResume() - - recyclerView = findViewById(R.id.recyclerView) - progressBar = findViewById(R.id.user_list_progress_bar) - - adapter = UserListAdapter() - recyclerView.adapter = adapter - recyclerView.layoutManager = LinearLayoutManager(this) - - progressBar.visibility = View.VISIBLE - service.getUsers() - .enqueue(object : Callback> { - override fun onFailure(call: Call>, t: Throwable) { - val message = getString(R.string.error) - - progressBar.visibility = View.GONE - recyclerView.visibility = View.GONE - - Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT) - .show() - } - - override fun onResponse(call: Call>, response: Response>) { - progressBar.visibility = View.GONE - - adapter.users = response.body()!! - } - }) - } +class MainActivity : BaseActivity() { + override val binding by viewBinding(ActivityMainBinding::inflate) } diff --git a/app/src/main/java/com/picpay/desafio/android/PicPayApp.kt b/app/src/main/java/com/picpay/desafio/android/PicPayApp.kt new file mode 100644 index 000000000..87c487c12 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/PicPayApp.kt @@ -0,0 +1,20 @@ +package com.picpay.desafio.android + +import android.app.Application +import com.picpay.desafio.android.di.MainModuleInitializer +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin + +class PicPayApp : Application() { + + override fun onCreate() { + super.onCreate() + startKoin { + androidLogger() + androidContext(this@PicPayApp) + } + MainModuleInitializer.init() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/PicPayService.kt b/app/src/main/java/com/picpay/desafio/android/PicPayService.kt deleted file mode 100644 index c26edac1f..000000000 --- a/app/src/main/java/com/picpay/desafio/android/PicPayService.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.picpay.desafio.android - -import retrofit2.Call -import retrofit2.http.GET - - -interface PicPayService { - - @GET("users") - fun getUsers(): Call> -} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt b/app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt deleted file mode 100644 index 538c98a4a..000000000 --- a/app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.picpay.desafio.android - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView - -class UserListAdapter : RecyclerView.Adapter() { - - var users = emptyList() - set(value) { - val result = DiffUtil.calculateDiff( - UserListDiffCallback( - field, - value - ) - ) - result.dispatchUpdatesTo(this) - field = value - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserListItemViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.list_item_user, parent, false) - - return UserListItemViewHolder(view) - } - - override fun onBindViewHolder(holder: UserListItemViewHolder, position: Int) { - holder.bind(users[position]) - } - - override fun getItemCount(): Int = users.size -} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt b/app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt deleted file mode 100644 index 7c734d37b..000000000 --- a/app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.picpay.desafio.android - -import androidx.recyclerview.widget.DiffUtil -import com.picpay.desafio.android.User - -class UserListDiffCallback( - private val oldList: List, - private val newList: List -) : DiffUtil.Callback() { - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldList[oldItemPosition].username.equals(newList[newItemPosition].username) - } - - override fun getOldListSize(): Int { - return oldList.size - } - - override fun getNewListSize(): Int { - return newList.size - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/UserListItemViewHolder.kt b/app/src/main/java/com/picpay/desafio/android/UserListItemViewHolder.kt deleted file mode 100644 index 1d8240eb3..000000000 --- a/app/src/main/java/com/picpay/desafio/android/UserListItemViewHolder.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.picpay.desafio.android - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.squareup.picasso.Callback -import com.squareup.picasso.Picasso -import kotlinx.android.synthetic.main.list_item_user.view.* - -class UserListItemViewHolder( - itemView: View -) : RecyclerView.ViewHolder(itemView) { - - fun bind(user: User) { - itemView.name.text = user.name - itemView.username.text = user.username - itemView.progressBar.visibility = View.VISIBLE - Picasso.get() - .load(user.img) - .error(R.drawable.ic_round_account_circle) - .into(itemView.picture, object : Callback { - override fun onSuccess() { - itemView.progressBar.visibility = View.GONE - } - - override fun onError(e: Exception?) { - itemView.progressBar.visibility = View.GONE - } - }) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/bases/BaseActivity.kt b/app/src/main/java/com/picpay/desafio/android/bases/BaseActivity.kt new file mode 100644 index 000000000..ada62ff63 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/bases/BaseActivity.kt @@ -0,0 +1,18 @@ +package com.picpay.desafio.android.bases + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.viewbinding.ViewBinding +import com.picpay.desafio.android.extensions.getViewModelClass +import org.koin.androidx.viewmodel.ext.android.getViewModel + +abstract class BaseActivity : AppCompatActivity() { + + abstract val binding: ViewBinding + val viewModel: V by lazy { getViewModel(clazz = getViewModelClass()) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/bases/BaseFragment.kt b/app/src/main/java/com/picpay/desafio/android/bases/BaseFragment.kt new file mode 100644 index 000000000..832c6a5a8 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/bases/BaseFragment.kt @@ -0,0 +1,31 @@ +package com.picpay.desafio.android.bases + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import com.picpay.desafio.android.extensions.getViewModelClass +import org.koin.androidx.viewmodel.ext.android.getViewModel + +abstract class BaseFragment : Fragment() { + + abstract val binding: ViewBinding + abstract fun initComponents() + abstract fun initObservers() + val viewModel: V by lazy { getViewModel(clazz = getViewModelClass()) } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + super.onCreateView(inflater, container, savedInstanceState) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initComponents() + initObservers() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/bases/BaseViewModel.kt b/app/src/main/java/com/picpay/desafio/android/bases/BaseViewModel.kt new file mode 100644 index 000000000..423306f4a --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/bases/BaseViewModel.kt @@ -0,0 +1,43 @@ +package com.picpay.desafio.android.bases + +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.picpay.desafio.android.extensions.EMPTY +import com.picpay.desafio.android.extensions.FALSE +import com.picpay.desafio.android.extensions.NULL + +abstract class BaseViewModel : ViewModel() { + + private val _isLoading: MutableLiveData = MutableLiveData(Boolean.FALSE) + val isLoading: LiveData get() = _isLoading + + private val _message: MutableLiveData = MutableLiveData(String.EMPTY) + val message: LiveData get() = _message + + private val _messageResource: MutableLiveData = MutableLiveData(Int.NULL) + val messageResource: LiveData get() = _messageResource + + private fun setLoading(isLoading: Boolean = true) { + _isLoading.postValue(isLoading) + } + + fun startLoading() { + setLoading(isLoading = true) + } + + fun stopLoading() { + setLoading(isLoading = false) + } + + fun setMessage(message: String) { + _message.postValue(message) + stopLoading() + } + + fun setMessageResource(@StringRes messageResource: Int) { + _messageResource.postValue(messageResource) + stopLoading() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/User.kt b/app/src/main/java/com/picpay/desafio/android/data/entities/UserEntity.kt similarity index 72% rename from app/src/main/java/com/picpay/desafio/android/User.kt rename to app/src/main/java/com/picpay/desafio/android/data/entities/UserEntity.kt index aa28171c9..59954651b 100644 --- a/app/src/main/java/com/picpay/desafio/android/User.kt +++ b/app/src/main/java/com/picpay/desafio/android/data/entities/UserEntity.kt @@ -1,13 +1,13 @@ -package com.picpay.desafio.android +package com.picpay.desafio.android.data.entities import android.os.Parcelable import com.google.gson.annotations.SerializedName -import kotlinx.android.parcel.Parcelize +import kotlinx.parcelize.Parcelize @Parcelize -data class User( +data class UserEntity( + @SerializedName("id") val id: Int, @SerializedName("img") val img: String, @SerializedName("name") val name: String, - @SerializedName("id") val id: Int, @SerializedName("username") val username: String ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/mapper/UserMapper.kt b/app/src/main/java/com/picpay/desafio/android/data/mapper/UserMapper.kt new file mode 100644 index 000000000..3faa71126 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/mapper/UserMapper.kt @@ -0,0 +1,8 @@ +package com.picpay.desafio.android.data.mapper + +import com.picpay.desafio.android.data.entities.UserEntity +import com.picpay.desafio.android.domain.model.ContactModel + +fun UserEntity.toContactModel() = ContactModel(image = img, name = name, username = username) + +fun List.toListContactModel() = map { it.toContactModel() } \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/remote/PicPayApi.kt b/app/src/main/java/com/picpay/desafio/android/data/remote/PicPayApi.kt new file mode 100644 index 000000000..61a20e9d3 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/remote/PicPayApi.kt @@ -0,0 +1,10 @@ +package com.picpay.desafio.android.data.remote + +import com.picpay.desafio.android.data.entities.UserEntity +import retrofit2.http.GET + +interface PicPayApi { + + @GET("users") + suspend fun getUsers(): List +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/remote/UserRemoteDataSource.kt b/app/src/main/java/com/picpay/desafio/android/data/remote/UserRemoteDataSource.kt new file mode 100644 index 000000000..fa1a2cbde --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/remote/UserRemoteDataSource.kt @@ -0,0 +1,15 @@ +package com.picpay.desafio.android.data.remote + +import com.picpay.desafio.android.data.entities.UserEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow + +class UserRemoteDataSource(private val api: PicPayApi) { + + private fun call(block: suspend FlowCollector.() -> T): Flow = flow { + emit(block()) + } + + fun getUsers(): Flow> = call { api.getUsers() } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/repository/ContactDataRepository.kt b/app/src/main/java/com/picpay/desafio/android/data/repository/ContactDataRepository.kt new file mode 100644 index 000000000..673ad033d --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/repository/ContactDataRepository.kt @@ -0,0 +1,13 @@ +package com.picpay.desafio.android.data.repository + +import com.picpay.desafio.android.data.mapper.toListContactModel +import com.picpay.desafio.android.data.remote.UserRemoteDataSource +import com.picpay.desafio.android.domain.repository.ContactRepository +import kotlinx.coroutines.flow.map + +class ContactDataRepository( + private val remoteDataSource: UserRemoteDataSource +) : ContactRepository { + + override fun getContacts() = remoteDataSource.getUsers().map { it.toListContactModel() } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/di/MainModule.kt b/app/src/main/java/com/picpay/desafio/android/di/MainModule.kt new file mode 100644 index 000000000..a21fb15a4 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/di/MainModule.kt @@ -0,0 +1,104 @@ +package com.picpay.desafio.android.di + +import android.content.Context +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.picpay.desafio.android.data.remote.PicPayApi +import com.picpay.desafio.android.data.remote.UserRemoteDataSource +import com.picpay.desafio.android.data.repository.ContactDataRepository +import com.picpay.desafio.android.domain.repository.ContactRepository +import com.picpay.desafio.android.domain.useCases.ListContactsUseCase +import com.picpay.desafio.android.domain.useCases.ListContactsUseCaseImpl +import com.picpay.desafio.android.presentation.viewModels.ContactViewModel +import okhttp3.* +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.context.loadKoinModules +import org.koin.core.scope.Scope +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +fun Scope.getRetrofit() = get() + +object MainModuleInitializer { + + private var CACHE_MAX_AGE_VALUE = 5 + private var CACHE_MAX_STALE_VALUE = 1 + private var HEADER_PRAGMA = "Pragma" + private var HEADER_CACHE_CONTROL = "Cache-Control" + private const val CACHE_SIZE = (5 * 1024 * 1024).toLong() + + private val networkModule = module { + + fun provideBaseUrl() = "https://609a908e0f5a13001721b74e.mockapi.io/picpay/api/" + + fun provideGson() = GsonBuilder().create() + + fun provideCache(context: Context) = Cache(context.cacheDir, CACHE_SIZE) + + fun provideCacheInterceptor() = Interceptor { chain -> + val request: Request = chain.request() + val cacheControl = CacheControl.Builder() + .maxAge(CACHE_MAX_AGE_VALUE, TimeUnit.MINUTES) + .build() + request.newBuilder() + .removeHeader(HEADER_PRAGMA) + .header(HEADER_CACHE_CONTROL, cacheControl.toString()) + .build() + chain.proceed(request) + } + + fun provideOfflineCacheInterceptor() = Interceptor { chain -> + try { + chain.proceed(chain.request()) + } catch (e: Exception) { + val cacheControl = CacheControl.Builder() + .onlyIfCached() + .maxStale(CACHE_MAX_STALE_VALUE, TimeUnit.DAYS) + .build() + val offlineRequest: Request = chain.request().newBuilder() + .cacheControl(cacheControl) + .removeHeader(HEADER_PRAGMA) + .build() + chain.proceed(offlineRequest) + } + } + + fun provideOkHttp(cache: Cache) = OkHttpClient.Builder() + .addNetworkInterceptor(provideCacheInterceptor()) + .addInterceptor(provideOfflineCacheInterceptor()) + .cache(cache) + .build() + + fun provideRetrofit(baseUrl: String, okHttp: OkHttpClient, gson: Gson) = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttp) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + single { provideBaseUrl() } + single { provideGson() } + single { provideCache(get()) } + single { provideOkHttp(get()) } + single { provideRetrofit(get(), get(), get()) } + single { getRetrofit().create(PicPayApi::class.java) } + } + + private val dataModule = module { + single { UserRemoteDataSource(get()) } + single { ContactDataRepository(get()) } + } + + private val useCasesModule = module { + factory { ListContactsUseCaseImpl(get()) } + } + + private val viewModelsModule = module { + viewModel { ContactViewModel(get()) } + } + + fun init() = loadKoinModules( + listOf(networkModule, dataModule, useCasesModule, viewModelsModule) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/model/ContactModel.kt b/app/src/main/java/com/picpay/desafio/android/domain/model/ContactModel.kt new file mode 100644 index 000000000..915f25555 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/model/ContactModel.kt @@ -0,0 +1,21 @@ +package com.picpay.desafio.android.domain.model + +import androidx.recyclerview.widget.DiffUtil + +data class ContactModel(val image: String, val name: String, val username: String) { + + companion object { + + val DIFF_UTIL_CALLBACK = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: ContactModel, newItem: ContactModel): Boolean { + return oldItem.username == newItem.username + } + + override fun areContentsTheSame(oldItem: ContactModel, newItem: ContactModel): Boolean { + return oldItem == newItem + } + + } + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/domain/repository/ContactRepository.kt b/app/src/main/java/com/picpay/desafio/android/domain/repository/ContactRepository.kt new file mode 100644 index 000000000..09b1961fb --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/repository/ContactRepository.kt @@ -0,0 +1,8 @@ +package com.picpay.desafio.android.domain.repository + +import com.picpay.desafio.android.domain.model.ContactModel +import kotlinx.coroutines.flow.Flow + +interface ContactRepository { + fun getContacts(): Flow> +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCase.kt b/app/src/main/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCase.kt new file mode 100644 index 000000000..071cf1a39 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCase.kt @@ -0,0 +1,8 @@ +package com.picpay.desafio.android.domain.useCases + +import com.picpay.desafio.android.domain.model.ContactModel +import kotlinx.coroutines.flow.Flow + +interface ListContactsUseCase { + operator fun invoke(): Flow> +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCaseImpl.kt b/app/src/main/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCaseImpl.kt new file mode 100644 index 000000000..4cab44a2b --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCaseImpl.kt @@ -0,0 +1,9 @@ +package com.picpay.desafio.android.domain.useCases + +import com.picpay.desafio.android.domain.model.ContactModel +import com.picpay.desafio.android.domain.repository.ContactRepository +import kotlinx.coroutines.flow.Flow + +class ListContactsUseCaseImpl(private val contactRepository: ContactRepository) : ListContactsUseCase { + override fun invoke(): Flow> = contactRepository.getContacts() +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/extensions/ActivityExtensions.kt b/app/src/main/java/com/picpay/desafio/android/extensions/ActivityExtensions.kt new file mode 100644 index 000000000..327192c17 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/extensions/ActivityExtensions.kt @@ -0,0 +1,11 @@ +package com.picpay.desafio.android.extensions + +import android.view.LayoutInflater +import androidx.appcompat.app.AppCompatActivity +import androidx.viewbinding.ViewBinding + +inline fun AppCompatActivity.viewBinding( + crossinline bindingInflater: (LayoutInflater) -> T +) = lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) } + +internal fun AppCompatActivity.getViewModelClass() = toViewModelClass().kotlin \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/extensions/AnyExtensions.kt b/app/src/main/java/com/picpay/desafio/android/extensions/AnyExtensions.kt new file mode 100644 index 000000000..acc2306f0 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/extensions/AnyExtensions.kt @@ -0,0 +1,9 @@ +package com.picpay.desafio.android.extensions + +import java.lang.reflect.ParameterizedType + +@Suppress("UNCHECKED_CAST") +internal fun Any.toViewModelClass(): Class { + val type = javaClass.genericSuperclass as ParameterizedType + return type.actualTypeArguments[0] as Class +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/extensions/BooleanExtensions.kt b/app/src/main/java/com/picpay/desafio/android/extensions/BooleanExtensions.kt new file mode 100644 index 000000000..21dea3123 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/extensions/BooleanExtensions.kt @@ -0,0 +1,5 @@ +package com.picpay.desafio.android.extensions + +val Boolean.Companion.FALSE: Boolean get() = false + +fun Boolean?.orFalse() = this ?: Boolean.FALSE \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/extensions/ContextExtensions.kt b/app/src/main/java/com/picpay/desafio/android/extensions/ContextExtensions.kt new file mode 100644 index 000000000..c3ed932d1 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/extensions/ContextExtensions.kt @@ -0,0 +1,24 @@ +package com.picpay.desafio.android.extensions + +import android.content.Context +import android.view.LayoutInflater +import android.widget.Toast +import androidx.annotation.StringRes + +fun Context.toLayoutInflater(): LayoutInflater = LayoutInflater.from(this) + +fun Context.showToastLongText(text: String?) { + Toast.makeText(this, text, Toast.LENGTH_LONG).show() +} + +fun Context.showToastLongText(@StringRes resId: Int) { + Toast.makeText(this, resId, Toast.LENGTH_LONG).show() +} + +fun Context.showToastShortText(text: String?) { + Toast.makeText(this, text, Toast.LENGTH_SHORT).show() +} + +fun Context.showToastShortText(@StringRes resId: Int) { + Toast.makeText(this, resId, Toast.LENGTH_SHORT).show() +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/extensions/FragmentExtensions.kt b/app/src/main/java/com/picpay/desafio/android/extensions/FragmentExtensions.kt new file mode 100644 index 000000000..17de78aaf --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/extensions/FragmentExtensions.kt @@ -0,0 +1,11 @@ +package com.picpay.desafio.android.extensions + +import android.view.LayoutInflater +import androidx.fragment.app.Fragment +import androidx.viewbinding.ViewBinding + +inline fun Fragment.viewBinding( + crossinline bindingInflater: (LayoutInflater) -> T +) = lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) } + +internal fun Fragment.getViewModelClass() = toViewModelClass().kotlin \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/extensions/IntExtensions.kt b/app/src/main/java/com/picpay/desafio/android/extensions/IntExtensions.kt new file mode 100644 index 000000000..d0ea9eec1 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/extensions/IntExtensions.kt @@ -0,0 +1,7 @@ +package com.picpay.desafio.android.extensions + +val Int.Companion.ZERO: Int get() = 0 + +val Int.Companion.NULL: Int? get() = null + +fun Int?.orZero() = this ?: Int.ZERO \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/extensions/StringsExtensions.kt b/app/src/main/java/com/picpay/desafio/android/extensions/StringsExtensions.kt new file mode 100644 index 000000000..2f197d392 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/extensions/StringsExtensions.kt @@ -0,0 +1,5 @@ +package com.picpay.desafio.android.extensions + +val String.Companion.EMPTY: String get() = "" + +fun String?.takeIfNotBlank() = takeIf { it?.isNotBlank().orFalse() } \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/extensions/SwipeRefreshLayoutExtensions.kt b/app/src/main/java/com/picpay/desafio/android/extensions/SwipeRefreshLayoutExtensions.kt new file mode 100644 index 000000000..e89e4ce1e --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/extensions/SwipeRefreshLayoutExtensions.kt @@ -0,0 +1,18 @@ +package com.picpay.desafio.android.extensions + +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.picpay.desafio.android.R + +fun SwipeRefreshLayout.updateRefreshing(isRefreshing: Boolean) { + if (this.isRefreshing != isRefreshing) + this.isRefreshing = isRefreshing +} + +fun SwipeRefreshLayout.stopRefreshing() { + this.isRefreshing = false +} + +fun SwipeRefreshLayout.setTheme() { + setColorSchemeResources(R.color.colorAccent) + setProgressBackgroundColorSchemeResource(R.color.colorPrimaryDark) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/adapters/ContactListAdapter.kt b/app/src/main/java/com/picpay/desafio/android/presentation/adapters/ContactListAdapter.kt new file mode 100644 index 000000000..c9d2c5c9c --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/adapters/ContactListAdapter.kt @@ -0,0 +1,17 @@ +package com.picpay.desafio.android.presentation.adapters + +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import com.picpay.desafio.android.domain.model.ContactModel +import com.picpay.desafio.android.presentation.viewHolders.ContactListItemViewHolder + +class ContactListAdapter : ListAdapter(ContactModel.DIFF_UTIL_CALLBACK) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactListItemViewHolder { + return ContactListItemViewHolder.newInstance(parent) + } + + override fun onBindViewHolder(holder: ContactListItemViewHolder, position: Int) { + holder.bind(currentList[position]) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/fragments/ContactFragment.kt b/app/src/main/java/com/picpay/desafio/android/presentation/fragments/ContactFragment.kt new file mode 100644 index 000000000..47f33714d --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/fragments/ContactFragment.kt @@ -0,0 +1,42 @@ +package com.picpay.desafio.android.presentation.fragments + +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import com.picpay.desafio.android.bases.BaseFragment +import com.picpay.desafio.android.databinding.FragContactBinding +import com.picpay.desafio.android.extensions.* +import com.picpay.desafio.android.presentation.adapters.ContactListAdapter +import com.picpay.desafio.android.presentation.viewModels.ContactViewModel + +class ContactFragment : BaseFragment() { + + override val binding by viewBinding(FragContactBinding::inflate) + private val listAdapter: ContactListAdapter by lazy { ContactListAdapter() } + + override fun initComponents() { + with(binding) { + srlContent.setTheme() + srlContent.setOnRefreshListener(viewModel::loadContacts) + recyclerView.apply { + adapter = listAdapter + layoutManager = LinearLayoutManager(context) + } + } + } + + override fun initObservers() { + with(viewModel) { + isLoading.observe(viewLifecycleOwner) { binding.srlContent.updateRefreshing(it.orFalse()) } + messageResource.observe(viewLifecycleOwner) { resource -> + binding.recyclerView.visibility = View.GONE + resource?.let { this@ContactFragment.context?.showToastShortText(getString(it)) } + } + contacts.observe(viewLifecycleOwner) { + binding.recyclerView.visibility = View.VISIBLE + listAdapter.submitList(it) + } + loadContacts() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/viewHolders/ContactListItemViewHolder.kt b/app/src/main/java/com/picpay/desafio/android/presentation/viewHolders/ContactListItemViewHolder.kt new file mode 100644 index 000000000..b210f2455 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/viewHolders/ContactListItemViewHolder.kt @@ -0,0 +1,43 @@ +package com.picpay.desafio.android.presentation.viewHolders + +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.R +import com.picpay.desafio.android.databinding.ListItemUserBinding +import com.picpay.desafio.android.domain.model.ContactModel +import com.picpay.desafio.android.extensions.toLayoutInflater +import com.squareup.picasso.Callback +import com.squareup.picasso.Picasso + +class ContactListItemViewHolder( + private val binding: ListItemUserBinding +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(contactModel: ContactModel) { + with(binding) { + name.text = contactModel.name + username.text = contactModel.username + progressBar.visibility = View.VISIBLE + Picasso.get() + .load(contactModel.image) + .error(R.drawable.ic_round_account_circle) + .into(picture, object : Callback { + override fun onSuccess() { + progressBar.visibility = View.GONE + } + + override fun onError(e: Exception?) { + progressBar.visibility = View.GONE + } + }) + } + } + + companion object { + + fun newInstance(parent: ViewGroup) = ContactListItemViewHolder( + ListItemUserBinding.inflate(parent.context.toLayoutInflater(), parent, false) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/viewModels/ContactViewModel.kt b/app/src/main/java/com/picpay/desafio/android/presentation/viewModels/ContactViewModel.kt new file mode 100644 index 000000000..2c1cea179 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/viewModels/ContactViewModel.kt @@ -0,0 +1,30 @@ +package com.picpay.desafio.android.presentation.viewModels + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import com.picpay.desafio.android.R +import com.picpay.desafio.android.bases.BaseViewModel +import com.picpay.desafio.android.domain.model.ContactModel +import com.picpay.desafio.android.domain.useCases.ListContactsUseCase +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +class ContactViewModel(private val listContactsUseCase: ListContactsUseCase) : BaseViewModel() { + + private val _contacts: MutableLiveData> = MutableLiveData() + val contacts: LiveData> get() = _contacts + + fun loadContacts() { + viewModelScope.launch { + listContactsUseCase() + .onStart { startLoading() } + .catch { setMessageResource(R.string.error) } + .onCompletion { stopLoading() } + .collect { _contacts.postValue(it) } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 487ac549e..e0cc2748f 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,5 @@ - - - - - - - - - - - - - - - - \ No newline at end of file + android:layout_marginStart="@dimen/medium_margin" + android:layout_marginTop="@dimen/big_margin" + android:text="@string/title" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/frag_contact.xml b/app/src/main/res/layout/frag_contact.xml new file mode 100644 index 000000000..14c01bf11 --- /dev/null +++ b/app/src/main/res/layout/frag_contact.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_item_user.xml b/app/src/main/res/layout/list_item_user.xml index 587d40cc8..4a105f091 100644 --- a/app/src/main/res/layout/list_item_user.xml +++ b/app/src/main/res/layout/list_item_user.xml @@ -9,12 +9,10 @@ + tools:text="User name" /> + + + 0dp + 52dp + + + 48dp + 24dp + 8dp + 12dp + 16dp + \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/ExampleService.kt b/app/src/test/java/com/picpay/desafio/android/ExampleService.kt deleted file mode 100644 index 0199c5e4a..000000000 --- a/app/src/test/java/com/picpay/desafio/android/ExampleService.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.picpay.desafio.android - -class ExampleService( - private val service: PicPayService -) { - - fun example(): List { - val users = service.getUsers().execute() - - return users.body() ?: emptyList() - } -} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/ExampleServiceTest.kt b/app/src/test/java/com/picpay/desafio/android/ExampleServiceTest.kt deleted file mode 100644 index 843c0e776..000000000 --- a/app/src/test/java/com/picpay/desafio/android/ExampleServiceTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.picpay.desafio.android - -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.whenever -import junit.framework.Assert.assertEquals -import org.junit.Test -import retrofit2.Call -import retrofit2.Response - -class ExampleServiceTest { - - private val api = mock() - - private val service = ExampleService(api) - - @Test - fun exampleTest() { - // given - val call = mock>>() - val expectedUsers = emptyList() - - whenever(call.execute()).thenReturn(Response.success(expectedUsers)) - whenever(api.getUsers()).thenReturn(call) - - // when - val users = service.example() - - // then - assertEquals(users, expectedUsers) - } -} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/base/BaseTest.kt b/app/src/test/java/com/picpay/desafio/android/base/BaseTest.kt new file mode 100644 index 000000000..6ce2e1ecf --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/base/BaseTest.kt @@ -0,0 +1,25 @@ +package com.picpay.desafio.android.base + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.MockKAnnotations +import junit.framework.TestCase +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +abstract class BaseTest : TestCase() { + + @get: Rule + val coroutineRule = CoroutineTestRule() + + @get: Rule + val rule = InstantTaskExecutorRule() + + @Before + open fun setup() { + MockKAnnotations.init(this) + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/base/CoroutineTestRule.kt b/app/src/test/java/com/picpay/desafio/android/base/CoroutineTestRule.kt new file mode 100644 index 000000000..fa618693f --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/base/CoroutineTestRule.kt @@ -0,0 +1,26 @@ +package com.picpay.desafio.android.base + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class CoroutineTestRule : TestWatcher() { + + private val dispatcher = TestCoroutineDispatcher() + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + dispatcher.cleanupTestCoroutines() + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/data/mapper/UserMapperTest.kt b/app/src/test/java/com/picpay/desafio/android/data/mapper/UserMapperTest.kt new file mode 100644 index 000000000..733c68e78 --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/data/mapper/UserMapperTest.kt @@ -0,0 +1,21 @@ +package com.picpay.desafio.android.data.mapper + +import com.picpay.desafio.android.base.BaseTest +import com.picpay.desafio.android.providers.MockContactProvider +import com.picpay.desafio.android.providers.MockUserProvider +import org.junit.Test + +class UserMapperTest : BaseTest() { + + @Test + fun shouldReturnContact() { + val contact = MockUserProvider.mockedUser().toContactModel() + assertEquals(MockContactProvider.mockedContact(), contact) + } + + @Test + fun shouldReturnContacts() { + val contacts = MockUserProvider.mockedUsers().toListContactModel() + assertEquals(MockContactProvider.mockedContacts(), contacts) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCaseTest.kt b/app/src/test/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCaseTest.kt new file mode 100644 index 000000000..f53550a38 --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/domain/useCases/ListContactsUseCaseTest.kt @@ -0,0 +1,37 @@ +package com.picpay.desafio.android.domain.useCases + +import com.picpay.desafio.android.base.BaseTest +import com.picpay.desafio.android.domain.repository.ContactRepository +import com.picpay.desafio.android.providers.MockContactProvider +import com.picpay.desafio.android.providers.MockErrorProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.junit.Test + +class ListContactsUseCaseTest : BaseTest() { + + private val repository = mockk(relaxed = true) + + private lateinit var listContactsUseCase: ListContactsUseCase + + override fun setup() { + super.setup() + listContactsUseCase = ListContactsUseCaseImpl(repository) + } + + @Test + fun shouldListContacts() { + coEvery { repository.getContacts() } returns MockContactProvider.mockedFlowContacts() + listContactsUseCase() + coVerify { repository.getContacts() } + } + + @Test + fun shouldNotListContacts() { + coEvery { repository.getContacts() } returns MockErrorProvider.mockErrorFlow() + listContactsUseCase() + coVerify { repository.getContacts() } + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/presentation/viewModels/ContactViewModelTest.kt b/app/src/test/java/com/picpay/desafio/android/presentation/viewModels/ContactViewModelTest.kt new file mode 100644 index 000000000..ff1811a8a --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/presentation/viewModels/ContactViewModelTest.kt @@ -0,0 +1,42 @@ +package com.picpay.desafio.android.presentation.viewModels + +import com.picpay.desafio.android.base.BaseTest +import com.picpay.desafio.android.domain.useCases.ListContactsUseCase +import com.picpay.desafio.android.extensions.orFalse +import com.picpay.desafio.android.providers.MockContactProvider +import com.picpay.desafio.android.providers.MockErrorProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.junit.Test + +class ContactViewModelTest : BaseTest() { + + private lateinit var viewModel: ContactViewModel + private val listContactsUseCase = mockk(relaxed = true) + + override fun setup() { + super.setup() + viewModel = ContactViewModel(listContactsUseCase) + } + + @Test + fun shouldLoadingContacts() { + viewModel.run { + coEvery { listContactsUseCase() } returns MockContactProvider.mockedFlowContacts() + loadContacts() + coVerify { listContactsUseCase() } + assertTrue(contacts.value?.isNotEmpty().orFalse()) + } + } + + @Test + fun shouldNotLoadContacts() { + viewModel.run { + coEvery { listContactsUseCase() } returns MockErrorProvider.mockErrorFlow() + loadContacts() + coVerify { listContactsUseCase() } + assertNotNull(messageResource.value) + } + } +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/providers/MockContactProvider.kt b/app/src/test/java/com/picpay/desafio/android/providers/MockContactProvider.kt new file mode 100644 index 000000000..3cac05874 --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/providers/MockContactProvider.kt @@ -0,0 +1,30 @@ +package com.picpay.desafio.android.providers + +import com.picpay.desafio.android.domain.model.ContactModel +import kotlinx.coroutines.flow.flowOf + +object MockContactProvider { + + fun mockedContact() = internalMockedContact() + private fun internalMockedContact() = ContactModel( + image = "https://randomuser.me/api/portraits/men/1.jpg", + name = "Eduardo Santos", + username = "@eduardo.santos" + ) + + fun mockedFlowContacts() = flowOf(mockedContacts()) + fun mockedContacts() = internalMockedContacts() + private fun internalMockedContacts() = listOf( + internalMockedContact(), + ContactModel( + image = "https://randomuser.me/api/portraits/women/2.jpg", + name = "Marina Coelho", + username = "@marina.coelho" + ), + ContactModel( + image = "https://randomuser.me/api/portraits/women/3.jpg", + name = "Márcia Silva", + username = "@marcia.silva" + ) + ) +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/providers/MockErrorProvider.kt b/app/src/test/java/com/picpay/desafio/android/providers/MockErrorProvider.kt new file mode 100644 index 000000000..2c4b9252a --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/providers/MockErrorProvider.kt @@ -0,0 +1,7 @@ +package com.picpay.desafio.android.providers + +import kotlinx.coroutines.flow.flow + +object MockErrorProvider { + fun mockErrorFlow() = flow { emit(throw Exception()) } +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/providers/MockUserProvider.kt b/app/src/test/java/com/picpay/desafio/android/providers/MockUserProvider.kt new file mode 100644 index 000000000..93bbb333e --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/providers/MockUserProvider.kt @@ -0,0 +1,31 @@ +package com.picpay.desafio.android.providers + +import com.picpay.desafio.android.data.entities.UserEntity + +object MockUserProvider { + + fun mockedUser() = internalMockedUser() + private fun internalMockedUser() = UserEntity( + id = 1, + img = "https://randomuser.me/api/portraits/men/1.jpg", + name = "Eduardo Santos", + username = "@eduardo.santos" + ) + + fun mockedUsers() = internalMockedUsers() + private fun internalMockedUsers() = listOf( + internalMockedUser(), + UserEntity( + id = 2, + img = "https://randomuser.me/api/portraits/women/2.jpg", + name = "Marina Coelho", + username = "@marina.coelho" + ), + UserEntity( + id = 3, + img = "https://randomuser.me/api/portraits/women/3.jpg", + name = "Márcia Silva", + username = "@marcia.silva" + ) + ) +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7d1b94f34..260a61a0d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,42 +2,42 @@ buildscript { ext { - kotlin_version = '1.3.61' + gradle_version = '7.1.3' + kotlin_version = '1.6.21' - appcompat_version = '1.1.0' - core_ktx_version = '1.2.0' + appcompat_version = '1.4.2' + legacy_support_version = '1.0.0' + core_ktx_version = '1.8.0' + fragment_ktx_version = '1.4.1' core_testing_version = '2.1.0' - constraintlayout_version = '1.1.3' - material_version = "1.1.0" - moshi_version = '1.8.0' + constraintlayout_version = '2.1.4' + gson_version = "2.8.9" + material_version = "1.6.1" retrofit_version = '2.7.1' - okhttp_version = '4.3.1' + okhttp_version = '4.8.0' picasso_version = '2.71828' circleimageview_version = '3.0.0' - junit_version = '4.12' - mockito_version = '2.27.0' - mockito_kotlin_version = '2.1.0' + junit_version = '4.13.2' + mockk_version = '1.12.0' - test_runner_version = '1.1.1' - espresso_version = '3.1.1' + test_ext_junit_ktx_version = '1.1.3' + test_runner_version = '1.4.0' + test_rules_version = '1.4.0' + espresso_version = '3.4.0' - koin_version = "2.0.1" - dagger_version = "2.23.2" - lifecycle_version = "2.2.0" - coroutines_version = "1.3.3" - rxjava_version = "2.2.17" - rxandroid_version = "2.1.1" - core_ktx_test_version = "1.2.0" + koin_version = "2.2.3" + lifecycle_version = "2.4.1" + coroutines_version = "1.5.2" + core_ktx_test_version = "1.4.0" } repositories { google() - jcenter() - + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.3' + classpath "com.android.tools.build:gradle:$gradle_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -47,8 +47,7 @@ buildscript { allprojects { repositories { google() - jcenter() - + mavenCentral() } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 31680f1d6..6348c7266 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip