diff --git a/.github/workflows/AndroidBuild.yml b/.github/workflows/AndroidBuild.yml new file mode 100644 index 000000000..5caefb0be --- /dev/null +++ b/.github/workflows/AndroidBuild.yml @@ -0,0 +1,37 @@ +name: AndroidBuild +on: + push: + branches: [ '**' ] + pull_request: + branches: [ '**' ] + +jobs : + build : + runs-on : ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Setup Java JDK + uses: actions/setup-java@v4.5.0 + with: + java-version: '17' + distribution: 'zulu' + + - name: Run Lint + run: ./gradlew lint + + - name: Run Detekt + run: ./gradlew detekt + + - name: Build with Gradle + run: ./gradlew build + + - name: Run Tests + run: ./gradlew test + + - name: Upload a Build Artifact + uses: actions/upload-artifact@v4.4.3 + with: + name: DesafioAndroid.apk + path: app/build/outputs/apk/debug/app-debug.apk \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2b75303ac..0430edd5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,55 @@ +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +*.apk +output.json + +# IDE *.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/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml + +# Keystore files +*.jks +*.keystore + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Android Profiling +*.hprof \ No newline at end of file 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/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 6e6eec114..000000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/dictionaries/mdime.xml b/.idea/dictionaries/mdime.xml deleted file mode 100644 index 8cfcdaab3..000000000 --- a/.idea/dictionaries/mdime.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - moshi - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index 15a15b218..000000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ 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/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f4..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..c80d39ab6 --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +# Define variables +GRADLEW = gradlew.bat +ADB = adb + +# Default target (runs `make build`) +all: build + +# Build the debug APK +build: + $(GRADLEW) assembleDebug + +# Run unit and instrumented tests +all-tests: + $(GRADLEW) test + $(GRADLEW) uitest + +# Run unit tests +test: + $(GRADLEW) test + +# Run instrumentation tests on a connected device/emulator +uitest: + make start-emulator + $(GRADLEW) connectedAndroidTest + +# Run lint and detekt +lint: + $(GRADLEW) lint + $(GRADLEW) detekt + +# Clean the project +clean: + $(GRADLEW) clean + +# Install the debug APK on a connected device/emulator +install: + $(ADB) install -r app/build/outputs/apk/debug/app-debug.apk + +# Uninstall the app from the device/emulator +uninstall: + $(ADB) uninstall com.example.yourapp + +# Open the logcat to debug +logcat: + $(ADB) logcat -s "YourAppTag" + +start-emulator: + @echo "Starting emulator..." + adb devices | findstr emulator || start cmd /c "emulator -avd Medium_Phone_API_34 -no-snapshot-load" + @echo "Waiting for device to be ready..." + adb wait-for-device + +.PHONY: all build release test uitest clean install uninstall logcat start-emulator diff --git a/app/build.gradle b/app/build.gradle index a7fbdc0e9..bbe464d5d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,17 +1,20 @@ -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-kapt') + id('kotlin-parcelize') + id('org.jetbrains.kotlin.plugin.compose') + id('io.gitlab.arturbosch.detekt') +} android { - compileSdkVersion 29 + namespace = "com.picpay.desafio.android" + defaultConfig { applicationId "com.picpay.desafio.android" - minSdkVersion 21 - targetSdkVersion 29 + compileSdk 34 + minSdkVersion 24 + targetSdkVersion 34 versionCode 1 versionName "1.0" @@ -19,6 +22,7 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + buildTypes { debug {} @@ -30,15 +34,55 @@ android { } compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8.toString() + jvmTarget = JavaVersion.VERSION_17.toString() + } + + buildFeatures { + compose true + } + + kapt { + correctErrorTypes true + + arguments { + arg("room.schemaLocation", "$projectDir/schemas".toString()) + } + } + + lint { + abortOnError = false + warningsAsErrors = true + checkReleaseBuilds = false + disable.add("UnusedResources") + enable.add("ObsoleteSdkInt") + lintConfig = file("lint.xml") + } + + packagingOptions { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + merges += "META-INF/LICENSE.md" + merges += "META-INF/LICENSE-notice.md" + } } } +detekt { + config = files("$rootDir/config/detekt/detekt.yml") + buildUponDefaultConfig = true + allRules = false + autoCorrect = true +} + +tasks.named("check") { + dependsOn("detekt") +} + dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) @@ -46,17 +90,15 @@ dependencies { implementation "androidx.core:core-ktx:$core_ktx_version" - implementation "androidx.appcompat:appcompat:$appcompat_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" - - implementation "com.google.dagger:dagger:$dagger_version" - kapt "com.google.dagger:dagger-compiler:$dagger_version" + implementation(project.dependencies.platform("io.insert-koin:koin-bom:$koin_version")) + implementation "io.insert-koin:koin-core" + implementation "io.insert-koin:koin-core-coroutines" + implementation "io.insert-koin:koin-android" + implementation "io.insert-koin:koin-androidx-compose" + implementation "io.insert-koin:koin-androidx-compose-navigation" + implementation "io.insert-koin:koin-androidx-workmanager" + implementation "io.insert-koin:koin-androidx-navigation" + implementation "io.insert-koin:koin-test" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" @@ -69,24 +111,38 @@ dependencies { implementation "io.reactivex.rxjava2:rxjava:$rxjava_version" implementation "io.reactivex.rxjava2:rxandroid:$rxandroid_version" - implementation 'com.google.code.gson:gson:2.8.6' + implementation "com.google.code.gson:gson:$gson_version" 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" - implementation "com.squareup.picasso:picasso:$picasso_version" - implementation "de.hdodenhof:circleimageview:$circleimageview_version" + implementation(platform "com.squareup.okhttp3:okhttp-bom:$okhttp_version") + implementation "com.squareup.okhttp3:okhttp" + implementation"com.squareup.okhttp3:logging-interceptor" + testImplementation "com.squareup.okhttp3:mockwebserver" + implementation 'androidx.compose.ui:ui-test-junit4-android:1.7.8' testImplementation "junit:junit:$junit_version" - testImplementation "org.mockito:mockito-core:$mockito_version" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_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.espresso:espresso-core:$espresso_version" - androidTestImplementation "androidx.test:core-ktx:$core_ktx_test_version" -} + testImplementation("io.mockk:mockk:$mockk_version") + androidTestImplementation("io.mockk:mockk-android:$mockk_version") + + implementation(platform("androidx.compose:compose-bom:$compose_version")) + androidTestImplementation(platform("androidx.compose:compose-bom:$compose_version")) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_viewmodel_compose_version") + implementation("androidx.compose.runtime:runtime-livedata") + implementation("androidx.compose.runtime:runtime-rxjava2") + + implementation("io.coil-kt.coil3:coil-compose:$coil_version") + implementation("io.coil-kt.coil3:coil-network-okhttp:$coil_version") + + implementation("androidx.room:room-runtime:$room_version") + kapt("androidx.room:room-compiler:$room_version") + implementation("androidx.room:room-ktx:$room_version") +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt deleted file mode 100644 index e4a4978eb..000000000 --- a/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -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.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 org.junit.Test - - -class MainActivityTest { - - private val server = MockWebServer() - - private val context = InstrumentationRegistry.getInstrumentation().targetContext - - @Test - fun shouldDisplayTitle() { - launchActivity().apply { - val expectedTitle = context.getString(R.string.title) - - moveToState(Lifecycle.State.RESUMED) - - onView(withText(expectedTitle)).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() - } - - 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/RecyclerViewMatchers.kt b/app/src/androidTest/java/com/picpay/desafio/android/RecyclerViewMatchers.kt deleted file mode 100644 index 62be92ebd..000000000 --- a/app/src/androidTest/java/com/picpay/desafio/android/RecyclerViewMatchers.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.picpay.desafio.android - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.Espresso -import androidx.test.espresso.assertion.ViewAssertions -import androidx.test.espresso.matcher.BoundedMatcher -import androidx.test.espresso.matcher.ViewMatchers -import org.hamcrest.Description -import org.hamcrest.Matcher - -object RecyclerViewMatchers { - - fun atPosition( - position: Int, - itemMatcher: Matcher - ) = object : BoundedMatcher(RecyclerView::class.java) { - override fun describeTo(description: Description?) { - description?.appendText("has item at position $position: ") - itemMatcher.describeTo(description) - } - - override fun matchesSafely(item: RecyclerView?): Boolean { - val viewHolder = item?.findViewHolderForAdapterPosition(position) ?: return false - return itemMatcher.matches(viewHolder.itemView) - } - } - - fun checkRecyclerViewItem(resId: Int, position: Int, withMatcher: Matcher) { - Espresso.onView(ViewMatchers.withId(resId)).check( - ViewAssertions.matches( - atPosition( - position, - ViewMatchers.hasDescendant(withMatcher) - ) - ) - ) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/data/features/contacts/local/ContactDatabaseTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/data/features/contacts/local/ContactDatabaseTest.kt new file mode 100644 index 000000000..fca237389 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/data/features/contacts/local/ContactDatabaseTest.kt @@ -0,0 +1,103 @@ +package com.picpay.desafio.android.data.features.contacts.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.picpay.desafio.android.data.features.contacts.local.dao.ContactDao +import com.picpay.desafio.android.data.features.contacts.local.database.ContactDatabase +import com.picpay.desafio.android.data.features.contacts.local.entities.database.ContactDb +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class ContactDatabaseTest { + private lateinit var dbContacts: List + private lateinit var dao: ContactDao + private lateinit var contactDatabase: ContactDatabase + + @Before + fun createInstances() { + val contact = ContactDb( + id = 1, + name = "Rodrigo M", + username = "rodrigom", + image = "https://example.com/rodrigom.jpg" + ) + + dbContacts = List(3) { index -> + contact.copy( + id = index + 1, + name = "Rodrigo M ${index + 1}", + username = "rodrigom${index + 1}", + image = "https://example.com/rodrigom${index + 1}.jpg" + ) + } + } + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + contactDatabase = Room.inMemoryDatabaseBuilder( + context, + ContactDatabase::class.java + ).build() + + dao = contactDatabase.dao + } + + @After + @Throws(IOException::class) + fun closeDb() { + contactDatabase.close() + } + + @Test + fun should_write_in_database() { + lateinit var result: List + + runBlocking { + result = dao.queryContacts().first() + } + assertTrue(result.isEmpty()) + + runBlocking { + dao.insertAll(dbContacts) + result = dao.queryContacts().first() + } + assertTrue(result.isNotEmpty()) + } + + @Test + fun should_read_from_database() { + lateinit var result: ContactDb + + runBlocking { + dao.deleteAll() + + dao.insertAll(dbContacts.subList(0, 1)) + result = dao.queryContacts().first()[0] + } + + assertEquals(result.id, dbContacts[0].id) + } + + @Test + fun should_clear_database() { + lateinit var result: List + + runBlocking { + dao.insertAll(dbContacts) + dao.deleteAll() + result = dao.queryContacts().first() + } + assertTrue(result.isEmpty()) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactComposableTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactComposableTest.kt new file mode 100644 index 000000000..157436550 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactComposableTest.kt @@ -0,0 +1,46 @@ +package com.picpay.desafio.android.presentation.ui.features.contacts.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.picpay.desafio.android.domain.features.contacts.model.Contact +import org.junit.Rule +import org.junit.Test + +class ContactComposableTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun assert_contact_properties_are_displayed_correctly() { + // Given + val contact = Contact( + id = 1, + name = "Rodrigo Murta", + username = "rodrigo.mmurta", + image = "https://randomuser.me/api/portraits/men/1.jpg" + ) + + // When + composeTestRule.setContent { + ContactComposable( + modifier = Modifier, + contact = contact + ) + } + + // Then + composeTestRule.onNodeWithTag(IMAGE_TAG) + .assertIsDisplayed() + + composeTestRule.onNodeWithTag(USERNAME_TAG) + .assertIsDisplayed() + .assertTextEquals("rodrigo.mmurta") + + composeTestRule.onNodeWithTag(NAME_TAG) + .assertIsDisplayed() + .assertTextEquals("Rodrigo Murta") + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/CacheAlertComposableTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/CacheAlertComposableTest.kt new file mode 100644 index 000000000..5b00ba12c --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/CacheAlertComposableTest.kt @@ -0,0 +1,33 @@ +package com.picpay.desafio.android.presentation.ui.utils.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.picpay.desafio.android.domain.utils.ErrorInformation +import org.junit.Rule +import org.junit.Test + +class CacheAlertComposableTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun assert_cache_alert_is_displayed_correctly() { + // Given + val errorMessage = "Ocorreu um erro. Exibindo dados em cache." + + // When + composeTestRule.setContent { + CacheAlertComposable( + error = ErrorInformation( + message = errorMessage + ) + ) + } + + // Then + composeTestRule.onNodeWithTag(TEXT_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(TEXT_TAG).assertTextEquals(errorMessage) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/ErrorFeedbackComposableKtTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/ErrorFeedbackComposableKtTest.kt new file mode 100644 index 000000000..7f91fc400 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/ErrorFeedbackComposableKtTest.kt @@ -0,0 +1,61 @@ +package com.picpay.desafio.android.presentation.ui.utils.compose + +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertAny +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import com.picpay.desafio.android.presentation.ui.utils.FeedbackListener +import io.mockk.mockk +import io.mockk.verify +import org.junit.Rule +import org.junit.Test + +private const val ERROR_FEEDBACK_TAG = "errorFeedbackTag" + +class ErrorFeedbackComposableKtTest { + @get:Rule + val composeTestRule = createComposeRule() + + // Prepare + private val listener = mockk(relaxed = true) + + private fun setContent() = composeTestRule.setContent { + ErrorFeedbackComposable( + modifier = Modifier.testTag(ERROR_FEEDBACK_TAG), + listener = listener, + ) + } + + @Test + fun error_should_be_displayed() { + // Given + setContent() + + // Then + composeTestRule.onNodeWithTag(ERROR_FEEDBACK_TAG) + .assertIsDisplayed() + .onChildren() + .assertAny((hasTestTag(ERROR_TEXT_TAG))) + .assertAny((hasTestTag(ERROR_BUTTON_TAG))) + } + + @Test + fun listener_should_be_called_when_button_clicked() { + // Given + setContent() + + // When + composeTestRule.onNodeWithTag(ERROR_BUTTON_TAG) + .assertHasClickAction() + .performClick() + + // Then + verify(exactly = 1) { listener.onButtonClicked() } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/ScreenComposableKtTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/ScreenComposableKtTest.kt new file mode 100644 index 000000000..2bbd9d0e3 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/presentation/ui/utils/compose/ScreenComposableKtTest.kt @@ -0,0 +1,115 @@ +package com.picpay.desafio.android.presentation.ui.utils.compose + +import androidx.compose.material3.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.picpay.desafio.android.domain.utils.ErrorInformation +import com.picpay.desafio.android.domain.utils.State +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.CACHE_TAG +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.ERROR_TAG +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.LOADING_IMAGE_TAG +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.LOADING_TAG +import com.picpay.desafio.android.presentation.ui.utils.FeedbackListener +import io.mockk.mockk +import org.junit.Rule +import org.junit.Test + +private const val SUCCESS_TAG = "successTag" + +class ScreenComposableTest { + @get:Rule + val composeTestRule = createComposeRule() + + // Prepare + private val listener = mockk(relaxed = true) + + private fun setCompose(screenState: State) { + composeTestRule.setContent { + ScreenComposable( + screenState = screenState, + listener = listener, + toolbar = {}, + successContent = { + Text(text = "Success Content", modifier = Modifier.testTag(SUCCESS_TAG)) + } + ) + } + } + + @Test + fun screenComposable_should_display_loading_content_when_state_is_Loading() { + // Given + val state = State.Loading + + // When + setCompose(state) + + // Then + composeTestRule.onNodeWithTag(LOADING_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(LOADING_IMAGE_TAG).assertIsDisplayed() + + composeTestRule.onNodeWithTag(ERROR_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(CACHE_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(SUCCESS_TAG).assertDoesNotExist() + } + + @Test + fun screenComposable_should_display_error_content_when_state_is_Error() { + // Given + val state = State.Error( + error = ErrorInformation( + message = "Ocorreu um erro, tente novamente." + ) + ) + + // When + setCompose(state) + + // Then + composeTestRule.onNodeWithTag(ERROR_TAG).assertIsDisplayed() + + composeTestRule.onNodeWithTag(LOADING_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(CACHE_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(SUCCESS_TAG).assertDoesNotExist() + } + + @Test + fun screenComposable_should_display_cache_alert_and_success_content_when_state_is_ErrorWithCache() { + // Given + val state = State.ErrorWithCache( + error = ErrorInformation( + message = "Não foi possível conectar ao servidor. Exibindo dados em cache." + ) + ) + + // When + setCompose(state) + + // Then + composeTestRule.onNodeWithTag(CACHE_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(SUCCESS_TAG).assertIsDisplayed() + + composeTestRule.onNodeWithTag(ERROR_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(LOADING_TAG).assertDoesNotExist() + } + + @Test + fun screenComposable_should_display_success_content_when_state_is_Success() { + // Given + val state = State.Success(Any()) + + // When + setCompose(state) + + // Then + composeTestRule.onNodeWithTag(SUCCESS_TAG).assertIsDisplayed() + + composeTestRule.onNodeWithTag(LOADING_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(ERROR_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(CACHE_TAG).assertDoesNotExist() + + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7bdf2ce38..96b624331 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/App.kt b/app/src/main/java/com/picpay/desafio/android/App.kt new file mode 100644 index 000000000..644e52c31 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/App.kt @@ -0,0 +1,20 @@ +package com.picpay.desafio.android + +import android.app.Application +import com.picpay.desafio.android.data.di.DataModule +import com.picpay.desafio.android.domain.di.DomainModule +import com.picpay.desafio.android.presentation.di.PresentationModule +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +class App : Application() { + override fun onCreate() { + super.onCreate() + + startKoin { androidContext(this@App) } + + DomainModule.load() + DataModule.load() + PresentationModule.load() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/MainActivity.kt b/app/src/main/java/com/picpay/desafio/android/MainActivity.kt deleted file mode 100644 index 2447de98d..000000000 --- a/app/src/main/java/com/picpay/desafio/android/MainActivity.kt +++ /dev/null @@ -1,75 +0,0 @@ -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 - -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()!! - } - }) - } -} 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/User.kt b/app/src/main/java/com/picpay/desafio/android/User.kt deleted file mode 100644 index aa28171c9..000000000 --- a/app/src/main/java/com/picpay/desafio/android/User.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.picpay.desafio.android - -import android.os.Parcelable -import com.google.gson.annotations.SerializedName -import kotlinx.android.parcel.Parcelize - -@Parcelize -data class User( - @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/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/data/di/DaoModule.kt b/app/src/main/java/com/picpay/desafio/android/data/di/DaoModule.kt new file mode 100644 index 000000000..c253afb22 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/di/DaoModule.kt @@ -0,0 +1,13 @@ +package com.picpay.desafio.android.data.di + +import com.picpay.desafio.android.data.features.contacts.local.database.ContactDatabase +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +object DaoModule { + fun getModule() = dao + + private val dao = module { + single { ContactDatabase.getInstance(androidContext()).dao } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/di/DataModule.kt b/app/src/main/java/com/picpay/desafio/android/data/di/DataModule.kt new file mode 100644 index 000000000..383f474c9 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/di/DataModule.kt @@ -0,0 +1,37 @@ +package com.picpay.desafio.android.data.di + +import android.util.Log +import com.google.gson.GsonBuilder +import com.picpay.desafio.android.data.utils.Constants.OK_HTTP +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module + +object DataModule { + fun load() = loadKoinModules( + listOf( + RepositoryModule.getModule(), + ServiceModule.getModule(), + DaoModule.getModule(), + networkModule, + ) + ) + + private val networkModule = module { + single { createOkHttpClient() } + + single { GsonBuilder().create() } + } + + private fun createOkHttpClient(): OkHttpClient { + val interceptor = HttpLoggingInterceptor { + Log.i(OK_HTTP, it) + } + interceptor.level = HttpLoggingInterceptor.Level.BODY + + return OkHttpClient.Builder() + .addInterceptor(interceptor) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/di/RepositoryModule.kt b/app/src/main/java/com/picpay/desafio/android/data/di/RepositoryModule.kt new file mode 100644 index 000000000..291250661 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/di/RepositoryModule.kt @@ -0,0 +1,18 @@ +package com.picpay.desafio.android.data.di + +import com.picpay.desafio.android.data.features.contacts.repository.ContactsRepositoryImpl +import com.picpay.desafio.android.domain.features.contacts.repository.ContactsRepository +import org.koin.dsl.module + +object RepositoryModule { + fun getModule() = repository + + private val repository = module { + single { + ContactsRepositoryImpl( + service = get(), + dao = get() + ) + } + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/data/di/ServiceModule.kt b/app/src/main/java/com/picpay/desafio/android/data/di/ServiceModule.kt new file mode 100644 index 000000000..272747a47 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/di/ServiceModule.kt @@ -0,0 +1,31 @@ +package com.picpay.desafio.android.data.di + +import com.google.gson.Gson +import com.picpay.desafio.android.data.features.contacts.service.ContactsService +import com.picpay.desafio.android.data.utils.Constants +import okhttp3.OkHttpClient +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +object ServiceModule { + fun getModule() = service + + private inline fun createService( + client: OkHttpClient, + converterFactory: Gson, + ): T = Retrofit.Builder() + .baseUrl(Constants.BASE_URL) + .client(client) + .addConverterFactory( + GsonConverterFactory.create( + converterFactory + ) + ) + .build() + .create(T::class.java) + + private val service = module { + single { createService(get(), get()) } + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/dao/ContactDao.kt b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/dao/ContactDao.kt new file mode 100644 index 000000000..f0abc058b --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/dao/ContactDao.kt @@ -0,0 +1,20 @@ +package com.picpay.desafio.android.data.features.contacts.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.picpay.desafio.android.data.features.contacts.local.entities.database.ContactDb +import kotlinx.coroutines.flow.Flow + +@Dao +interface ContactDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(list: List) + + @Query("SELECT * FROM contact") + fun queryContacts(): Flow> + + @Query("DELETE FROM contact") + suspend fun deleteAll() +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/database/ContactDatabase.kt b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/database/ContactDatabase.kt new file mode 100644 index 000000000..b485712d6 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/database/ContactDatabase.kt @@ -0,0 +1,39 @@ +package com.picpay.desafio.android.data.features.contacts.local.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.picpay.desafio.android.data.features.contacts.local.dao.ContactDao +import com.picpay.desafio.android.data.features.contacts.local.entities.database.ContactDb + +@Database( + entities = [ContactDb::class], + version = 1, + exportSchema = false +) + +abstract class ContactDatabase : RoomDatabase() { + abstract val dao: ContactDao + + companion object { + @Volatile + private var INSTANCE: ContactDatabase? = null + + fun getInstance(context: Context): ContactDatabase { + synchronized(this) { + var instance = INSTANCE + if (instance == null) { + instance = Room.databaseBuilder( + context.applicationContext, + ContactDatabase::class.java, + "contact_cache_db" + ).fallbackToDestructiveMigration() + .build() + INSTANCE = instance + } + return instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/entities/database/ContactDb.kt b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/entities/database/ContactDb.kt new file mode 100644 index 000000000..c3caf1177 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/local/entities/database/ContactDb.kt @@ -0,0 +1,25 @@ +package com.picpay.desafio.android.data.features.contacts.local.entities.database + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.picpay.desafio.android.domain.features.contacts.model.Contact + +@Entity(tableName = "contact") +data class ContactDb( + @PrimaryKey + val id: Int, + val name: String, + val username: String, + val image: String +) + +fun ContactDb.toDomain() = Contact( + id = id, + name = name, + username = username, + image = image +) + +fun List.toDomain() = this.map { + it.toDomain() +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/features/contacts/model/ContactRemote.kt b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/model/ContactRemote.kt new file mode 100644 index 000000000..0f0bcd4c8 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/model/ContactRemote.kt @@ -0,0 +1,37 @@ +package com.picpay.desafio.android.data.features.contacts.model + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import com.picpay.desafio.android.data.features.contacts.local.entities.database.ContactDb +import com.picpay.desafio.android.domain.features.contacts.model.Contact +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ContactRemote( + @SerializedName("id") val id: Int?, + @SerializedName("name") val name: String?, + @SerializedName("username") val username: String?, + @SerializedName("img") val image: String? +) : Parcelable + +fun ContactRemote.toDomain() = Contact( + id = id ?: 0, + name = name.orEmpty(), + username = username.orEmpty(), + image = image.orEmpty() +) + +fun List.toDomain() = this.map { + it.toDomain() +} + +fun ContactRemote.toDb() = ContactDb( + id = id ?: 0, + name = name.orEmpty(), + username = username.orEmpty(), + image = image.orEmpty() +) + +fun List.toDb() = this.map { + it.toDb() +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/features/contacts/repository/ContactsRepositoryImpl.kt b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/repository/ContactsRepositoryImpl.kt new file mode 100644 index 000000000..43b9540cb --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/repository/ContactsRepositoryImpl.kt @@ -0,0 +1,28 @@ +package com.picpay.desafio.android.data.features.contacts.repository + +import com.picpay.desafio.android.data.features.contacts.local.dao.ContactDao +import com.picpay.desafio.android.data.features.contacts.local.entities.database.toDomain +import com.picpay.desafio.android.data.features.contacts.model.toDb +import com.picpay.desafio.android.data.features.contacts.service.ContactsService +import com.picpay.desafio.android.data.utils.networkAdapter +import com.picpay.desafio.android.domain.features.contacts.model.Contact +import com.picpay.desafio.android.domain.features.contacts.repository.ContactsRepository +import com.picpay.desafio.android.domain.utils.State +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class ContactsRepositoryImpl( + private val service: ContactsService, + private val dao: ContactDao, +) : ContactsRepository { + override suspend fun getContacts(endpoint: String): Flow>> = + networkAdapter( + query = { dao.queryContacts().map { it.toDomain() } }, + get = { service.getContacts(endpoint) }, + saveGetResult = { listContactDb -> + dao.deleteAll() + dao.insertAll(listContactDb.toDb()) + }, + ) +} + diff --git a/app/src/main/java/com/picpay/desafio/android/data/features/contacts/service/ContactsService.kt b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/service/ContactsService.kt new file mode 100644 index 000000000..3213ef446 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/features/contacts/service/ContactsService.kt @@ -0,0 +1,13 @@ +package com.picpay.desafio.android.data.features.contacts.service + +import com.picpay.desafio.android.data.features.contacts.model.ContactRemote +import retrofit2.http.GET +import retrofit2.http.Path + +interface ContactsService { + @GET("{endpoint}") + suspend fun getContacts( + @Path("endpoint") + endpoint: String + ): List +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/utils/Constants.kt b/app/src/main/java/com/picpay/desafio/android/data/utils/Constants.kt new file mode 100644 index 000000000..c7602bf87 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/utils/Constants.kt @@ -0,0 +1,6 @@ +package com.picpay.desafio.android.data.utils + +object Constants { + internal const val BASE_URL = "https://609a908e0f5a13001721b74e.mockapi.io/picpay/api/" + internal const val OK_HTTP = "Ok Http" +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/utils/NetworkAdapter.kt b/app/src/main/java/com/picpay/desafio/android/data/utils/NetworkAdapter.kt new file mode 100644 index 000000000..d109a382e --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/utils/NetworkAdapter.kt @@ -0,0 +1,41 @@ +package com.picpay.desafio.android.data.utils + +import com.picpay.desafio.android.domain.utils.State +import com.picpay.desafio.android.domain.utils.toErrorInformation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow + +inline fun networkAdapter( + crossinline query: () -> Flow, + crossinline get: suspend () -> Request, + crossinline saveGetResult: suspend (Request) -> Unit, +): Flow> = flow { + var data = query().first() + + try { + saveGetResult(get()) + data = query().first() + + } catch (error: Exception) { + if (data != emptyList() && data != null) { + emit( + State.ErrorWithCache( + data, + error.toErrorInformation( + "Não foi possível conectar ao servidor. Exibindo dados em cache." + ), + ) + ) + } else { + emit( + State.Error( + error = error.toErrorInformation( + "Não foi possível conectar ao servidor." + ), + ) + ) + } + } + emit(State.Success(data)) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/di/DomainModule.kt b/app/src/main/java/com/picpay/desafio/android/domain/di/DomainModule.kt new file mode 100644 index 000000000..8798a1bfa --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/di/DomainModule.kt @@ -0,0 +1,13 @@ +package com.picpay.desafio.android.domain.di + +import com.picpay.desafio.android.domain.features.contacts.usecase.GetContactsUseCase +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module + +object DomainModule { + fun load() = loadKoinModules(useCaseModule) + + private val useCaseModule = module { + factory { GetContactsUseCase(get()) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/model/Contact.kt b/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/model/Contact.kt new file mode 100644 index 000000000..b65057c7a --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/model/Contact.kt @@ -0,0 +1,19 @@ +package com.picpay.desafio.android.domain.features.contacts.model + +import androidx.compose.runtime.Immutable +import com.picpay.desafio.android.domain.utils.State +import com.picpay.desafio.android.domain.utils.StateBearer +import java.io.Serializable + +data class ContactsScreen( + override val state: State = State.Loading, + val contacts: List? = null +) : Serializable, StateBearer + +@Immutable +data class Contact( + val id: Int, + val name: String, + val username: String, + val image: String +) : Serializable \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/repository/ContactsRepository.kt b/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/repository/ContactsRepository.kt new file mode 100644 index 000000000..cc6d43588 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/repository/ContactsRepository.kt @@ -0,0 +1,9 @@ +package com.picpay.desafio.android.domain.features.contacts.repository + +import com.picpay.desafio.android.domain.features.contacts.model.Contact +import com.picpay.desafio.android.domain.utils.State +import kotlinx.coroutines.flow.Flow + +interface ContactsRepository { + suspend fun getContacts(endpoint: String): Flow>> +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/usecase/GetContactsUseCase.kt b/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/usecase/GetContactsUseCase.kt new file mode 100644 index 000000000..22872d24a --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/features/contacts/usecase/GetContactsUseCase.kt @@ -0,0 +1,52 @@ +package com.picpay.desafio.android.domain.features.contacts.usecase + +import com.picpay.desafio.android.domain.features.contacts.model.ContactsScreen +import com.picpay.desafio.android.domain.features.contacts.repository.ContactsRepository +import com.picpay.desafio.android.domain.utils.State +import com.picpay.desafio.android.domain.utils.UseCase +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow + +class GetContactsUseCase( + private val repository: ContactsRepository +) : UseCase() { + override suspend fun execute( + param: String, + currentState: ContactsScreen, + ): Flow = flow { + emit(currentState.copy(state = State.Loading)) + + when (val result = repository.getContacts(param).first()) { + is State.Success -> { + emit( + currentState.copy( + state = State.Success(data = result.data), + contacts = result.data, + ) + ) + } + + is State.Error -> { + emit( + currentState.copy( + state = State.Error(error = result.error), + ) + ) + } + + is State.ErrorWithCache -> { + emit( + currentState.copy( + state = State.ErrorWithCache(data = result.data, error = result.error), + contacts = result.data, + ) + ) + } + + else -> { + emit(currentState.copy(state = State.Loading)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/utils/State.kt b/app/src/main/java/com/picpay/desafio/android/domain/utils/State.kt new file mode 100644 index 000000000..a4ecc355d --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/utils/State.kt @@ -0,0 +1,32 @@ +package com.picpay.desafio.android.domain.utils + +sealed class State { + data object Loading : State() + + data class Success(val data: T) : State() + + data class Error(val error: ErrorInformation) : State() + + data class ErrorWithCache( + val data: T? = null, + val error: ErrorInformation = ErrorInformation( + "Não foi possível conectar ao servidor. Exibindo dados em cache." + ), + ) : State() +} + +data class ErrorInformation( + val message: String? = null, + val cause: Throwable? = null, +) + +fun Exception.toErrorInformation(message: String? = null) = ErrorInformation( + message = message + ?: localizedMessage + ?: "Ocorreu um erro. Tente novamente.", + cause = this +) + +interface StateBearer { + val state: State +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/domain/utils/UseCase.kt b/app/src/main/java/com/picpay/desafio/android/domain/utils/UseCase.kt new file mode 100644 index 000000000..8d5874529 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/utils/UseCase.kt @@ -0,0 +1,15 @@ +package com.picpay.desafio.android.domain.utils + +import kotlinx.coroutines.flow.Flow + +abstract class UseCase { + abstract suspend fun execute( + param: K, + currentState: T, + ): Flow + + suspend operator fun invoke( + param: K, + currentState: T + ) = execute(param, currentState) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/MainActivity.kt b/app/src/main/java/com/picpay/desafio/android/presentation/MainActivity.kt new file mode 100644 index 000000000..041fafd0a --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/MainActivity.kt @@ -0,0 +1,30 @@ +package com.picpay.desafio.android.presentation + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.picpay.desafio.android.presentation.ui.features.contacts.ContactsViewModel +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.ContactsScreenComposable +import org.koin.androidx.viewmodel.ext.android.viewModel + +class MainActivity : ComponentActivity() { + private val viewModel: ContactsViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ContactsScreenComposable( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxSize(), + screen = viewModel.screen.collectAsState().value, + listener = viewModel + ) + } + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/di/PresentationModule.kt b/app/src/main/java/com/picpay/desafio/android/presentation/di/PresentationModule.kt new file mode 100644 index 000000000..acb56d363 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/di/PresentationModule.kt @@ -0,0 +1,20 @@ +package com.picpay.desafio.android.presentation.di + +import androidx.lifecycle.SavedStateHandle +import com.picpay.desafio.android.presentation.ui.features.contacts.ContactsViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module + +object PresentationModule { + fun load() = loadKoinModules(viewModelModule,) + + private val viewModelModule = module { + viewModel { (savedState: SavedStateHandle) -> + ContactsViewModel( + getContactsUseCase = get(), + savedState = savedState, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/ContactsListener.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/ContactsListener.kt new file mode 100644 index 000000000..c07e47f67 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/ContactsListener.kt @@ -0,0 +1,9 @@ +package com.picpay.desafio.android.presentation.ui.features.contacts + +import com.picpay.desafio.android.presentation.ui.utils.FeedbackListener + +interface ContactsListener : FeedbackListener + +object DummyContactsListener : ContactsListener { + override fun onButtonClicked() = Unit +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/ContactsViewModel.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/ContactsViewModel.kt new file mode 100644 index 000000000..084e4d405 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/ContactsViewModel.kt @@ -0,0 +1,54 @@ +package com.picpay.desafio.android.presentation.ui.features.contacts + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.picpay.desafio.android.domain.features.contacts.model.ContactsScreen +import com.picpay.desafio.android.domain.features.contacts.usecase.GetContactsUseCase +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +const val CONTACTS_STATE = "CONTACTS_STATE" +const val CONTACTS_PARAM = "users" + +class ContactsViewModel( + private val getContactsUseCase: GetContactsUseCase, + private val savedState: SavedStateHandle, +) : ViewModel(), ContactsListener { + private val _screen = MutableStateFlow(ContactsScreen()) + val screen: StateFlow = _screen.asStateFlow() + + init { + if (savedState.contains(CONTACTS_STATE).not()) { + getUsers() + } else { + savedState.get(CONTACTS_STATE)?.let { + viewModelScope.launch { + _screen.emit(it) + } + } + } + } + + private fun getUsers() { + viewModelScope.launch { + getContactsUseCase( + CONTACTS_PARAM, + screen.value + ) + .onEach { + savedState[CONTACTS_STATE] = it + } + .collect { + _screen.emit(it) + } + } + } + + override fun onButtonClicked() { + getUsers() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactComposable.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactComposable.kt new file mode 100644 index 000000000..6bbb6cd3c --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactComposable.kt @@ -0,0 +1,88 @@ +package com.picpay.desafio.android.presentation.ui.features.contacts.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import coil3.request.transformations +import coil3.transform.RoundedCornersTransformation +import com.picpay.desafio.android.R +import com.picpay.desafio.android.domain.features.contacts.model.Contact +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.fakedata.ContactFakeData + +const val IMAGE_TAG = "image" +const val USERNAME_TAG = "username" +const val NAME_TAG = "name" + +@Composable +fun ContactComposable( + contact: Contact, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .testTag(IMAGE_TAG), + model = ImageRequest.Builder(LocalContext.current) + .data(contact.image) + .memoryCachePolicy(CachePolicy.ENABLED) + .transformations(RoundedCornersTransformation(radius = 16f)) + .build(), + placeholder = painterResource(R.drawable.ic_round_account_circle), + contentDescription = stringResource(R.string.image_profile_description), + contentScale = ContentScale.Crop, + ) + + Column( + modifier = Modifier + .padding(start = 16.dp) + ) { + Text( + modifier = Modifier.testTag(USERNAME_TAG), + text = contact.username, + color = Color.White + ) + Text( + modifier = Modifier.testTag(NAME_TAG), + text = contact.name, + color = colorResource(R.color.colorDetail) + ) + } + } +} + +@Composable +@Preview("ContactPreview") +private fun ContactPreview( + @PreviewParameter(ContactFakeData::class) contact: Contact +) { + ContactComposable( + modifier = Modifier.wrapContentSize(), + contact = contact + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactsScreenComposable.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactsScreenComposable.kt new file mode 100644 index 000000000..da9c9f7c1 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/ContactsScreenComposable.kt @@ -0,0 +1,91 @@ +package com.picpay.desafio.android.presentation.ui.features.contacts.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.picpay.desafio.android.R +import com.picpay.desafio.android.domain.features.contacts.model.ContactsScreen +import com.picpay.desafio.android.presentation.ui.features.contacts.ContactsListener +import com.picpay.desafio.android.presentation.ui.features.contacts.DummyContactsListener +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.fakedata.ContactsScreenFakeData +import com.picpay.desafio.android.presentation.ui.utils.compose.ScreenComposable + +internal const val ERROR_TAG = "error" +internal const val LOADING_TAG = "loading" +internal const val SUCCESS_TAG = "success" +internal const val LOADING_IMAGE_TAG = "loading_image" +internal const val CACHE_TAG = "cache_tag" + +@Composable +fun ContactsScreenComposable( + screen: ContactsScreen, + listener: ContactsListener, + modifier: Modifier = Modifier, +) { + ScreenComposable( + screenState = screen.state, + listener = listener, + toolbar = { + Text( + modifier = Modifier.padding(24.dp), + text = stringResource(R.string.title), + color = Color.White, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + ) + }, + successContent = { + Column( + modifier = modifier + .testTag(SUCCESS_TAG) + ) { + screen.contacts?.let { contacts -> + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items( + items = contacts, + key = { it.id } + ) { contact -> + ContactComposable( + modifier = Modifier + .padding(vertical = 12.dp) + .fillMaxWidth(), + contact = contact, + ) + } + } + } + } + } + ) +} + + +@Composable +@Preview("ContactsScreenPreview") +private fun ContactsScreenPreview( + @PreviewParameter(ContactsScreenFakeData::class) data: ContactsScreen +) { + ContactsScreenComposable( + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxSize(), + screen = data, + listener = DummyContactsListener, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/fakedata/ContactFakeData.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/fakedata/ContactFakeData.kt new file mode 100644 index 000000000..0fc027c92 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/fakedata/ContactFakeData.kt @@ -0,0 +1,20 @@ +package com.picpay.desafio.android.presentation.ui.features.contacts.compose.fakedata + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.picpay.desafio.android.domain.features.contacts.model.Contact + +class ContactFakeData : PreviewParameterProvider { + private fun contact(id: Int): Contact { + return Contact( + id = id, + name = "Rodrigo Murta", + username = "rodrigo.mmurta$id", + image = "https://randomuser.me/api/portraits/men/${id + 1}.jpg" + ) + } + + override val values: Sequence + get() = List(size = 10) { index -> + contact(index) + }.asSequence() +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/fakedata/ContactsScreenFakeData.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/fakedata/ContactsScreenFakeData.kt new file mode 100644 index 000000000..9196903b1 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/features/contacts/compose/fakedata/ContactsScreenFakeData.kt @@ -0,0 +1,54 @@ +package com.picpay.desafio.android.presentation.ui.features.contacts.compose.fakedata + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.picpay.desafio.android.domain.features.contacts.model.Contact +import com.picpay.desafio.android.domain.features.contacts.model.ContactsScreen +import com.picpay.desafio.android.domain.utils.ErrorInformation +import com.picpay.desafio.android.domain.utils.State + +class ContactsScreenFakeData : PreviewParameterProvider { + private fun contact(id: Int): Contact { + return Contact( + id = id, + name = "Rodrigo Murta", + username = "rodrigo.mmurta$id", + image = "https://randomuser.me/api/portraits/men/${id + 1}.jpg" + ) + } + + private fun listContacts(size: Int): List = List(size) { index -> + contact(index) + } + + private val successScreen = ContactsScreen( + state = State.Success(data = listContacts(size = 10)), + contacts = listContacts(size = 10) + ) + + private val loadingScreen = successScreen.copy(state = State.Loading) + + private val errorWithCacheScreen = successScreen.copy( + state = State.ErrorWithCache( + error = ErrorInformation( + message = "Não foi possível conectar ao servidor. Exibindo dados em cache." + ) + ) + ) + + private val errorWithoutCacheScreen = ContactsScreen( + state = State.Error( + error = ErrorInformation( + message = "Ocorreu um erro, tente novamente." + ) + ), + contacts = null, + ) + + override val values: Sequence + get() = sequenceOf( + successScreen, + loadingScreen, + errorWithCacheScreen, + errorWithoutCacheScreen + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/FeedbackListener.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/FeedbackListener.kt new file mode 100644 index 000000000..299bb99e2 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/FeedbackListener.kt @@ -0,0 +1,5 @@ +package com.picpay.desafio.android.presentation.ui.utils + +interface FeedbackListener { + fun onButtonClicked() +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/CacheAlertComposable.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/CacheAlertComposable.kt new file mode 100644 index 000000000..607954547 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/CacheAlertComposable.kt @@ -0,0 +1,64 @@ +package com.picpay.desafio.android.presentation.ui.utils.compose + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.picpay.desafio.android.R +import com.picpay.desafio.android.domain.utils.ErrorInformation + +const val TEXT_TAG = "text_tag" + +@Composable +fun CacheAlertComposable( + error: ErrorInformation, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardColors( + Color.Transparent, + Color.Transparent, + Color.Transparent, + Color.Transparent, + ), + border = BorderStroke( + width = 1.dp, + color = colorResource(R.color.colorAccent) + ) + ) { + Text( + modifier = Modifier + .padding(8.dp) + .testTag(TEXT_TAG), + text = error.message.orEmpty(), + color = Color.White, + fontSize = 14.sp, + textAlign = TextAlign.Center, + style = TextStyle() + ) + } +} + +@Composable +@Preview +private fun CacheAlertPreview() { + CacheAlertComposable( + modifier = Modifier.padding(top = 12.dp), + error = ErrorInformation( + stringResource(R.string.errorWithCache) + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/ErrorFeedbackComposable.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/ErrorFeedbackComposable.kt new file mode 100644 index 000000000..f668942b4 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/ErrorFeedbackComposable.kt @@ -0,0 +1,81 @@ +package com.picpay.desafio.android.presentation.ui.utils.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.picpay.desafio.android.R +import com.picpay.desafio.android.presentation.ui.features.contacts.DummyContactsListener +import com.picpay.desafio.android.presentation.ui.utils.FeedbackListener + +const val ERROR_TEXT_TAG = "errorText" +const val ERROR_BUTTON_TAG = "errorButton" + +@Composable +fun ErrorFeedbackComposable( + errorMessage: String = stringResource(R.string.error), + buttonTitle: String = stringResource(R.string.reload), + listener: FeedbackListener, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically), + ) { + Text( + modifier = Modifier.testTag(ERROR_TEXT_TAG), + text = errorMessage, + color = Color.White, + fontSize = 16.sp, + textAlign = TextAlign.Center, + ) + + Button( + modifier = Modifier + .padding(horizontal = 24.dp) + .testTag(ERROR_BUTTON_TAG), + colors = ButtonColors( + containerColor = colorResource(R.color.colorAccent), + contentColor = Color.White, + disabledContentColor = colorResource(R.color.colorDetail), + disabledContainerColor = colorResource(R.color.colorPrimary), + ), + shape = RoundedCornerShape(8.dp), + onClick = { + listener.onButtonClicked() + }, + ) { + Text( + text = buttonTitle, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + ) + } + } +} + +@Composable +@Preview +private fun ErrorFeedbackComposablePreview() { + ErrorFeedbackComposable( + modifier = Modifier.fillMaxSize(), + listener = DummyContactsListener + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/ScreenComposable.kt b/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/ScreenComposable.kt new file mode 100644 index 000000000..35e0d030d --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/ui/utils/compose/ScreenComposable.kt @@ -0,0 +1,111 @@ +package com.picpay.desafio.android.presentation.ui.utils.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp +import com.picpay.desafio.android.R +import com.picpay.desafio.android.domain.utils.State +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.CACHE_TAG +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.ERROR_TAG +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.LOADING_IMAGE_TAG +import com.picpay.desafio.android.presentation.ui.features.contacts.compose.LOADING_TAG +import com.picpay.desafio.android.presentation.ui.utils.FeedbackListener + +@Composable +fun ScreenComposable( + screenState: State = State.Loading, + listener: FeedbackListener, + toolbar: @Composable () -> Unit, + loadingContent: @Composable () -> Unit = { + LoadingGeneric( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + ) + }, + errorContent: @Composable () -> Unit = { + ErrorGeneric( + screenState = screenState, + listener = listener + ) + }, + successContent: @Composable () -> Unit, +) { + Scaffold( + containerColor = colorResource(R.color.colorPrimaryDark) + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + toolbar() + + val errorWithoutCache = screenState is State.Error + + if (errorWithoutCache) { + errorContent() + } else if (screenState is State.Loading) { + loadingContent() + } else { + (screenState as? State.ErrorWithCache)?.let { errorWithCache -> + CacheAlertComposable( + modifier = Modifier + .padding( + top = 12.dp, + start = 24.dp, + end = 24.dp, + ) + .testTag(CACHE_TAG) + .clickable { listener.onButtonClicked() }, + error = errorWithCache.error + ) + } + + successContent() + } + } + } +} + +@Composable +private fun LoadingGeneric( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .testTag(LOADING_TAG), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator( + modifier = Modifier + .size(48.dp) + .testTag(LOADING_IMAGE_TAG), + color = colorResource(R.color.colorAccent) + ) + } +} + +@Composable +private fun ErrorGeneric( + screenState: State, + listener: FeedbackListener, +) { + val state = screenState as? State.Error + ErrorFeedbackComposable( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + .testTag(ERROR_TAG), + errorMessage = state?.error?.message.orEmpty(), + listener = listener, + ) +} \ 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 deleted file mode 100644 index 487ac549e..000000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - \ 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 deleted file mode 100644 index 587d40cc8..000000000 --- a/app/src/main/res/layout/list_item_user.xml +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39df3169e..4cf08d3e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,8 @@ PicPay Contatos Ocorreu um erro. Tente novamente. + Não foi possível conectar ao servidor. Exibindo dados em cache. + Recarregar + Imagem do perfil de um contato 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/data/features/contacts/model/ContactRemoteTest.kt b/app/src/test/java/com/picpay/desafio/android/data/features/contacts/model/ContactRemoteTest.kt new file mode 100644 index 000000000..dd992f60b --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/data/features/contacts/model/ContactRemoteTest.kt @@ -0,0 +1,78 @@ +package com.picpay.desafio.android.data.features.contacts.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContactRemoteTest { + + @Test + fun `toDomain should map ContactRemote to Contact with non-null values`() { + // Given + val contactRemote = ContactRemote( + id = 1, + name = "Rodrigo M", + username = "rodrigom", + image = "https://example.com/rodrigom.jpg" + ) + + // When + val contact = contactRemote.toDomain() + + // Then + assertEquals(1, contact.id) + assertEquals("Rodrigo M", contact.name) + assertEquals("rodrigom", contact.username) + assertEquals("https://example.com/rodrigom.jpg", contact.image) + } + + @Test + fun `toDomain should map ContactRemote to Contact with null values`() { + // Given + val contactRemote = ContactRemote( + id = null, + name = null, + username = null, + image = null + ) + + // When + val contact = contactRemote.toDomain() + + // Then + assertEquals(0, contact.id) + assertEquals("", contact.name) + assertEquals("", contact.username) + assertEquals("", contact.image) + } + + @Test + fun `toDomain should map list of ContactRemote to list of Contact`() { + // Given + val contactRemoteList = listOf( + ContactRemote(1, "Rodrigo M", "rodrigom", "https://example.com/rodrigom.jpg"), + ContactRemote(2, "Rodrigo MM", "rodrigomm", "https://example.com/rodrigomm.jpg"), + ContactRemote(null, null, null, null) + ) + + // When + val contactList = contactRemoteList.toDomain() + + // Then + assertEquals(3, contactList.size) + + assertEquals(1, contactList[0].id) + assertEquals("Rodrigo M", contactList[0].name) + assertEquals("rodrigom", contactList[0].username) + assertEquals("https://example.com/rodrigom.jpg", contactList[0].image) + + assertEquals(2, contactList[1].id) + assertEquals("Rodrigo MM", contactList[1].name) + assertEquals("rodrigomm", contactList[1].username) + assertEquals("https://example.com/rodrigomm.jpg", contactList[1].image) + + assertEquals(0, contactList[2].id) + assertEquals("", contactList[2].name) + assertEquals("", contactList[2].username) + assertEquals("", contactList[2].image) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7d1b94f34..58bbc43f4 100644 --- a/build.gradle +++ b/build.gradle @@ -2,56 +2,46 @@ buildscript { ext { - kotlin_version = '1.3.61' + kotlin_version = '2.0.21' - appcompat_version = '1.1.0' - core_ktx_version = '1.2.0' - core_testing_version = '2.1.0' - constraintlayout_version = '1.1.3' - material_version = "1.1.0" - moshi_version = '1.8.0' - retrofit_version = '2.7.1' - okhttp_version = '4.3.1' - picasso_version = '2.71828' - circleimageview_version = '3.0.0' - - junit_version = '4.12' - mockito_version = '2.27.0' - mockito_kotlin_version = '2.1.0' - - test_runner_version = '1.1.1' - espresso_version = '3.1.1' - - koin_version = "2.0.1" - dagger_version = "2.23.2" - lifecycle_version = "2.2.0" + core_ktx_version = '1.13.1' + koin_version = "3.5.6" + lifecycle_version = "2.8.7" coroutines_version = "1.3.3" rxjava_version = "2.2.17" rxandroid_version = "2.1.1" - core_ktx_test_version = "1.2.0" + gson_version = "2.10.1" + retrofit_version = '2.7.1' + okhttp_version = '4.12.0' + junit_version = '4.12' + core_testing_version = '2.2.0' + compose_version = "2024.10.01" + activity_compose_version = "1.9.3" + lifecycle_viewmodel_compose_version = "2.8.5" + coil_version = "3.0.2" + room_version = "2.6.1" + mockk_version = '1.13.16' + detekt_version = '1.23.1' } repositories { google() - jcenter() - + mavenCentral() } + dependencies { - classpath 'com.android.tools.build:gradle:3.5.3' + classpath 'com.android.tools.build:gradle:8.7.3' 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 } } -allprojects { - repositories { - google() - jcenter() - - } +plugins { + id("org.jetbrains.kotlin.plugin.compose") version "$kotlin_version" apply false + id("io.gitlab.arturbosch.detekt") version "$detekt_version" apply false } -task clean(type: Delete) { +tasks.register('clean', Delete) { delete rootProject.buildDir } diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 000000000..b73357402 --- /dev/null +++ b/config/detekt/detekt.yml @@ -0,0 +1,788 @@ +build: + maxIssues: 0 + excludeCorrectable: false + weights: + # complexity: 2 + # LongParameterList: 1 + # style: 1 + # comments: 1 + +config: + validation: true + warningsAsErrors: false + checkExhaustiveness: false + # when writing own rules with new properties, exclude the property path e.g.: 'my_rule_set,.*>.*>[my_property]' + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + # - 'KtFileCountProcessor' + # - 'PackageCountProcessor' + # - 'ClassCountProcessor' + # - 'FunctionCountProcessor' + # - 'PropertyCountProcessor' + # - 'ProjectComplexityProcessor' + # - 'ProjectCognitiveComplexityProcessor' + # - 'ProjectLLOCProcessor' + # - 'ProjectCLOCProcessor' + # - 'ProjectLOCProcessor' + # - 'ProjectSLOCProcessor' + # - 'LicenseHeaderLoaderExtension' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + # - 'LiteFindingsReport' + +output-reports: + active: true + exclude: + # - 'TxtOutputReport' + # - 'XmlOutputReport' + # - 'HtmlOutputReport' + # - 'MdOutputReport' + # - 'SarifOutputReport' + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + searchInProtectedClass: false + UndocumentedPublicFunction: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedFunction: false + UndocumentedPublicProperty: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedProperty: false + +complexity: + active: true + CognitiveComplexMethod: + active: false + threshold: 15 + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + ignoreOverloaded: false + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + ignoreAnnotated: ['Composable'] + LongParameterList: + active: true + functionThreshold: 6 + constructorThreshold: 7 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [] + ignoreAnnotated: ['Composable'] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: false + threshold: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + threshold: 4 + NestedScopeFunctions: + active: false + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + thresholdInFiles: 11 + thresholdInClasses: 11 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: true + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: false + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: false + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + privateParameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + FunctionNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + ignoreAnnotated: ['Composable'] + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: true + mustBeFirst: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + threshold: 3 + ForEachOnRange: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + SpreadOperator: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnnecessaryPartOfBinaryExpression: + active: false + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastNullableToNonNullableType: + active: false + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + ignoredSubjectTypes: [] + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - 'CheckResult' + - '*.CheckResult' + - 'CheckReturnValue' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' + - '*.CanIgnoreReturnValue' + returnValueTypes: + - 'kotlin.sequences.Sequence' + - 'kotlinx.coroutines.flow.*Flow' + - 'java.util.stream.*Stream' + ignoreFunctionCall: [] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: ['**/*.kts'] + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + PropertyUsedBeforeDeclaration: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullCheck: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: true + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: true + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: true + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + allowOperators: false + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' + ForbiddenComment: + active: true + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' + allowedPatterns: '' + ForbiddenImport: + active: false + imports: [] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - reason: 'print does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.print' + - reason: 'println does not allow you to configure the output stream. Use a logger instead.' + value: 'kotlin.io.println' + ForbiddenSuppress: + active: true + rules: [] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 140 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + MultilineRawStringIndentation: + active: false + indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: false + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 2 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: false + SpacingBetweenPackageAndImports: + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: true + TrimMultilineRawString: + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryBracesAroundTrailingLambda: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + allowForUnclearPrecedence: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: true + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '' + ignoreAnnotated: ['Preview'] + UnusedPrivateProperty: + active: false + allowedNames: '_|ignored|expected|serialVersionUID' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + ignoreWhenContainingVariableDeclaration: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: false + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: true + excludeImports: + - 'java.util.*' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 31680f1d6..1d7fbaf23 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sun Feb 16 19:36:17 BRT 2020 +#Mon Feb 17 22:32:20 BRT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/settings.gradle b/settings.gradle index e7b4def49..ff6d84fe7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,11 @@ +rootProject.name = "desafio-android" + include ':app' + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} \ No newline at end of file