diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..aa724b770 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.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 +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 000000000..12cd6dc87 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Bank App \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 000000000..4c3743a3c --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,141 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 000000000..79ee123c2 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..61a9130cd --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 000000000..d87b21200 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,25 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 000000000..a5f05cd8c --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..d5d35ec44 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Bank_App.postman_collection.json b/Bank_App.postman_collection.json deleted file mode 100644 index 53882a1c6..000000000 --- a/Bank_App.postman_collection.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "info": { - "_postman_id": "b90c97e1-4261-4a34-a348-a0604f0264a7", - "name": "Bank App", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "Login", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "name": "Content-Type", - "value": "application/x-www-form-urlencoded", - "type": "text" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "key": "user", - "value": "test_user", - "type": "text" - }, - { - "key": "password", - "value": "Test@1", - "type": "text" - } - ] - }, - "url": { - "raw": "https://bank-app-test.herokuapp.com/api/login", - "protocol": "https", - "host": [ - "bank-app-test", - "herokuapp", - "com" - ], - "path": [ - "api", - "login" - ] - } - }, - "response": [] - }, - { - "name": "Statements", - "request": { - "method": "GET", - "header": [ - { - "key": "Content-Type", - "name": "Content-Type", - "value": "application/x-www-form-urlencoded", - "type": "text" - } - ], - "body": { - "mode": "urlencoded", - "urlencoded": [ - { - "key": "user", - "value": "4", - "type": "text" - }, - { - "key": "password", - "value": "asdfa", - "type": "text" - } - ] - }, - "url": { - "raw": "https://bank-app-test.herokuapp.com/api/statements/1", - "protocol": "https", - "host": [ - "bank-app-test", - "herokuapp", - "com" - ], - "path": [ - "api", - "statements", - "1" - ] - } - }, - "response": [] - } - ] -} \ No newline at end of file diff --git a/README.md b/README.md index bd73feb5f..a0ee25d3b 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,4 @@ -# Show me the code +# Teste entrevista Santander -Esse repositório contem todo o material necessário para realizar o teste: -- A especificação do layout está na pasta 'bank_app_layout' abrindo o index.html, utilizar os Styles do Android - -- Os dados da Api estão mockados, os exemplos e a especificação dos serviços (login e statements) se encontram no arquivo BankApp.postman_collection.json ( é necessário instalar o postman e importar a colection https://www.getpostman.com/apps) - -![Image of Yaktocat](https://github.com/SantanderTecnologia/TesteiOS/blob/new_test/telas.png) - -### # DESAFIO: - -Na primeira tela teremos um formulario de login, o campo user deve aceitar email ou cpf, -o campo password deve validar se a senha tem pelo menos uma letra maiuscula, um caracter especial e um caracter alfanumérico. -Apos a validação, realizar o login no endpoint https://bank-app-test.herokuapp.com/api/login e exibir os dados de retorno na próxima tela. -O ultimo usuário logado deve ser salvo de forma segura localmente, e exibido na tela de login se houver algum salvo. - -Na segunda tela será exibido os dados formatados do retorno do login e será necessário fazer um segundo request para obter os lançamentos do usuário, no endpoint https://bank-app-test.herokuapp.com/api/statements/{idUser} que retornará uma lista de lançamentos - -### # Avaliação - -Você será avaliado pela usabilidade, por respeitar o design e pela arquitetura do app. É esperado que você consiga explicar as decisões que tomou durante o desenvolvimento através de commits. - -Obrigatórios: - -* Java ou Kotlin -* Material Design -* O app deve funcionar a partir do android 4.4 -* Testes unitários, pode usar a ferramenta que você tem mais experiência, só nos explique o que ele tem de bom. -* Arquitetura a ser utilizada: Android Clean Code (https://github.com/kmmraj/android-clean-code && https://medium.com/@kmmraj/android-clean-code-part-1-c66da6551d1) -* Uso do git. - -### # Observações gerais - -Adicione um arquivo [README.md](http://README.md) com os procedimentos para executar o projeto. -Pedimos que trabalhe sozinho e não divulgue o resultado na internet. - -Faça um fork desse desse repositório em seu Github e ao finalizar nos envie um Pull Request com o resultado, por favor informe por qual empresa você esta se candidatando. - -# Importante: não há prazo de entrega, faça com qualidade! - -# BOA SORTE! +O projeto está na Master, para executá-lo basta apenas abrir o diretório raiz no android studio e compilar. +Para rodar os testes de UI deve utilizar um emulador com API 26 > com as animações desabilitadas. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..096c1ee96 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,89 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'kotlin-android-extensions' +} + +android { + compileSdkVersion rootProject.ext.compile_sdk_version + buildToolsVersion rootProject.ext.build_tools_module_version + + defaultConfig { + applicationId "com.jeanjnap.bankapp" + minSdkVersion rootProject.ext.min_sdk_version + targetSdkVersion rootProject.ext.target_sdk_version + versionCode 1 + versionName "1.0" + multiDexEnabled true + vectorDrawables.useSupportLibrary = true + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = rootProject.ext.jvm_target_version + } + dataBinding { + enabled true + } + testOptions { + unitTests.includeAndroidResources = true + unitTests.all { + jvmArgs '-noverify' + } + } +} + +dependencies { + + implementation project(':data') + implementation project(':domain') + implementation project(':infrastructure') + + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "androidx.core:core-ktx:$core_ktx_version" + implementation "androidx.appcompat:appcompat:$appcompat_version" + implementation "com.google.android.material:material:$material_version" + implementation "androidx.constraintlayout:constraintlayout:$constraintlayout_version" + implementation "org.koin:koin-core:$koin_version" + implementation "org.koin:koin-androidx-scope:$koin_version" + implementation "org.koin:koin-androidx-viewmodel:$koin_version" + implementation "org.koin:koin-androidx-ext:$koin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + implementation "com.facebook.shimmer:shimmer:$shimmer_version" + kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_codegen_version" + + testImplementation "junit:junit:$junit_4_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "android.arch.core:core-testing:$lifecycle_version" + testImplementation "org.robolectric:robolectric:$robolectric_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" + testImplementation "org.koin:koin-test:$koin_version" + + debugImplementation "androidx.test.ext:junit:$junit_version" + debugImplementation "androidx.test.espresso:espresso-core:$espresso_version" + debugImplementation "androidx.test.espresso:espresso-core:$espresso_version" + debugImplementation "androidx.test.espresso:espresso-contrib:$espresso_version" + debugImplementation "androidx.test.espresso:espresso-idling-resource:$espresso_version" + debugImplementation "androidx.test.espresso:espresso-intents:$espresso_version" + debugImplementation "androidx.test.espresso:espresso-web:$espresso_version" + debugImplementation "androidx.test.ext:junit:$junit_version" + debugImplementation "androidx.test:runner:$androidx_runner_version" + debugImplementation "io.mockk:mockk-android:$mockk_version" + debugImplementation "io.mockk:mockk:$mockk_version" + debugImplementation "androidx.arch.core:core-testing:$arch_core_testing_version" + debugImplementation "org.koin:koin-test:$koin_version" + debugImplementation "androidx.test.uiautomator:uiautomator:$ui_automator_version" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/BaseTest.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/BaseTest.kt new file mode 100644 index 000000000..56db34cb1 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/BaseTest.kt @@ -0,0 +1,35 @@ +package com.jeanjnap.bankapp + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.jeanjnap.bankapp.di.AppModules +import com.jeanjnap.bankapp.util.EspressoCoroutinesRule +import com.jeanjnap.infrastructure.network.Network +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.Before +import org.junit.Rule +import org.koin.core.context.unloadKoinModules +import org.koin.test.KoinTest + +open class BaseTest : KoinTest { + + @Rule + @JvmField + val coroutinesRule = EspressoCoroutinesRule() + + @Rule + @JvmField + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @MockK + protected lateinit var network: Network + + @Before + fun initialization() { + MockKAnnotations.init(this) + unloadKoinModules(AppModules.viewModelModules) + + every { network.hasActiveInternetConnection() } returns true + } +} diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/ui/login/LoginActivityRobot.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/ui/login/LoginActivityRobot.kt new file mode 100644 index 000000000..0dffcc0f0 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/ui/login/LoginActivityRobot.kt @@ -0,0 +1,96 @@ +package com.jeanjnap.bankapp.ui.login + +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.pressImeActionButton +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.withHint +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.jeanjnap.bankapp.R +import com.jeanjnap.bankapp.ui.statements.StatementsActivity +import com.jeanjnap.bankapp.util.extension.onView +import com.jeanjnap.bankapp.util.extension.verify +import com.jeanjnap.bankapp.util.extension.verifyText +import com.jeanjnap.bankapp.util.matcher.hasTextInputLayoutError +import com.jeanjnap.bankapp.util.matcher.withDrawable +import com.jeanjnap.bankapp.util.matcher.withWidthSize + +fun loginActivityRobot(func: LoginActivityRobot.() -> Unit) = LoginActivityRobot().apply(func) + +class LoginActivityRobot { + + fun elementsMustBeConfiguredCorrectly() = apply { + withId(R.id.iv_logo).onView { + verify(withWidthSize(124)) + verify(withDrawable(R.drawable.logo)) + } + withId(R.id.tiet_user).onView { + verify(withHint("User")) + verifyText( + R.color.cadet_blue, + "", + 15, + R.font.helvetica_neue + ) + } + withId(R.id.tiet_pass).onView { + verify(withHint("Password")) + verifyText( + R.color.cadet_blue, + "", + 15, + R.font.helvetica_neue + ) + } + withId(R.id.bt_login).onView { + verify(withWidthSize(190)) + verify(withText("Login")) + } + } + + fun verifyUsername(username: String) { + withId(R.id.tiet_user).onView { + verify(withHint("User")) + verifyText( + R.color.cadet_blue, + username, + 15, + R.font.helvetica_neue + ) + } + } + + fun typeUser(isValid: Boolean) { + withId(R.id.tiet_user).onView { + perform(click()) + perform(typeText(if (isValid) "user@test.com" else "123")) + perform(pressImeActionButton()) + } + } + + fun typePass(isValid: Boolean) { + withId(R.id.tiet_pass).onView { + perform(click()) + perform(typeText(if (isValid) "aA!" else "123")) + perform(pressImeActionButton()) + } + } + + fun hasUserErrorMessage(error: String?) { + withId(R.id.til_user).onView { + verify(hasTextInputLayoutError(error)) + } + } + + fun hasPassErrorMessage(error: String?) { + withId(R.id.til_pass).onView { + verify(hasTextInputLayoutError(error)) + } + } + + fun verifyStatementsScreen() = apply { + intended(hasComponent(StatementsActivity::class.java.name)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/ui/login/LoginActivityTest.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/ui/login/LoginActivityTest.kt new file mode 100644 index 000000000..22b610de1 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/ui/login/LoginActivityTest.kt @@ -0,0 +1,140 @@ +package com.jeanjnap.bankapp.ui.login + +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.intent.Intents +import com.jeanjnap.bankapp.BaseTest +import com.jeanjnap.bankapp.ui.statements.StatementsViewModel +import com.jeanjnap.domain.entity.Statement +import com.jeanjnap.domain.entity.SuccessResponse +import com.jeanjnap.domain.entity.UserAccount +import com.jeanjnap.domain.usecase.BankUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module +import java.math.BigDecimal +import java.util.Date + +class LoginActivityTest : BaseTest() { + + @MockK + private lateinit var bankUseCase: BankUseCase + + private lateinit var activityScenario: ActivityScenario + + private lateinit var activity: LoginActivity + + private lateinit var viewModel: LoginViewModel + + @Before + fun setup() { + viewModel = LoginViewModel(network, bankUseCase) + + loadKoinModules(module { + viewModel { viewModel } + viewModel { StatementsViewModel(network, bankUseCase) } + }) + Intents.init() + } + + @After + fun tearDown() { + Intents.release() + activityScenario.close() + } + + @Test + fun onCreate() { + every { bankUseCase.getUser() } returns null + startActivity() + + loginActivityRobot { + elementsMustBeConfiguredCorrectly() + } + } + + @Test + fun onCreate_withSavedUsername_shouldSetInUserField() { + val user = "user@test.com" + every { bankUseCase.getUser() } returns user + startActivity() + + loginActivityRobot { + verifyUsername(user) + } + } + + @Test + fun invalidFields() { + every { bankUseCase.getUser() } returns null + startActivity() + + loginActivityRobot { + typeUser(false) + typePass(false) + hasUserErrorMessage("Usuário inválido") + hasPassErrorMessage("Senha inválida") + } + } + + @Test + fun validFields() { + every { bankUseCase.getUser() } returns null + startActivity() + + loginActivityRobot { + typeUser(true) + typePass(true) + hasUserErrorMessage(null) + hasPassErrorMessage(null) + } + } + + @Test + fun loginSuccess() { + every { bankUseCase.getUser() } returns null + coEvery { bankUseCase.login(any(), any()) } returns SuccessResponse(mockUserAccount()) + coEvery { bankUseCase.getStatements(any()) } returns SuccessResponse(mockStatementList()) + startActivity() + + loginActivityRobot { + typeUser(true) + typePass(true) + verifyStatementsScreen() + } + } + + private fun mockUserAccount() = UserAccount( + userId = 1L, + name = "Jose da Silva Teste", + bankAccount = "1234", + agency = "123456", + balance = BigDecimal.TEN + ) + + private fun mockStatementList() = listOf( + mockStatement(), + mockStatement(), + mockStatement(), + mockStatement(), + mockStatement() + ) + + private fun mockStatement() = Statement( + title = "Pagamento", + desc = "Conta de luz", + date = Date(1610852400000), + value = BigDecimal.TEN + ) + + private fun startActivity() { + activityScenario = ActivityScenario.launch(LoginActivity::class.java).onActivity { + activity = it + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/ui/statements/StatementsActivityRobot.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/ui/statements/StatementsActivityRobot.kt new file mode 100644 index 000000000..d6aeef46c --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/ui/statements/StatementsActivityRobot.kt @@ -0,0 +1,123 @@ +package com.jeanjnap.bankapp.ui.statements + +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.intent.Intents.intended +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.jeanjnap.bankapp.R +import com.jeanjnap.bankapp.ui.login.LoginActivity +import com.jeanjnap.bankapp.util.extension.onView +import com.jeanjnap.bankapp.util.extension.verify +import com.jeanjnap.bankapp.util.extension.verifyText +import com.jeanjnap.bankapp.util.matcher.RecyclerViewMatcher.Companion.withItemCount +import com.jeanjnap.bankapp.util.matcher.RecyclerViewMatcher.Companion.withRecyclerViewItemIdAtPosition +import com.jeanjnap.bankapp.util.matcher.withDrawable +import com.jeanjnap.bankapp.util.matcher.withHeightSize +import com.jeanjnap.bankapp.util.matcher.withWidthSize + +fun statementsActivityRobot(func: StatementsActivityRobot.() -> Unit) = + StatementsActivityRobot().apply(func) + +class StatementsActivityRobot { + + fun elementsMustBeConfiguredCorrectly() = apply { + withId(R.id.tv_name).onView { + verifyText( + R.color.white, + "Jose da Silva Teste", + 25, + R.font.helvetica_neue_light + ) + } + withId(R.id.iv_logout).onView { + verify(withWidthSize(28)) + verify(withHeightSize(28)) + verify(withDrawable(R.drawable.ic_logout)) + } + withId(R.id.tv_account).onView { + verifyText( + R.color.white, + "Conta", + 12, + R.font.helvetica_neue + ) + } + withId(R.id.tv_account_number_value).onView { + verifyText( + R.color.white, + "2050 / 01.111222-4", + 25, + R.font.helvetica_neue_light + ) + } + withId(R.id.tv_balance).onView { + verifyText( + R.color.white, + "Saldo", + 12, + R.font.helvetica_neue + ) + } + withId(R.id.tv_balance_value).onView { + verifyText( + R.color.white, + "R$ 10,00", + 25, + R.font.helvetica_neue_light + ) + } + withId(R.id.tv_recent).onView { + verifyText( + R.color.river_bed, + "Recentes", + 16, + R.font.helvetica_neue + ) + } + withId(R.id.rv_statements).onView { + verify(withItemCount(5)) + } + withRecyclerViewItemIdAtPosition(R.id.rv_statements, 0, R.id.tv_title).onView { + verifyText( + R.color.cadet_blue, + "Pagamento", + 16, + R.font.helvetica_neue + ) + } + withRecyclerViewItemIdAtPosition(R.id.rv_statements, 0, R.id.tv_date).onView { + verifyText( + R.color.cadet_blue, + "17/01/2021", + 12, + R.font.helvetica_neue + ) + } + withRecyclerViewItemIdAtPosition(R.id.rv_statements, 0, R.id.tv_desc).onView { + verifyText( + R.color.river_bed, + "Conta de luz", + 16, + R.font.helvetica_neue + ) + } + withRecyclerViewItemIdAtPosition(R.id.rv_statements, 0, R.id.tv_value).onView { + verifyText( + R.color.river_bed, + "R$ 10,00", + 20, + R.font.helvetica_neue_light + ) + } + } + + fun logout() { + withId(R.id.iv_logout).onView { + perform(click()) + } + } + + fun verifyLoginScreen() { + intended(hasComponent(LoginActivity::class.java.name)) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/ui/statements/StatementsActivityTest.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/ui/statements/StatementsActivityTest.kt new file mode 100644 index 000000000..f8bcbf2f4 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/ui/statements/StatementsActivityTest.kt @@ -0,0 +1,113 @@ +package com.jeanjnap.bankapp.ui.statements + +import android.content.Intent +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.espresso.intent.Intents +import com.jeanjnap.bankapp.BaseTest +import com.jeanjnap.bankapp.ui.login.LoginViewModel +import com.jeanjnap.domain.entity.Statement +import com.jeanjnap.domain.entity.SuccessResponse +import com.jeanjnap.domain.entity.UserAccount +import com.jeanjnap.domain.usecase.BankUseCase +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.core.context.loadKoinModules +import org.koin.dsl.module +import java.math.BigDecimal +import java.util.Date + +class StatementsActivityTest : BaseTest() { + + @MockK + private lateinit var bankUseCase: BankUseCase + + private lateinit var activityScenario: ActivityScenario + + private lateinit var activity: StatementsActivity + + private lateinit var viewModel: StatementsViewModel + + @Before + fun setup() { + viewModel = StatementsViewModel(network, bankUseCase) + + loadKoinModules(module { + viewModel { viewModel } + viewModel { LoginViewModel(network, bankUseCase) } + }) + Intents.init() + } + + @After + fun tearDown() { + Intents.release() + activityScenario.close() + } + + @Test + fun onCreate() { + coEvery { bankUseCase.getStatements(any()) } returns SuccessResponse(mockStatementList()) + startActivity() + + statementsActivityRobot { + elementsMustBeConfiguredCorrectly() + } + } + + @Test + fun logoutClick() { + every { bankUseCase.getUser() } returns "user@test.com" + coEvery { bankUseCase.getStatements(any()) } returns SuccessResponse(mockStatementList()) + startActivity() + + statementsActivityRobot { + logout() + verifyLoginScreen() + } + } + + private fun mockUserAccount() = UserAccount( + userId = 1L, + name = "Jose da Silva Teste", + bankAccount = "2050", + agency = "011112224", + balance = BigDecimal.TEN + ) + + private fun mockStatementList() = listOf( + mockStatement(), + mockStatement(), + mockStatement(), + mockStatement(), + mockStatement() + ) + + private fun mockStatement() = Statement( + title = "Pagamento", + desc = "Conta de luz", + date = Date(1610852400000), + value = BigDecimal.TEN + ) + + private fun startActivity() { + activityScenario = ActivityScenario.launch( + Intent(getApplicationContext(),StatementsActivity::class.java).apply { + putExtra(USER_ACCOUNT_EXTRA, mockUserAccount()) + } + ) + + activityScenario.onActivity { + activity = it + } + } + + companion object { + private const val USER_ACCOUNT_EXTRA = "userAccount" + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/CoroutinesExecutor.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/CoroutinesExecutor.kt new file mode 100644 index 000000000..dcfd90968 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/CoroutinesExecutor.kt @@ -0,0 +1,36 @@ +package com.jeanjnap.bankapp.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.launch + +typealias CoroutinesLaunchExecutor = (CoroutineDispatcher, suspend CoroutineScope.() -> Unit) -> Unit + +typealias CoroutinesAsyncExecutor = (CoroutineDispatcher, suspend CoroutineScope.() -> T) -> Deferred + +class CoroutinesExecutor private constructor() { + + var launchExecutor: CoroutinesLaunchExecutor? = null + + var asyncExecutor: CoroutinesAsyncExecutor<*>? = null + + private val defaultLaunchExecutor: CoroutinesLaunchExecutor + + private val defaultAsyncExecutor: CoroutinesAsyncExecutor<*> + + init { + defaultLaunchExecutor = { asyncContext, block -> + CoroutineScope(asyncContext).launch(block = block) + } + + defaultAsyncExecutor = { asyncContext, block -> + CoroutineScope(asyncContext).async(block = block) + } + } + + companion object { + var instance: CoroutinesExecutor = CoroutinesExecutor() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/EspressoCoroutinesRule.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/EspressoCoroutinesRule.kt new file mode 100644 index 000000000..466ab8db7 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/EspressoCoroutinesRule.kt @@ -0,0 +1,44 @@ +package com.jeanjnap.bankapp.util + +import androidx.test.espresso.IdlingRegistry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class EspressoCoroutinesRule : TestWatcher() { + + override fun starting(description: Description?) { + super.starting(description) + + IdlingRegistry.getInstance().register(EspressoCountingIdlingResource) + + CoroutinesExecutor.instance.launchExecutor = { asyncContext, block -> + EspressoCountingIdlingResource.increment() + + CoroutineScope(asyncContext).launch(block = block) + .invokeOnCompletion { + EspressoCountingIdlingResource.decrement() + } + } + + CoroutinesExecutor.instance.asyncExecutor = { asyncContext, block -> + EspressoCountingIdlingResource.increment() + + CoroutineScope(asyncContext).async(block = block).apply { + invokeOnCompletion { + EspressoCountingIdlingResource.decrement() + } + } + } + } + + override fun finished(description: Description?) { + super.finished(description) + CoroutinesExecutor.instance.launchExecutor = null + CoroutinesExecutor.instance.asyncExecutor = null + IdlingRegistry.getInstance().unregister(EspressoCountingIdlingResource) + } + +} diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/EspressoCountingIdlingResource.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/EspressoCountingIdlingResource.kt new file mode 100644 index 000000000..ad476ce0a --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/EspressoCountingIdlingResource.kt @@ -0,0 +1,40 @@ +package com.jeanjnap.bankapp.util + +import androidx.test.espresso.IdlingResource +import java.util.concurrent.atomic.AtomicInteger + +object EspressoCountingIdlingResource : IdlingResource { + + private val counter = AtomicInteger(0) + + @Volatile + private var resourceCallback: IdlingResource.ResourceCallback? = null + + override fun getName(): String { + return EspressoCountingIdlingResource::class.java.simpleName + } + + override fun isIdleNow(): Boolean { + return counter.get() == 0 + } + + override fun registerIdleTransitionCallback(resourceCallback: IdlingResource.ResourceCallback) { + EspressoCountingIdlingResource.resourceCallback = resourceCallback + } + + fun increment() { + counter.getAndIncrement() + } + + fun decrement() { + with(counter.decrementAndGet()) { + if (this == 0) { + resourceCallback?.onTransitionToIdle() + } + + if (this < 0) { + throw IllegalArgumentException("O contador foi corrompido") + } + } + } +} diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/ContextExtensions.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/ContextExtensions.kt new file mode 100644 index 000000000..ea39f5f5a --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/ContextExtensions.kt @@ -0,0 +1,7 @@ +package com.jeanjnap.bankapp.util.extension + +import android.content.Context +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat + +fun Context.getContextCompactDrawable(@DrawableRes drawableId: Int) = ContextCompat.getDrawable(this, drawableId) \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/MatcherExtensions.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/MatcherExtensions.kt new file mode 100644 index 000000000..5f4318916 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/MatcherExtensions.kt @@ -0,0 +1,12 @@ +package com.jeanjnap.bankapp.util.extension + +import android.view.View +import androidx.test.espresso.Espresso +import androidx.test.espresso.ViewInteraction +import org.hamcrest.Matcher + +fun Matcher.onView(func: ViewInteraction.() -> Unit): ViewInteraction { + val viewInteraction = Espresso.onView(this) + viewInteraction.func() + return viewInteraction +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/ViewInteractionExtensions.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/ViewInteractionExtensions.kt new file mode 100644 index 000000000..62f32e7de --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/extension/ViewInteractionExtensions.kt @@ -0,0 +1,135 @@ +package com.jeanjnap.bankapp.util.extension + +import android.view.View +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.FontRes +import androidx.annotation.StringRes +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.ViewAssertion +import androidx.test.espresso.ViewInteraction +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.jeanjnap.bankapp.util.matcher.withBackgroundDrawable +import com.jeanjnap.bankapp.util.matcher.withFont +import com.jeanjnap.bankapp.util.matcher.withFontSize +import com.jeanjnap.bankapp.util.matcher.withTextColor +import junit.framework.AssertionFailedError +import org.hamcrest.Matcher +import org.hamcrest.Matchers.not + +private fun ViewInteraction.verify( + matcher: Matcher, + retryCount: Int +): ViewInteraction { + withFailureHandler { error, _ -> + when { + error !is AssertionFailedError -> throw error + retryCount > 0 -> { + perform(waitFor()) + verify(matcher, retryCount - 1) + } + else -> throw AssertionFailedError("${error.message} after retries") + } + } + return check(ViewAssertions.matches(matcher)) +} + +fun ViewInteraction.verify(matcher: Matcher) = verify(matcher, 5) + +fun ViewInteraction.verifyText( + @ColorRes colorId: Int, + text: String?, + fontSize: Int, + @FontRes fontId: Int = 0, + isDisplayed: Boolean = true +) { + verify(if (isDisplayed) ViewMatchers.isDisplayed() else not(ViewMatchers.isDisplayed())) + verify(withTextColor(colorId)) + verify(withText(text)) + verify(withFontSize(fontSize)) + if (fontId != 0) verify(withFont(fontId)) +} + +fun ViewInteraction.verifyIconText( + @ColorRes colorId: Int, + @StringRes textId: Int, + fontSize: Int, + isDisplayed: Boolean = true +) { + verify(if (isDisplayed) ViewMatchers.isDisplayed() else not(ViewMatchers.isDisplayed())) + verify(withTextColor(colorId)) + verify(withText(textId)) + verify(withFontSize(fontSize)) +} + +fun ViewInteraction.verifyButton( + @ColorRes textColorId: Int, + text: String, + fontSize: Int, + @FontRes fontId: Int, + @DrawableRes drawableId: Int, + isDisplayed: Boolean = true, + isEnabled: Boolean = true +) { + verify(if (isDisplayed) ViewMatchers.isDisplayed() else not(ViewMatchers.isDisplayed())) + verify(if (isEnabled) ViewMatchers.isEnabled() else not(ViewMatchers.isEnabled())) + verify(withTextColor(textColorId)) + verify(withText(text)) + verify(withFontSize(fontSize)) + verify(withFont(fontId)) + if (isDisplayed) verify(withBackgroundDrawable(drawableId)) +} + +private fun waitFor(millis: Long = 200): ViewAction { + return object : ViewAction { + override fun getConstraints() = ViewMatchers.isAssignableFrom(View::class.java) + + override fun getDescription() = "Wait for $millis milliseconds." + + override fun perform(uiController: UiController, view: View) { + uiController.loopMainThreadUntilIdle() + uiController.loopMainThreadForAtLeast(millis) + } + } +} + +private fun ViewInteraction.verify( + assertion: ViewAssertion, + retryCount: Int +): ViewInteraction { + withFailureHandler { error, _ -> + when { + error !is AssertionFailedError -> throw error + retryCount > 0 -> { + perform(waitFor()) + verify(assertion, retryCount - 1) + } + else -> throw AssertionFailedError("${error.message} after retries") + } + } + return check(assertion) +} + +fun ViewInteraction.verify(assertion: ViewAssertion) = verify(assertion, 5) + +private fun ViewInteraction.act( + action: ViewAction, + retryCount: Int +): ViewInteraction { + withFailureHandler { error, _ -> + when { + error !is AssertionFailedError -> throw error + retryCount > 0 -> { + perform(waitFor(200)) + act(action, retryCount - 1) + } + else -> throw AssertionFailedError("${error.message} after retries") + } + } + return perform(action) +} + +fun ViewInteraction.act(action: ViewAction) = act(action, 5) \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/ImageViewMatchers.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/ImageViewMatchers.kt new file mode 100644 index 000000000..dd03f9ee1 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/ImageViewMatchers.kt @@ -0,0 +1,22 @@ +package com.jeanjnap.bankapp.util.matcher + +import android.view.View +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.core.content.res.ResourcesCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.test.espresso.matcher.BoundedMatcher +import org.hamcrest.Description + +fun withDrawable(@DrawableRes drawableId: Int) = + object : BoundedMatcher(ImageView::class.java) { + + override fun describeTo(description: Description) { + description.appendText("checking Drawable is $drawableId") + } + + override fun matchesSafely(item: ImageView): Boolean { + val desired = ResourcesCompat.getDrawable(item.resources, drawableId, null) + return item.drawable.toBitmap().sameAs(desired?.toBitmap()) + } + } diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/RecyclerViewItemCountAssertion.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/RecyclerViewItemCountAssertion.kt new file mode 100644 index 000000000..88998ec77 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/RecyclerViewItemCountAssertion.kt @@ -0,0 +1,21 @@ +package com.jeanjnap.bankapp.util.matcher + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.ViewAssertion +import androidx.test.espresso.matcher.ViewMatchers +import org.hamcrest.Matcher + +class RecyclerViewItemCountAssertion(private val matcher: Matcher) : ViewAssertion { + + override fun check(view: View, noViewFoundException: NoMatchingViewException?) { + if (noViewFoundException != null) { + throw noViewFoundException + } + + val recyclerView = view as RecyclerView + val actualItemCount = recyclerView.adapter?.itemCount ?: 0 + ViewMatchers.assertThat(actualItemCount, matcher) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/RecyclerViewMatcher.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/RecyclerViewMatcher.kt new file mode 100644 index 000000000..abecc3d56 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/RecyclerViewMatcher.kt @@ -0,0 +1,93 @@ +package com.jeanjnap.bankapp.util.matcher + +import android.content.res.Resources +import android.view.View +import androidx.annotation.IdRes +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.matcher.BoundedMatcher +import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +class RecyclerViewMatcher constructor(private val recyclerViewId: Int) { + + companion object { + private fun withRecyclerView(recyclerViewId: Int): RecyclerViewMatcher { + return RecyclerViewMatcher( + recyclerViewId + ) + } + + fun withItemCount(matcher: Matcher): RecyclerViewItemCountAssertion { + return RecyclerViewItemCountAssertion( + matcher + ) + } + + fun atPosition(position: Int, itemMatcher: Matcher): Matcher? { + return object : BoundedMatcher(RecyclerView::class.java) { + override fun describeTo(description: Description) { + description.appendText("has item at position $position: ") + itemMatcher.describeTo(description) + } + + override fun matchesSafely(view: RecyclerView): Boolean { + val viewHolder = + view.findViewHolderForAdapterPosition(position) ?: return false + return itemMatcher.matches(viewHolder.itemView) + } + } + } + + fun withRecyclerViewItemIdAtPosition(@IdRes recyclerView: Int, position: Int, @IdRes childViewId: Int) = + withRecyclerView( + recyclerView + ).atPositionOnView(position, childViewId) + + fun withItemCount(expectedCount: Int): RecyclerViewItemCountAssertion { + return withItemCount( + `is`(expectedCount) + ) + } + } + + fun atPositionOnView(position: Int, targetViewId: Int): Matcher { + return object : TypeSafeMatcher() { + var resources: Resources? = null + var childView: View? = null + + override fun describeTo(description: Description) { + var idDescription = "$recyclerViewId (resource name not found)" + this.resources?.let { + idDescription = it.getResourceName(recyclerViewId) + } + + description.appendText("with id: $idDescription") + } + + public override fun matchesSafely(view: View): Boolean { + this.resources = view.resources + + val recyclerView = + view.rootView.findViewById(recyclerViewId) as RecyclerView + if (recyclerView.id == recyclerViewId) { + val viewHolder = + recyclerView.findViewHolderForAdapterPosition(position) + if (viewHolder != null) { + childView = viewHolder.itemView + } + } else { + return false + } + + return if (targetViewId == -1) { + view === childView + } else { + val targetView = childView?.findViewById(targetViewId) + view === targetView + } + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/TextInputMatcher.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/TextInputMatcher.kt new file mode 100644 index 000000000..e33c998f8 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/TextInputMatcher.kt @@ -0,0 +1,20 @@ +package com.jeanjnap.bankapp.util.matcher + +import android.view.View +import com.google.android.material.textfield.TextInputLayout +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +fun hasTextInputLayoutError(errorText: String?): Matcher = object : TypeSafeMatcher() { + + override fun describeTo(description: Description?) { + description?.appendText("checking if error is $errorText") + } + + override fun matchesSafely(item: View?): Boolean { + if (item !is TextInputLayout) return false + + return errorText == item.error + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/TextViewMatchers.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/TextViewMatchers.kt new file mode 100644 index 000000000..e68db9f8a --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/TextViewMatchers.kt @@ -0,0 +1,49 @@ +package com.jeanjnap.bankapp.util.matcher + +import android.view.View +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.FontRes +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import androidx.test.espresso.matcher.BoundedMatcher +import org.hamcrest.Description +import kotlin.math.roundToInt + +fun withTextColor(@ColorRes colorId: Int) = + object : BoundedMatcher(TextView::class.java) { + + override fun describeTo(description: Description) { + description.appendText("checking textColor is $colorId") + } + + override fun matchesSafely(item: TextView) = + item.currentTextColor == ContextCompat.getColor(item.context, colorId) + + } + +fun withFontSize(expectedSize: Int) = + object : BoundedMatcher(TextView::class.java) { + + override fun describeTo(description: Description) { + description.appendText("checking textSize is $expectedSize") + } + + override fun matchesSafely(item: TextView): Boolean { + val pixels = item.textSize + val actualSize = (pixels / item.resources.displayMetrics.density).roundToInt() + return actualSize == expectedSize + } + + } + +fun withFont(@FontRes fontId: Int) = object : BoundedMatcher(TextView::class.java) { + + override fun describeTo(description: Description) { + description.appendText("checking font: $fontId") + } + + override fun matchesSafely(item: TextView): Boolean { + return item.typeface == ResourcesCompat.getFont(item.context, fontId) + } +} diff --git a/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/ViewMatchers.kt b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/ViewMatchers.kt new file mode 100644 index 000000000..f56c5a936 --- /dev/null +++ b/app/src/androidTest/java/com/jeanjnap/bankapp/util/matcher/ViewMatchers.kt @@ -0,0 +1,124 @@ +package com.jeanjnap.bankapp.util.matcher + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.StateListDrawable +import android.graphics.drawable.VectorDrawable +import android.view.View +import androidx.test.espresso.matcher.BoundedMatcher +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import com.jeanjnap.bankapp.util.extension.getContextCompactDrawable +import org.hamcrest.Description +import kotlin.math.roundToInt + +fun withBackgroundDrawable(drawableId: Int) = + object : BoundedMatcher(View::class.java) { + + override fun describeTo(description: Description) { + description.appendText("checking background is $drawableId") + } + + override fun matchesSafely(item: View): Boolean { + var expectedDrawable = item.context.getContextCompactDrawable(drawableId) + var drawable = item.background + + if (drawable == null || expectedDrawable == null) return false + + if (drawable is StateListDrawable && expectedDrawable is StateListDrawable) { + drawable = drawable.getCurrent() + expectedDrawable = expectedDrawable.getCurrent() + } + + if (drawable is ColorDrawable && expectedDrawable is ColorDrawable) { + return drawable.color == expectedDrawable.color + } + + if (drawable is BitmapDrawable && expectedDrawable is BitmapDrawable) { + return drawable.bitmap.sameAs(expectedDrawable.bitmap) + } + + if (drawable is GradientDrawable && expectedDrawable is GradientDrawable) { + return drawable.colors?.first() == expectedDrawable.colors?.first() && + drawable.colors?.last() == expectedDrawable.colors?.last() + } + + if (drawable is VectorDrawable || drawable is VectorDrawableCompat) { + val drawableRect: Rect = drawable.bounds + val bitmap = Bitmap.createBitmap( + drawableRect.width(), + drawableRect.height(), + Bitmap.Config.ARGB_8888 + ) + + val canvas = Canvas(bitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + + val otherBitmap = Bitmap.createBitmap( + drawableRect.width(), + drawableRect.height(), + Bitmap.Config.ARGB_8888 + ) + val otherCanvas = Canvas(otherBitmap) + expectedDrawable.setBounds(0, 0, otherCanvas.width, otherCanvas.height) + expectedDrawable.draw(otherCanvas) + + return bitmap.sameAs(otherBitmap) + } + + return false + } + } + +fun withBackgroundColorDrawable(hexString: String) = + object : BoundedMatcher(View::class.java) { + + override fun describeTo(description: Description?) { + description?.appendText("checking background is $hexString") + } + + override fun matchesSafely(item: View?) = if (item != null) { + val desired = Color.parseColor(hexString) + val background = item.background as? ColorDrawable + if (background != null) { + background.color == desired + } else { + false + } + } else { + false + } + } + +fun withWidthSize(width: Int) = object : BoundedMatcher(View::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("checking width is $width") + } + + override fun matchesSafely(item: View?) = if (item != null) { + val factor = item.context.resources.displayMetrics.density + val pixels = (width * factor) + item.width in arrayOf(pixels.toInt(), pixels.roundToInt()) + } else { + false + } +} + +fun withHeightSize(height: Int) = object : BoundedMatcher(View::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("checking height is $height") + } + + override fun matchesSafely(item: View?) = if (item != null) { + val factor = item.context.resources.displayMetrics.density + val pixels = (height * factor) + item.height in arrayOf(pixels.toInt(), pixels.roundToInt()) + } else { + false + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a4c24b0c4 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/jeanjnap/bankapp/BankApplication.kt b/app/src/main/java/com/jeanjnap/bankapp/BankApplication.kt new file mode 100644 index 000000000..02ee19c15 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/BankApplication.kt @@ -0,0 +1,22 @@ +package com.jeanjnap.bankapp + +import androidx.multidex.MultiDexApplication +import com.jeanjnap.bankapp.di.AppComponent.getAllModules +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.KoinComponent +import org.koin.core.context.startKoin + +@Suppress("unused") +open class BankApplication : MultiDexApplication(), KoinComponent { + + override fun onCreate() { + super.onCreate() + + startKoin { + androidLogger() + androidContext(this@BankApplication) + modules(getAllModules()) + } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/di/AppComponent.kt b/app/src/main/java/com/jeanjnap/bankapp/di/AppComponent.kt new file mode 100644 index 000000000..14f06a387 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/di/AppComponent.kt @@ -0,0 +1,17 @@ +package com.jeanjnap.bankapp.di + +import com.jeanjnap.bankapp.di.AppModules.utilsModules +import com.jeanjnap.bankapp.di.AppModules.viewModelModules +import com.jeanjnap.bankapp.di.DataModules.dataModulesItems +import com.jeanjnap.bankapp.di.DataModules.serviceModulesItems +import com.jeanjnap.bankapp.di.DomainModules.domainModulesItems +import com.jeanjnap.bankapp.di.InfrastructureModules.infrastructureModulesItems +import com.jeanjnap.data.di.MapperModules.mapperModulesItems + +object AppComponent { + + fun getAllModules() = listOf( + utilsModules, viewModelModules, domainModulesItems, serviceModulesItems, dataModulesItems, + infrastructureModulesItems, mapperModulesItems + ) +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/di/AppModules.kt b/app/src/main/java/com/jeanjnap/bankapp/di/AppModules.kt new file mode 100644 index 000000000..9ab64f049 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/di/AppModules.kt @@ -0,0 +1,20 @@ +package com.jeanjnap.bankapp.di + +import com.jeanjnap.bankapp.ui.login.LoginViewModel +import com.jeanjnap.bankapp.ui.statements.StatementsViewModel +import com.jeanjnap.bankapp.util.ResourcesStringImpl +import com.jeanjnap.domain.boundary.ResourcesString +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +object AppModules { + + val utilsModules = module { + single { ResourcesStringImpl(get()) } + } + + val viewModelModules = module { + viewModel { LoginViewModel(get(), get()) } + viewModel { StatementsViewModel(get(), get()) } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/di/DataModules.kt b/app/src/main/java/com/jeanjnap/bankapp/di/DataModules.kt new file mode 100644 index 000000000..9673c25b3 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/di/DataModules.kt @@ -0,0 +1,43 @@ +package com.jeanjnap.bankapp.di + +import com.jeanjnap.data.client.ApiClient.makeService +import com.jeanjnap.data.mapper.StatementSummaryResponseToStatementListMapper +import com.jeanjnap.data.mapper.UserDataResponseToUserAccountMapper +import com.jeanjnap.data.repository.BankRepositoryImpl +import com.jeanjnap.data.source.local.BankLocalDataSource +import com.jeanjnap.data.source.local.BankLocalDataSourceImpl +import com.jeanjnap.data.source.local.Cache +import com.jeanjnap.data.source.local.CacheImpl +import com.jeanjnap.data.source.remote.BankRemoteDataSource +import com.jeanjnap.data.source.remote.BankRemoteDataSourceImpl +import com.jeanjnap.data.source.remote.service.BankService +import com.jeanjnap.data.util.moshi.InternalMoshi +import com.jeanjnap.data.util.moshi.InternalMoshiImpl +import com.jeanjnap.domain.repository.BankRepository +import org.koin.core.qualifier.named +import org.koin.dsl.module + +object DataModules { + + val serviceModulesItems = module { + single { makeService(get()) } + } + + val dataModulesItems = module { + single { InternalMoshiImpl() } + single { CacheImpl(get(), get()) } + + // DATA SOURCES + single { + BankRemoteDataSourceImpl( + get(), + get(named(UserDataResponseToUserAccountMapper::class.java.name)), + get(named(StatementSummaryResponseToStatementListMapper::class.java.name)) + ) + } + single { BankLocalDataSourceImpl(get()) } + + // REPOSITORIES + single { BankRepositoryImpl(get(), get()) } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/di/DomainModules.kt b/app/src/main/java/com/jeanjnap/bankapp/di/DomainModules.kt new file mode 100644 index 000000000..a6fbdd347 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/di/DomainModules.kt @@ -0,0 +1,13 @@ +package com.jeanjnap.bankapp.di + +import com.jeanjnap.domain.usecase.BankUseCase +import com.jeanjnap.domain.usecase.BankUseCaseImpl +import org.koin.dsl.module + +object DomainModules { + + val domainModulesItems = module { + //USECASES + single { BankUseCaseImpl(get(), get()) } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/di/InfrastructureModules.kt b/app/src/main/java/com/jeanjnap/bankapp/di/InfrastructureModules.kt new file mode 100644 index 000000000..51db9aaa0 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/di/InfrastructureModules.kt @@ -0,0 +1,14 @@ +package com.jeanjnap.bankapp.di + +import com.jeanjnap.infrastructure.crypto.RSACrypto +import com.jeanjnap.infrastructure.crypto.RSACryptoImpl +import com.jeanjnap.infrastructure.network.Network +import com.jeanjnap.infrastructure.network.NetworkImpl +import org.koin.dsl.module + +object InfrastructureModules { + val infrastructureModulesItems = module { + single { NetworkImpl(get()) } + single { RSACryptoImpl(get()) } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseActivity.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseActivity.kt new file mode 100644 index 000000000..0f9d782b9 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseActivity.kt @@ -0,0 +1,25 @@ +package com.jeanjnap.bankapp.ui.base + +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import com.jeanjnap.bankapp.util.extension.observe + +abstract class BaseActivity : AppCompatActivity() { + + abstract val viewModel: BaseViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + listenUi() + } + + private fun listenUi() { + observe(viewModel.error, ::showToast) + } + + private fun showToast(msg: String, duration: Int = Toast.LENGTH_SHORT) { + Toast.makeText(this, msg, duration).show() + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseAdapter.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseAdapter.kt new file mode 100644 index 000000000..8ee7a3b9a --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseAdapter.kt @@ -0,0 +1,43 @@ +package com.jeanjnap.bankapp.ui.base + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import androidx.databinding.DataBindingUtil +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.ListAdapter + +open class BaseAdapter( + @LayoutRes val view: Int, + val onBind: (T, ViewDataBinding) -> Unit +) : ListAdapter(BaseDiffUtil()) { + + private var items: MutableList = mutableListOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder { + return BaseViewHolder( + DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + view, + parent, + false + ) + ) + } + + override fun getItemCount() = items.size + + override fun onBindViewHolder(holder: BaseViewHolder, position: Int) { + items.getOrNull(position)?.let { + onBind(it, holder.viewDataBinding) + return + } + } + + override fun submitList(list: List?) { + if (items.isNotEmpty()) items.clear() + list?.let { items.addAll(it) } + super.submitList(items) + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseDiffUtil.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseDiffUtil.kt new file mode 100644 index 000000000..89036362b --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseDiffUtil.kt @@ -0,0 +1,13 @@ + +package com.jeanjnap.bankapp.ui.base + +import androidx.recyclerview.widget.DiffUtil + +class BaseDiffUtil : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: T, newItem: T) = compare(oldItem, newItem) + + override fun areContentsTheSame(oldItem: T, newItem: T) = compare(oldItem, newItem) + + private fun compare(oldItem: T, newItem: T) = oldItem == newItem +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseViewHolder.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseViewHolder.kt new file mode 100644 index 000000000..9fff3205c --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseViewHolder.kt @@ -0,0 +1,7 @@ +package com.jeanjnap.bankapp.ui.base + +import androidx.databinding.ViewDataBinding +import androidx.recyclerview.widget.RecyclerView + +class BaseViewHolder(val viewDataBinding: ViewDataBinding) : + RecyclerView.ViewHolder(viewDataBinding.root) diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseViewModel.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseViewModel.kt new file mode 100644 index 000000000..fabb17435 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/base/BaseViewModel.kt @@ -0,0 +1,58 @@ +package com.jeanjnap.bankapp.ui.base + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.jeanjnap.domain.boundary.ResourcesString +import com.jeanjnap.infrastructure.network.Network +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.koin.core.inject +import java.util.concurrent.CancellationException + +abstract class BaseViewModel( + private val network: Network +) : DefaultViewModel() { + + protected val resourcesString: ResourcesString by inject() + + val loading: LiveData get() = _loading + val error: LiveData get() = _error + + private val _loading = MutableLiveData() + private val _error = MutableLiveData() + + private val viewModelJob = Job() + private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) + + fun launchDataLoad( + checkConnection: Boolean = true, + block: suspend CoroutineScope.() -> Unit + ): Job { + return uiScope.launch { + if (checkConnection && network.hasActiveInternetConnection()) { + try { + _loading.value = true + block() + } catch (exception: Exception) { + doOnError(exception) + } finally { + _loading.value = false + } + } else { + _error.value = resourcesString.noConnectionError + } + } + } + + fun displayError(message: String? = null) { + _error.value = message ?: resourcesString.genericError + } + + private fun doOnError(exception: Exception) { + if (exception !is CancellationException) { + displayError() + } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/base/DefaultViewModel.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/base/DefaultViewModel.kt new file mode 100644 index 000000000..30f01d84c --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/base/DefaultViewModel.kt @@ -0,0 +1,7 @@ +package com.jeanjnap.bankapp.ui.base + +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.ViewModel +import org.koin.core.KoinComponent + +abstract class DefaultViewModel : ViewModel(), LifecycleObserver, KoinComponent diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginActivity.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginActivity.kt new file mode 100644 index 000000000..4c0176d05 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginActivity.kt @@ -0,0 +1,78 @@ +package com.jeanjnap.bankapp.ui.login + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.inputmethod.EditorInfo +import androidx.databinding.DataBindingUtil +import com.jeanjnap.bankapp.R +import com.jeanjnap.bankapp.databinding.ActivityLoginBinding +import com.jeanjnap.bankapp.ui.statements.StatementsActivity +import com.jeanjnap.bankapp.ui.base.BaseActivity +import com.jeanjnap.bankapp.util.extension.changeStatusBarColorToWhite +import com.jeanjnap.bankapp.util.extension.observe + +import org.koin.androidx.viewmodel.ext.android.viewModel + +class LoginActivity : BaseActivity() { + + private lateinit var binding: ActivityLoginBinding + + override val viewModel: LoginViewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_login) + binding.viewModel = viewModel + configureElements() + listenUi() + } + + private fun configureElements() { + changeStatusBarColorToWhite() + binding.tietPass.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + viewModel.onLoginClick() + } + false + } + } + + private fun listenUi() { + observe(viewModel.username, binding.tietUser::setText) + observe(viewModel.loading, ::onLoading) + observe(viewModel.usernameError, ::onUsernameError) + observe(viewModel.passwordError, ::onPasswordError) + observe(viewModel.loginSuccess) { StatementsActivity.clearTopStart(this, it) } + } + + private fun onLoading(isLoading: Boolean) { + with(binding.btLogin) { + text = getString( + if (isLoading) R.string.loading + else R.string.login + ) + isEnabled = !isLoading + } + } + + private fun onUsernameError(isWrong: Boolean) { + binding.tilUser.error = if (isWrong) getString(R.string.invalid_username) else null + } + + private fun onPasswordError(isWrong: Boolean) { + binding.tilPass.error = if (isWrong) getString(R.string.invalid_password) else null + } + + companion object { + fun clearTopStart(context: Context) { + context.startActivity( + Intent(context, LoginActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginForm.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginForm.kt new file mode 100644 index 000000000..7e5cffd46 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginForm.kt @@ -0,0 +1,19 @@ +package com.jeanjnap.bankapp.ui.login + +import com.jeanjnap.domain.util.extensions.isCPF + +class LoginForm { + + var user: String = EMPTY_TEXT + var pass: String = EMPTY_TEXT + + fun isValidPassword() = PASSWORD_REGEX.matches(pass) + fun isValidUsername() = EMAIL_REGEX.matches(user) || user.isCPF() + fun isFormValid() = isValidPassword() && isValidUsername() + + companion object { + private const val EMPTY_TEXT = "" + private val PASSWORD_REGEX = "^(?=.*[A-Z])(?=.*[!@#\$%&*])(?=.*[a-z0-9]).{3,}\$".toRegex() + private val EMAIL_REGEX = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}$".toRegex() + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginViewModel.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginViewModel.kt new file mode 100644 index 000000000..9cc9666cd --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/login/LoginViewModel.kt @@ -0,0 +1,66 @@ +package com.jeanjnap.bankapp.ui.login + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.OnLifecycleEvent +import com.jeanjnap.bankapp.ui.base.BaseViewModel +import com.jeanjnap.domain.entity.Response +import com.jeanjnap.domain.entity.SuccessResponse +import com.jeanjnap.domain.entity.UserAccount +import com.jeanjnap.domain.usecase.BankUseCase +import com.jeanjnap.infrastructure.network.Network + +class LoginViewModel( + network: Network, + private val bankUseCase: BankUseCase +): BaseViewModel(network) { + + val username: LiveData get() = _username + val passwordError: LiveData get() = _passwordError + val usernameError: LiveData get() = _usernameError + val loginSuccess: LiveData get() = _loginSuccess + + private val _username = MutableLiveData() + private val _passwordError = MutableLiveData() + private val _usernameError = MutableLiveData() + private val _loginSuccess = MutableLiveData() + + private val form = LoginForm() + + @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) + fun onCreate() { + bankUseCase.getUser()?.let { + form.user = it + _username.value = it + } + } + + fun onUserNameChanged(text: CharSequence) { + form.user = text.toString() + _usernameError.value = false + } + + fun onPasswordChanged(text: CharSequence) { + form.pass = text.toString() + _passwordError.value = false + } + + fun onLoginClick() { + _usernameError.value = !form.isValidUsername() + _passwordError.value = !form.isValidPassword() + if (form.isFormValid()) { + launchDataLoad { + bankUseCase.login(form.user, form.pass).handleLoginResult() + } + } + } + + private fun Response.handleLoginResult() { + if (this is SuccessResponse) { + _loginSuccess.value = body + } else { + displayError(resourcesString.genericError) + } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsActivity.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsActivity.kt new file mode 100644 index 000000000..9caa11476 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsActivity.kt @@ -0,0 +1,66 @@ +package com.jeanjnap.bankapp.ui.statements + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.core.view.isVisible +import androidx.databinding.DataBindingUtil +import com.jeanjnap.bankapp.R +import com.jeanjnap.bankapp.databinding.ActivityStatementsBinding +import com.jeanjnap.bankapp.ui.base.BaseActivity +import com.jeanjnap.bankapp.ui.login.LoginActivity +import com.jeanjnap.bankapp.util.extension.observe +import com.jeanjnap.domain.entity.UserAccount +import org.koin.androidx.viewmodel.ext.android.viewModel + +class StatementsActivity : BaseActivity() { + + override val viewModel: StatementsViewModel by viewModel() + + private lateinit var binding: ActivityStatementsBinding + private lateinit var statementsAdapter: StatementsAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = DataBindingUtil.setContentView(this, R.layout.activity_statements) + binding.viewModel = viewModel + + (intent.extras?.getSerializable(USER_ACCOUNT_EXTRA) as? UserAccount)?.let { + binding.userAccount = it + viewModel.userId = it.userId + } + setupElements() + listenUi() + } + + private fun setupElements() { + statementsAdapter = StatementsAdapter() + binding.rvStatements.adapter = statementsAdapter + } + + private fun listenUi() { + observe(viewModel.loading, ::onLoading) + observe(viewModel.statements, statementsAdapter::submitList) + observe(viewModel.onLogout) { LoginActivity.clearTopStart(this) } + } + + private fun onLoading(isLoading: Boolean) { + binding.slLoading.isVisible = isLoading + binding.rvStatements.isVisible = !isLoading + } + + companion object { + private const val USER_ACCOUNT_EXTRA = "userAccount" + + fun clearTopStart(context: Context, userAccount: UserAccount) { + context.startActivity( + Intent(context, StatementsActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(USER_ACCOUNT_EXTRA, userAccount) + } + ) + } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsAdapter.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsAdapter.kt new file mode 100644 index 000000000..480c30d27 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsAdapter.kt @@ -0,0 +1,13 @@ +package com.jeanjnap.bankapp.ui.statements + +import com.jeanjnap.bankapp.R +import com.jeanjnap.bankapp.databinding.ItemStatementBinding +import com.jeanjnap.bankapp.ui.base.BaseAdapter +import com.jeanjnap.bankapp.util.extension.adapterDataBindingCast +import com.jeanjnap.domain.entity.Statement + +class StatementsAdapter : BaseAdapter(R.layout.item_statement, { statement, view -> + with(view.adapterDataBindingCast()) { + this.statement = statement + } +}) \ No newline at end of file diff --git a/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsViewModel.kt b/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsViewModel.kt new file mode 100644 index 000000000..566bf6a2a --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/ui/statements/StatementsViewModel.kt @@ -0,0 +1,46 @@ +package com.jeanjnap.bankapp.ui.statements + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.jeanjnap.bankapp.ui.base.BaseViewModel +import com.jeanjnap.domain.entity.Response +import com.jeanjnap.domain.entity.Statement +import com.jeanjnap.domain.entity.SuccessResponse +import com.jeanjnap.domain.usecase.BankUseCase +import com.jeanjnap.infrastructure.network.Network + +class StatementsViewModel( + network: Network, + private val bankUseCase: BankUseCase +) : BaseViewModel(network) { + + val statements: LiveData> get() = _statements + val onLogout: LiveData get() = _onLogout + + private val _statements = MutableLiveData>() + private val _onLogout = MutableLiveData() + + var userId: Long? = null + set(value) { + field = value + fetchStatements() + } + + fun onLogoutClick() { + _onLogout.value = true + } + + private fun fetchStatements() { + launchDataLoad { + bankUseCase.getStatements(userId).handleStatementsResponse() + } + } + + private fun Response>.handleStatementsResponse() { + if (this is SuccessResponse) { + _statements.value = body + } else { + displayError(resourcesString.genericError) + } + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/util/ResourcesStringImpl.kt b/app/src/main/java/com/jeanjnap/bankapp/util/ResourcesStringImpl.kt new file mode 100644 index 000000000..fbdcaa94d --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/util/ResourcesStringImpl.kt @@ -0,0 +1,12 @@ +package com.jeanjnap.bankapp.util + +import android.content.Context +import com.jeanjnap.bankapp.R +import com.jeanjnap.domain.boundary.ResourcesString + +class ResourcesStringImpl( + private val context: Context +) : ResourcesString { + override val genericError: String get() = context.getString(R.string.generic_error) + override val noConnectionError: String get() = context.getString(R.string.no_connection_error) +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/util/bindadapter/TextViewBindAdapters.kt b/app/src/main/java/com/jeanjnap/bankapp/util/bindadapter/TextViewBindAdapters.kt new file mode 100644 index 000000000..61b07a413 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/util/bindadapter/TextViewBindAdapters.kt @@ -0,0 +1,37 @@ +package com.jeanjnap.bankapp.util.bindadapter + +import android.widget.TextView +import androidx.databinding.BindingAdapter +import com.jeanjnap.domain.entity.UserAccount +import com.jeanjnap.domain.util.extensions.formatAgency +import com.jeanjnap.domain.util.extensions.formatCurrency +import com.jeanjnap.domain.util.extensions.formatToString +import java.math.BigDecimal +import java.util.Date + +@BindingAdapter("nullableText") +fun nullableText(tv: TextView, value: String?) { + value?.let { + tv.text = it + } +} +@BindingAdapter("formatAccount") +fun formatAccount(tv: TextView, value: UserAccount?) { + value?.let { + tv.text = "${it.bankAccount} / ${it.agency?.formatAgency()}" + } +} + +@BindingAdapter("formatCurrency") +fun formatCurrency(tv: TextView, value: BigDecimal?) { + value?.let { + tv.text = it.formatCurrency() + } +} + +@BindingAdapter("formatDate") +fun formatDate(tv: TextView, value: Date?) { + value?.let { + tv.text = it.formatToString() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/jeanjnap/bankapp/util/extension/ActivityExtensions.kt b/app/src/main/java/com/jeanjnap/bankapp/util/extension/ActivityExtensions.kt new file mode 100644 index 000000000..a82deac13 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/util/extension/ActivityExtensions.kt @@ -0,0 +1,14 @@ +package com.jeanjnap.bankapp.util.extension + +import android.app.Activity +import android.os.Build +import android.view.View +import com.jeanjnap.bankapp.R + +@Suppress("DEPRECATION") +fun Activity.changeStatusBarColorToWhite() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + window.statusBarColor = resources.getColor(R.color.white, theme) + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/util/extension/LifecycleOwnerExtensions.kt b/app/src/main/java/com/jeanjnap/bankapp/util/extension/LifecycleOwnerExtensions.kt new file mode 100644 index 000000000..cf0e21ed0 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/util/extension/LifecycleOwnerExtensions.kt @@ -0,0 +1,8 @@ +package com.jeanjnap.bankapp.util.extension + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData + +fun LifecycleOwner.observe(data: LiveData, function: (id: T) -> Unit) { + data.observeSmart(this) { function.invoke(it) } +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/util/extension/LiveDataExtensions.kt b/app/src/main/java/com/jeanjnap/bankapp/util/extension/LiveDataExtensions.kt new file mode 100644 index 000000000..cb6db9407 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/util/extension/LiveDataExtensions.kt @@ -0,0 +1,8 @@ +package com.jeanjnap.bankapp.util.extension + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData + +fun LiveData.observeSmart(owner: LifecycleOwner, observer: (T) -> Unit) { + observe(owner, { it?.let { source -> observer(source) } }) +} diff --git a/app/src/main/java/com/jeanjnap/bankapp/util/extension/ViewDataBindingViewExtensions.kt b/app/src/main/java/com/jeanjnap/bankapp/util/extension/ViewDataBindingViewExtensions.kt new file mode 100644 index 000000000..f2d9b7329 --- /dev/null +++ b/app/src/main/java/com/jeanjnap/bankapp/util/extension/ViewDataBindingViewExtensions.kt @@ -0,0 +1,7 @@ +package com.jeanjnap.bankapp.util.extension + +import androidx.databinding.ViewDataBinding + +inline fun ViewDataBinding.adapterDataBindingCast() = + this as? CLASS_DATA_BINDING_GENERATED + ?: throw ClassCastException("${CLASS_DATA_BINDING_GENERATED::class.java}") diff --git a/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bank_app_layout/assets/logout 2.png b/app/src/main/res/drawable/ic_logout.png similarity index 100% rename from bank_app_layout/assets/logout 2.png rename to app/src/main/res/drawable/ic_logout.png diff --git a/bank_app_layout/assets/Logo.png b/app/src/main/res/drawable/logo.png similarity index 100% rename from bank_app_layout/assets/Logo.png rename to app/src/main/res/drawable/logo.png diff --git a/app/src/main/res/drawable/shape_gray_fill_roud_corners.xml b/app/src/main/res/drawable/shape_gray_fill_roud_corners.xml new file mode 100644 index 000000000..e5d42e263 --- /dev/null +++ b/app/src/main/res/drawable/shape_gray_fill_roud_corners.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/helvetica_neue.ttf b/app/src/main/res/font/helvetica_neue.ttf new file mode 100644 index 000000000..373e545f7 Binary files /dev/null and b/app/src/main/res/font/helvetica_neue.ttf differ diff --git a/app/src/main/res/font/helvetica_neue_light.ttf b/app/src/main/res/font/helvetica_neue_light.ttf new file mode 100644 index 000000000..7e9b412d2 Binary files /dev/null and b/app/src/main/res/font/helvetica_neue_light.ttf differ diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 000000000..45903e017 --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + +