diff --git a/.gitignore b/.gitignore index 2b75303ac..3497e2de8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ /build /captures .externalNativeBuild + +.idea/ diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 45b565415..000000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
- - -
-
\ No newline at end of file diff --git a/.idea/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/README.md b/README.md index b36a55b63..e8ef5e561 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,63 @@ Com o passar do tempo identificamos alguns problemas que impedem esse aplicativo Boa sorte! =) Ps.: Fique à vontade para editar o projeto inteiro, organização de pastas e módulos, bem como as dependências utilizadas + + +# Refatoração +O app foi completamente refatorado para seguir a arquitetura Clean Architecture + MVVM conforme as recomendações oficiais do time do Android (https://developer.android.com/topic/architecture), implementando as melhores práticas de desenvolvimento Android moderno. +Arquitetura Implementada + +# MVVM (Model-View-ViewModel) +View: Activities/Fragments responsáveis apenas pela UI +ViewModel: Gerencia estado da UI, expõe LiveData para observers +Model: Dados e lógica de negócio (Domain + Data layers) + +# Clean Architecture - 3 Camadas +## Domain Layer +Regras de negócio, interfaces dos repositórios e entidades do domínio +Model: User, Post - entidades puras do domínio +Repository Interfaces: UserRepository, PostRepository - contratos para acesso a dados +Use Cases: GetUsersUseCase, GetPostsUseCase - encapsulam lógica de negócio + +## Data Layer +Implementações concretas de acesso a dados e mapeamento +Models: UserResponse, PostResponse - DTOs da API +Mappers: UserMapper, PostMapper - conversão DTO ↔ Domain +Repositories: UserRepositoryImpl, PostRepositoryImpl - implementações dos repositórios +Services: PicPayService, PostsService - clientes HTTP especializados + +## Presentation Layer +UI e gerenciamento de estado visual +Activities/Fragments: MainActivity, UsersFragment, PostsFragment +ViewModels: UsersViewModel, PostsViewModel - estados (Loading/Success/Error) +Adapters/ViewHolders: UserListAdapter, PostListAdapter - exibição de listas +UI Components: RecyclerView com DiffUtil para performance + +# Funcionalidades Implementadas +## Multi-API Architecture +PicPay API: https://609a908e0f5a13001721b74e.mockapi.io/picpay/api/ +JSONPlaceholder API: https://jsonplaceholder.typicode.com/ +Separação clara com módulos Koin dedicados para isolamento + +# Interface Moderna +Tabbed Navigation: ViewPager2 + TabLayout (Usuários + Posts) +Progressive UI: Loading states, error handling, empty states +Material Design: Tema consistente e responsivo + +# Testes Unitários Completos (usando IA para criar) +PostsViewModelTest: Estados Loading/Success/Error +GetPostsUseCaseTest: Lógica de negócio +PostRepositoryImplTest: Acesso a dados + mapeamento +PostMapperTest: Conversões DTO ↔ Domain +Cobertura: 11 testes passando (100% sucesso) + +# Injeção de Dependência +Koin: Módulos organizados por camada +Qualifiers: Separação de instâncias Retrofit (picpay, jsonplaceholder) +Scopes: ViewModels, factories, singletons apropriados + +# Performance & Qualidade +Coroutines + LiveData (poderia usar Flow): Programação assíncrona +View Binding: Substituição de findViewById +DiffUtil: Atualizações eficientes de listas +Error Handling: Tratamento robusto de exceções diff --git a/app/build.gradle b/app/build.gradle index a7fbdc0e9..b4d168243 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,16 +2,17 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-kapt' android { - compileSdkVersion 29 + namespace 'com.picpay.desafio.android' + compileSdkVersion 35 defaultConfig { applicationId "com.picpay.desafio.android" minSdkVersion 21 - targetSdkVersion 29 + targetSdkVersion 35 versionCode 1 versionName "1.0" @@ -19,6 +20,10 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } + + buildFeatures { + viewBinding true + } buildTypes { debug {} @@ -51,9 +56,8 @@ dependencies { 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 "io.insert-koin:koin-core:$koin_version" + implementation "io.insert-koin:koin-android:$koin_version" implementation "com.google.dagger:dagger:$dagger_version" kapt "com.google.dagger:dagger-compiler:$dagger_version" @@ -82,9 +86,9 @@ dependencies { testImplementation "junit:junit:$junit_version" testImplementation "org.mockito:mockito-core:$mockito_version" - testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version" + testImplementation "org.mockito:mockito-inline:$mockito_inline_version" testImplementation "androidx.arch.core:core-testing:$core_testing_version" - implementation "org.koin:koin-test:$koin_version" +// implementation "org.koin:koin-test:$koin_version" androidTestImplementation "androidx.test:runner:$test_runner_version" androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7bdf2ce38..3315c1b0f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ - + diff --git a/app/src/main/java/com/picpay/desafio/android/MainActivity.kt b/app/src/main/java/com/picpay/desafio/android/MainActivity.kt 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/PicPayApplication.kt b/app/src/main/java/com/picpay/desafio/android/PicPayApplication.kt new file mode 100644 index 000000000..c7726d359 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/PicPayApplication.kt @@ -0,0 +1,16 @@ +package com.picpay.desafio.android + +import android.app.Application +import com.picpay.desafio.android.di.appModule +import org.koin.android.ext.koin.androidContext +import org.koin.core.context.startKoin + +class PicPayApplication : Application() { + override fun onCreate() { + super.onCreate() + startKoin { + androidContext(this@PicPayApplication) + modules(appModule) + } + } +} 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/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/mapper/PostMapper.kt b/app/src/main/java/com/picpay/desafio/android/data/mapper/PostMapper.kt new file mode 100644 index 000000000..42786f563 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/mapper/PostMapper.kt @@ -0,0 +1,15 @@ +package com.picpay.desafio.android.data.mapper + +import com.picpay.desafio.android.data.model.PostResponse +import com.picpay.desafio.android.domain.model.Post + +fun PostResponse.toDomain(): Post { + return Post( + userId = userId, + id = id, + title = title, + body = body + ) +} + +fun List.toDomain(): List = map { it.toDomain() } diff --git a/app/src/main/java/com/picpay/desafio/android/data/mapper/UserMapper.kt b/app/src/main/java/com/picpay/desafio/android/data/mapper/UserMapper.kt new file mode 100644 index 000000000..a85479072 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/mapper/UserMapper.kt @@ -0,0 +1,15 @@ +package com.picpay.desafio.android.data.mapper + +import com.picpay.desafio.android.data.model.UserResponse +import com.picpay.desafio.android.domain.model.User + +fun UserResponse.toDomain(): User { + return User( + img = img, + name = name, + id = id, + username = username + ) +} + +fun List.toDomain(): List = map { it.toDomain() } diff --git a/app/src/main/java/com/picpay/desafio/android/data/model/PostResponse.kt b/app/src/main/java/com/picpay/desafio/android/data/model/PostResponse.kt new file mode 100644 index 000000000..de4ee66c1 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/model/PostResponse.kt @@ -0,0 +1,10 @@ +package com.picpay.desafio.android.data.model + +import com.google.gson.annotations.SerializedName + +data class PostResponse( + @SerializedName("userId") val userId: Int, + @SerializedName("id") val id: Int, + @SerializedName("title") val title: String, + @SerializedName("body") val body: String +) diff --git a/app/src/main/java/com/picpay/desafio/android/data/model/UserResponse.kt b/app/src/main/java/com/picpay/desafio/android/data/model/UserResponse.kt new file mode 100644 index 000000000..5c0993994 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/model/UserResponse.kt @@ -0,0 +1,10 @@ +package com.picpay.desafio.android.data.model + +import com.google.gson.annotations.SerializedName + +data class UserResponse( + @SerializedName("img") val img: String, + @SerializedName("name") val name: String, + @SerializedName("id") val id: Int, + @SerializedName("username") val username: String +) diff --git a/app/src/main/java/com/picpay/desafio/android/data/remote/PicPayService.kt b/app/src/main/java/com/picpay/desafio/android/data/remote/PicPayService.kt new file mode 100644 index 000000000..225a1323a --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/remote/PicPayService.kt @@ -0,0 +1,9 @@ +package com.picpay.desafio.android.data.remote + +import com.picpay.desafio.android.data.model.UserResponse +import retrofit2.http.GET + +interface PicPayService { + @GET("users") + suspend fun getUsers(): List +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/data/remote/PostsService.kt b/app/src/main/java/com/picpay/desafio/android/data/remote/PostsService.kt new file mode 100644 index 000000000..9edef23bf --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/remote/PostsService.kt @@ -0,0 +1,9 @@ +package com.picpay.desafio.android.data.remote + +import com.picpay.desafio.android.data.model.PostResponse +import retrofit2.http.GET + +interface PostsService { + @GET("posts") + suspend fun getPosts(): List +} diff --git a/app/src/main/java/com/picpay/desafio/android/data/repository/PostRepositoryImpl.kt b/app/src/main/java/com/picpay/desafio/android/data/repository/PostRepositoryImpl.kt new file mode 100644 index 000000000..31805c14a --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/repository/PostRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.picpay.desafio.android.data.repository + +import com.picpay.desafio.android.data.mapper.toDomain +import com.picpay.desafio.android.data.remote.PostsService +import com.picpay.desafio.android.domain.model.Post +import com.picpay.desafio.android.domain.repository.PostRepository + +class PostRepositoryImpl(private val service: PostsService) : PostRepository { + override suspend fun getPosts(): List { + return try { + service.getPosts().toDomain() + } catch (e: Exception) { + throw Exception("Erro ao buscar posts: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/data/repository/UserRepositoryImpl.kt b/app/src/main/java/com/picpay/desafio/android/data/repository/UserRepositoryImpl.kt new file mode 100644 index 000000000..45accc92e --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/data/repository/UserRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.picpay.desafio.android.data.repository + +import com.picpay.desafio.android.data.remote.PicPayService +import com.picpay.desafio.android.data.mapper.toDomain +import com.picpay.desafio.android.domain.model.User +import com.picpay.desafio.android.domain.repository.UserRepository + +class UserRepositoryImpl(private val service: PicPayService) : UserRepository { + override suspend fun getUsers(): List { + return try { + service.getUsers().toDomain() + } catch (e: Exception) { + throw Exception("Erro ao buscar usuários: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/di/KoinModules.kt b/app/src/main/java/com/picpay/desafio/android/di/KoinModules.kt new file mode 100644 index 000000000..c2ac34889 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/di/KoinModules.kt @@ -0,0 +1,12 @@ +package com.picpay.desafio.android.di + +import com.picpay.desafio.android.di.modules.dataModule +import com.picpay.desafio.android.di.modules.domainModule +import com.picpay.desafio.android.di.modules.networkModule +import com.picpay.desafio.android.di.modules.postsNetworkModule +import com.picpay.desafio.android.di.modules.presentationModule +import org.koin.dsl.module + +val appModule = module { + includes(networkModule, postsNetworkModule, dataModule, domainModule, presentationModule) +} diff --git a/app/src/main/java/com/picpay/desafio/android/di/modules/DataModule.kt b/app/src/main/java/com/picpay/desafio/android/di/modules/DataModule.kt new file mode 100644 index 000000000..1c949a42a --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/di/modules/DataModule.kt @@ -0,0 +1,12 @@ +package com.picpay.desafio.android.di.modules + +import com.picpay.desafio.android.data.repository.PostRepositoryImpl +import com.picpay.desafio.android.data.repository.UserRepositoryImpl +import com.picpay.desafio.android.domain.repository.PostRepository +import com.picpay.desafio.android.domain.repository.UserRepository +import org.koin.dsl.module + +val dataModule = module { + single { UserRepositoryImpl(get()) } + single { PostRepositoryImpl(get()) } +} diff --git a/app/src/main/java/com/picpay/desafio/android/di/modules/DomainModule.kt b/app/src/main/java/com/picpay/desafio/android/di/modules/DomainModule.kt new file mode 100644 index 000000000..057a07bcf --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/di/modules/DomainModule.kt @@ -0,0 +1,10 @@ +package com.picpay.desafio.android.di.modules + +import com.picpay.desafio.android.domain.usecase.GetPostsUseCase +import com.picpay.desafio.android.domain.usecase.GetUsersUseCase +import org.koin.dsl.module + +val domainModule = module { + factory { GetUsersUseCase(get()) } + factory { GetPostsUseCase(get()) } +} diff --git a/app/src/main/java/com/picpay/desafio/android/di/modules/NetworkModule.kt b/app/src/main/java/com/picpay/desafio/android/di/modules/NetworkModule.kt new file mode 100644 index 000000000..86e02972e --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/di/modules/NetworkModule.kt @@ -0,0 +1,23 @@ +package com.picpay.desafio.android.di.modules + +import com.picpay.desafio.android.data.remote.PicPayService +import okhttp3.OkHttpClient +import org.koin.core.qualifier.named +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +val networkModule = module { + single { OkHttpClient.Builder().build() } + + // PicPay API Retrofit + single(named("picpay")) { + Retrofit.Builder() + .baseUrl("https://609a908e0f5a13001721b74e.mockapi.io/picpay/api/") + .addConverterFactory(GsonConverterFactory.create()) + .client(get()) + .build() + } + + single { get(named("picpay")).create(PicPayService::class.java) } +} diff --git a/app/src/main/java/com/picpay/desafio/android/di/modules/PostsNetworkModule.kt b/app/src/main/java/com/picpay/desafio/android/di/modules/PostsNetworkModule.kt new file mode 100644 index 000000000..0d9adbb16 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/di/modules/PostsNetworkModule.kt @@ -0,0 +1,21 @@ +package com.picpay.desafio.android.di.modules + +import com.picpay.desafio.android.data.remote.PostsService +import okhttp3.OkHttpClient +import org.koin.core.qualifier.named +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +val postsNetworkModule = module { + // JSONPlaceholder API Retrofit (usando o mesmo OkHttpClient) + single(named("jsonplaceholder")) { + Retrofit.Builder() + .baseUrl("https://jsonplaceholder.typicode.com/") + .addConverterFactory(GsonConverterFactory.create()) + .client(get()) + .build() + } + + single { get(named("jsonplaceholder")).create(PostsService::class.java) } +} diff --git a/app/src/main/java/com/picpay/desafio/android/di/modules/PresentationModule.kt b/app/src/main/java/com/picpay/desafio/android/di/modules/PresentationModule.kt new file mode 100644 index 000000000..db5b42c33 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/di/modules/PresentationModule.kt @@ -0,0 +1,11 @@ +package com.picpay.desafio.android.di.modules + +import com.picpay.desafio.android.presentation.posts.viewmodel.PostsViewModel +import com.picpay.desafio.android.presentation.users.viewmodel.UsersViewModel +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module + +val presentationModule = module { + viewModel { UsersViewModel(get()) } + viewModel { PostsViewModel(get()) } +} diff --git a/app/src/main/java/com/picpay/desafio/android/domain/model/Post.kt b/app/src/main/java/com/picpay/desafio/android/domain/model/Post.kt new file mode 100644 index 000000000..8ad32d2be --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/model/Post.kt @@ -0,0 +1,8 @@ +package com.picpay.desafio.android.domain.model + +data class Post( + val userId: Int, + val id: Int, + val title: String, + val body: String +) diff --git a/app/src/main/java/com/picpay/desafio/android/User.kt b/app/src/main/java/com/picpay/desafio/android/domain/model/User.kt similarity index 87% rename from app/src/main/java/com/picpay/desafio/android/User.kt rename to app/src/main/java/com/picpay/desafio/android/domain/model/User.kt index aa28171c9..3097cbf80 100644 --- a/app/src/main/java/com/picpay/desafio/android/User.kt +++ b/app/src/main/java/com/picpay/desafio/android/domain/model/User.kt @@ -1,4 +1,4 @@ -package com.picpay.desafio.android +package com.picpay.desafio.android.domain.model import android.os.Parcelable import com.google.gson.annotations.SerializedName diff --git a/app/src/main/java/com/picpay/desafio/android/domain/repository/PostRepository.kt b/app/src/main/java/com/picpay/desafio/android/domain/repository/PostRepository.kt new file mode 100644 index 000000000..51e7ed164 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/repository/PostRepository.kt @@ -0,0 +1,7 @@ +package com.picpay.desafio.android.domain.repository + +import com.picpay.desafio.android.domain.model.Post + +interface PostRepository { + suspend fun getPosts(): List +} diff --git a/app/src/main/java/com/picpay/desafio/android/domain/repository/UserRepository.kt b/app/src/main/java/com/picpay/desafio/android/domain/repository/UserRepository.kt new file mode 100644 index 000000000..7032e4afe --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/repository/UserRepository.kt @@ -0,0 +1,7 @@ +package com.picpay.desafio.android.domain.repository + +import com.picpay.desafio.android.domain.model.User + +interface UserRepository { + suspend fun getUsers(): List +} diff --git a/app/src/main/java/com/picpay/desafio/android/domain/usecase/GetPostsUseCase.kt b/app/src/main/java/com/picpay/desafio/android/domain/usecase/GetPostsUseCase.kt new file mode 100644 index 000000000..2656d7ea4 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/usecase/GetPostsUseCase.kt @@ -0,0 +1,8 @@ +package com.picpay.desafio.android.domain.usecase + +import com.picpay.desafio.android.domain.model.Post +import com.picpay.desafio.android.domain.repository.PostRepository + +class GetPostsUseCase(private val repository: PostRepository) { + suspend operator fun invoke(): List = repository.getPosts() +} diff --git a/app/src/main/java/com/picpay/desafio/android/domain/usecase/GetUsersUseCase.kt b/app/src/main/java/com/picpay/desafio/android/domain/usecase/GetUsersUseCase.kt new file mode 100644 index 000000000..5bfb977fe --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/domain/usecase/GetUsersUseCase.kt @@ -0,0 +1,8 @@ +package com.picpay.desafio.android.domain.usecase + +import com.picpay.desafio.android.domain.model.User +import com.picpay.desafio.android.domain.repository.UserRepository + +class GetUsersUseCase(private val repository: UserRepository) { + suspend operator fun invoke(): List = repository.getUsers() +} diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/posts/PostsFragment.kt b/app/src/main/java/com/picpay/desafio/android/presentation/posts/PostsFragment.kt new file mode 100644 index 000000000..222a52ae3 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/posts/PostsFragment.kt @@ -0,0 +1,67 @@ +package com.picpay.desafio.android.presentation.posts + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.R +import com.picpay.desafio.android.presentation.posts.adapter.PostListAdapter +import com.picpay.desafio.android.presentation.posts.viewmodel.PostsState +import com.picpay.desafio.android.presentation.posts.viewmodel.PostsViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class PostsFragment : Fragment() { + + private lateinit var recyclerView: RecyclerView + private lateinit var progressBar: ProgressBar + private lateinit var adapter: PostListAdapter + private val viewModel: PostsViewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_posts, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Configurar UI + recyclerView = view.findViewById(R.id.recyclerView) + progressBar = view.findViewById(R.id.progressBar) + + adapter = PostListAdapter() + recyclerView.adapter = adapter + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + + // Observar estados do ViewModel + viewModel.state.observe(viewLifecycleOwner) { state -> + when (state) { + is PostsState.Loading -> { + progressBar.visibility = View.VISIBLE + recyclerView.visibility = View.GONE + } + is PostsState.Success -> { + progressBar.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + adapter.posts = state.posts + } + is PostsState.Error -> { + progressBar.visibility = View.GONE + recyclerView.visibility = View.GONE + Toast.makeText(requireContext(), state.message, Toast.LENGTH_SHORT).show() + } + } + } + + // Carregar dados + viewModel.loadPosts() + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/posts/adapter/PostDiffCallback.kt b/app/src/main/java/com/picpay/desafio/android/presentation/posts/adapter/PostDiffCallback.kt new file mode 100644 index 000000000..f7087ff97 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/posts/adapter/PostDiffCallback.kt @@ -0,0 +1,26 @@ +package com.picpay.desafio.android.presentation.posts.adapter + +import androidx.recyclerview.widget.DiffUtil +import com.picpay.desafio.android.domain.model.Post + +class PostDiffCallback( + private val oldList: List, + private val newList: List +) : DiffUtil.Callback() { + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition].id == newList[newItemPosition].id + } + + override fun getOldListSize(): Int { + return oldList.size + } + + override fun getNewListSize(): Int { + return newList.size + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition] == newList[newItemPosition] + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/posts/adapter/PostListAdapter.kt b/app/src/main/java/com/picpay/desafio/android/presentation/posts/adapter/PostListAdapter.kt new file mode 100644 index 000000000..d7a638251 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/posts/adapter/PostListAdapter.kt @@ -0,0 +1,40 @@ +package com.picpay.desafio.android.presentation.posts.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.R +import com.picpay.desafio.android.databinding.ListItemUserBinding +import com.picpay.desafio.android.domain.model.Post +import com.picpay.desafio.android.presentation.posts.viewholder.PostListItemViewHolder + +class PostListAdapter : RecyclerView.Adapter() { + + var posts = emptyList() + set(value) { + val result = DiffUtil.calculateDiff( + PostDiffCallback( + field, + value + ) + ) + result.dispatchUpdatesTo(this) + field = value + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostListItemViewHolder { + val binding = ListItemUserBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return PostListItemViewHolder(binding) + } + + override fun onBindViewHolder(holder: PostListItemViewHolder, position: Int) { + holder.bind(posts[position]) + } + + override fun getItemCount(): Int = posts.size +} diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/posts/viewholder/PostListItemViewHolder.kt b/app/src/main/java/com/picpay/desafio/android/presentation/posts/viewholder/PostListItemViewHolder.kt new file mode 100644 index 000000000..51b04f03a --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/posts/viewholder/PostListItemViewHolder.kt @@ -0,0 +1,16 @@ +package com.picpay.desafio.android.presentation.posts.viewholder + +import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.databinding.ListItemUserBinding +import com.picpay.desafio.android.domain.model.Post + +class PostListItemViewHolder( + private val binding: ListItemUserBinding +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(post: Post) { + binding.name.text = post.title + binding.username.text = post.body + binding.progressBar.visibility = android.view.View.GONE + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/posts/viewmodel/PostsViewModel.kt b/app/src/main/java/com/picpay/desafio/android/presentation/posts/viewmodel/PostsViewModel.kt new file mode 100644 index 000000000..da9028320 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/posts/viewmodel/PostsViewModel.kt @@ -0,0 +1,32 @@ +package com.picpay.desafio.android.presentation.posts.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.picpay.desafio.android.domain.model.Post +import com.picpay.desafio.android.domain.usecase.GetPostsUseCase +import kotlinx.coroutines.launch + +sealed class PostsState { + object Loading : PostsState() + data class Success(val posts: List) : PostsState() + data class Error(val message: String) : PostsState() +} + +class PostsViewModel(private val getPostsUseCase: GetPostsUseCase) : ViewModel() { + private val _state = MutableLiveData(PostsState.Loading) + val state: LiveData get() = _state + + fun loadPosts() { + _state.value = PostsState.Loading + viewModelScope.launch { + try { + val posts = getPostsUseCase() + _state.value = PostsState.Success(posts) + } catch (e: Exception) { + _state.value = PostsState.Error(e.message ?: "Erro desconhecido") + } + } + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/users/MainActivity.kt b/app/src/main/java/com/picpay/desafio/android/presentation/users/MainActivity.kt new file mode 100644 index 000000000..b92ddf1df --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/users/MainActivity.kt @@ -0,0 +1,44 @@ +package com.picpay.desafio.android.presentation.users + +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.viewpager2.adapter.FragmentStateAdapter +import androidx.viewpager2.widget.ViewPager2 +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import com.picpay.desafio.android.R +import com.picpay.desafio.android.presentation.posts.PostsFragment +import com.picpay.desafio.android.presentation.users.UsersFragment + +class MainActivity : AppCompatActivity(R.layout.activity_main) { + + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + + val tabLayout = findViewById(R.id.tabLayout) + val viewPager = findViewById(R.id.viewPager) + + val adapter = ViewPagerAdapter(this) + viewPager.adapter = adapter + + TabLayoutMediator(tabLayout, viewPager) { tab, position -> + tab.text = when (position) { + 0 -> "Usuários" + 1 -> "Posts" + else -> "Tab $position" + } + }.attach() + } + + class ViewPagerAdapter(activity: AppCompatActivity) : FragmentStateAdapter(activity) { + override fun getItemCount(): Int = 2 + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> UsersFragment() + 1 -> PostsFragment() + else -> throw IllegalStateException("Invalid position $position") + } + } + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/users/UsersFragment.kt b/app/src/main/java/com/picpay/desafio/android/presentation/users/UsersFragment.kt new file mode 100644 index 000000000..28a8dd1ad --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/users/UsersFragment.kt @@ -0,0 +1,67 @@ +package com.picpay.desafio.android.presentation.users + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.R +import com.picpay.desafio.android.presentation.users.adapter.UserListAdapter +import com.picpay.desafio.android.presentation.users.viewmodel.UsersState +import com.picpay.desafio.android.presentation.users.viewmodel.UsersViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class UsersFragment : Fragment() { + + private lateinit var recyclerView: RecyclerView + private lateinit var progressBar: ProgressBar + private lateinit var adapter: UserListAdapter + private val viewModel: UsersViewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_users, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Configurar UI + recyclerView = view.findViewById(R.id.recyclerView) + progressBar = view.findViewById(R.id.user_list_progress_bar) + + adapter = UserListAdapter() + recyclerView.adapter = adapter + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + + // Observar estados do ViewModel + viewModel.state.observe(viewLifecycleOwner) { state -> + when (state) { + is UsersState.Loading -> { + progressBar.visibility = View.VISIBLE + recyclerView.visibility = View.GONE + } + is UsersState.Success -> { + progressBar.visibility = View.GONE + recyclerView.visibility = View.VISIBLE + adapter.users = state.users + } + is UsersState.Error -> { + progressBar.visibility = View.GONE + recyclerView.visibility = View.GONE + Toast.makeText(requireContext(), state.message, Toast.LENGTH_SHORT).show() + } + } + } + + // Carregar dados + viewModel.loadUsers() + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt b/app/src/main/java/com/picpay/desafio/android/presentation/users/adapter/UserListAdapter.kt similarity index 56% rename from app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt rename to app/src/main/java/com/picpay/desafio/android/presentation/users/adapter/UserListAdapter.kt index 538c98a4a..008a16de3 100644 --- a/app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt +++ b/app/src/main/java/com/picpay/desafio/android/presentation/users/adapter/UserListAdapter.kt @@ -1,9 +1,13 @@ -package com.picpay.desafio.android +package com.picpay.desafio.android.presentation.users.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.domain.model.User +import com.picpay.desafio.android.databinding.ListItemUserBinding +import com.picpay.desafio.android.presentation.users.adapter.UserListDiffCallback +import com.picpay.desafio.android.presentation.users.viewholder.UserListItemViewHolder class UserListAdapter : RecyclerView.Adapter() { @@ -20,10 +24,14 @@ class UserListAdapter : RecyclerView.Adapter() { } 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) + // Inflate the layout using ViewBinding + val binding = ListItemUserBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + // Pass the binding object to the ViewHolder + return UserListItemViewHolder(binding) } override fun onBindViewHolder(holder: UserListItemViewHolder, position: Int) { diff --git a/app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt b/app/src/main/java/com/picpay/desafio/android/presentation/users/adapter/UserListDiffCallback.kt similarity index 84% rename from app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt rename to app/src/main/java/com/picpay/desafio/android/presentation/users/adapter/UserListDiffCallback.kt index 7c734d37b..f87ac21ed 100644 --- a/app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt +++ b/app/src/main/java/com/picpay/desafio/android/presentation/users/adapter/UserListDiffCallback.kt @@ -1,7 +1,7 @@ -package com.picpay.desafio.android +package com.picpay.desafio.android.presentation.users.adapter import androidx.recyclerview.widget.DiffUtil -import com.picpay.desafio.android.User +import com.picpay.desafio.android.domain.model.User class UserListDiffCallback( private val oldList: List, diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/users/viewholder/UserListItemViewHolder.kt b/app/src/main/java/com/picpay/desafio/android/presentation/users/viewholder/UserListItemViewHolder.kt new file mode 100644 index 000000000..dc78ece30 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/users/viewholder/UserListItemViewHolder.kt @@ -0,0 +1,32 @@ +package com.picpay.desafio.android.presentation.users.viewholder + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.R +import com.picpay.desafio.android.domain.model.User +import com.picpay.desafio.android.databinding.ListItemUserBinding +import com.squareup.picasso.Callback +import com.squareup.picasso.Picasso + +class UserListItemViewHolder( + private val binding: ListItemUserBinding +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(user: User) { + binding.name.text = user.name + binding.username.text = user.username + binding.progressBar.visibility = View.VISIBLE + Picasso.get() + .load(user.img) + .error(R.drawable.ic_round_account_circle) + .into(binding.picture, object : Callback { + override fun onSuccess() { + binding.progressBar.visibility = View.GONE + } + + override fun onError(e: Exception?) { + binding.progressBar.visibility = View.GONE + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/presentation/users/viewmodel/UsersViewModel.kt b/app/src/main/java/com/picpay/desafio/android/presentation/users/viewmodel/UsersViewModel.kt new file mode 100644 index 000000000..d01be77bf --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/presentation/users/viewmodel/UsersViewModel.kt @@ -0,0 +1,32 @@ +package com.picpay.desafio.android.presentation.users.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.picpay.desafio.android.domain.model.User +import com.picpay.desafio.android.domain.usecase.GetUsersUseCase +import kotlinx.coroutines.launch + +sealed class UsersState { + object Loading : UsersState() + data class Success(val users: List) : UsersState() + data class Error(val message: String) : UsersState() +} + +class UsersViewModel(private val getUsersUseCase: GetUsersUseCase) : ViewModel() { + private val _state = MutableLiveData() + val state: LiveData get() = _state + + fun loadUsers() { + _state.value = UsersState.Loading + viewModelScope.launch { + try { + val users = getUsersUseCase() + _state.value = UsersState.Success(users) + } catch (e: Exception) { + _state.value = UsersState.Error(e.message ?: "Erro desconhecido") + } + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 487ac549e..eb235649c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,5 +1,5 @@ - - + app:layout_constraintTop_toTopOf="parent" /> - - - - - - - - - - - + - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_posts.xml b/app/src/main/res/layout/fragment_posts.xml new file mode 100644 index 000000000..92407897a --- /dev/null +++ b/app/src/main/res/layout/fragment_posts.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_users.xml b/app/src/main/res/layout/fragment_users.xml new file mode 100644 index 000000000..8f7dedc81 --- /dev/null +++ b/app/src/main/res/layout/fragment_users.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + 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/mapper/PostMapperTest.kt b/app/src/test/java/com/picpay/desafio/android/data/mapper/PostMapperTest.kt new file mode 100644 index 000000000..f1b2619d4 --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/data/mapper/PostMapperTest.kt @@ -0,0 +1,68 @@ +package com.picpay.desafio.android.data.mapper + +import com.picpay.desafio.android.data.model.PostResponse +import com.picpay.desafio.android.domain.model.Post +import junit.framework.Assert.assertEquals +import org.junit.Test + +class PostMapperTest { + + @Test + fun `PostResponse toDomain should map correctly`() { + // given + val postResponse = PostResponse( + userId = 1, + id = 1, + title = "Test Title", + body = "Test Body" + ) + val expectedPost = Post( + userId = 1, + id = 1, + title = "Test Title", + body = "Test Body" + ) + + // when + val result = postResponse.toDomain() + + // then + assertEquals(expectedPost, result) + } + + @Test + fun `List PostResponse toDomain should map all items correctly`() { + // given + val postResponses = listOf( + PostResponse(1, 1, "Title 1", "Body 1"), + PostResponse(2, 2, "Title 2", "Body 2"), + PostResponse(3, 3, "Title 3", "Body 3") + ) + val expectedPosts = listOf( + Post(1, 1, "Title 1", "Body 1"), + Post(2, 2, "Title 2", "Body 2"), + Post(3, 3, "Title 3", "Body 3") + ) + + // when + val result = postResponses.toDomain() + + // then + assertEquals(expectedPosts, result) + assertEquals(3, result.size) + } + + @Test + fun `empty list should map to empty list`() { + // given + val postResponses = emptyList() + val expectedPosts = emptyList() + + // when + val result = postResponses.toDomain() + + // then + assertEquals(expectedPosts, result) + assertEquals(0, result.size) + } +} diff --git a/app/src/test/java/com/picpay/desafio/android/data/repository/PostRepositoryImplTest.kt b/app/src/test/java/com/picpay/desafio/android/data/repository/PostRepositoryImplTest.kt new file mode 100644 index 000000000..25804f16f --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/data/repository/PostRepositoryImplTest.kt @@ -0,0 +1,65 @@ +package com.picpay.desafio.android.data.repository + +import com.picpay.desafio.android.data.model.PostResponse +import com.picpay.desafio.android.data.remote.PostsService +import com.picpay.desafio.android.domain.model.Post +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertTrue +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +class PostRepositoryImplTest { + + private val postsService = mock(PostsService::class.java) + + private val postRepository = PostRepositoryImpl(postsService) + + @Test + fun `getPosts should return mapped posts when service succeeds`() = runBlocking { + // given + val postResponses = listOf( + PostResponse(1, 1, "Test Title 1", "Test Body 1"), + PostResponse(2, 2, "Test Title 2", "Test Body 2") + ) + val expectedPosts = listOf( + Post(1, 1, "Test Title 1", "Test Body 1"), + Post(2, 2, "Test Title 2", "Test Body 2") + ) + `when`(postsService.getPosts()).thenReturn(postResponses) + + // when + val result = postRepository.getPosts() + + // then + assertEquals(expectedPosts, result) + } + + @Test + fun `getPosts should return empty list when service returns empty list`() = runBlocking { + // given + val postResponses = emptyList() + val expectedPosts = emptyList() + `when`(postsService.getPosts()).thenReturn(postResponses) + + // when + val result = postRepository.getPosts() + + // then + assertEquals(expectedPosts, result) + assertTrue(result.isEmpty()) + } + + @Test(expected = Exception::class) + fun `getPosts should throw exception when service fails`(): Unit = runBlocking { + // given + val expectedException = RuntimeException("Network error") + `when`(postsService.getPosts()).thenThrow(expectedException) + + // when + postRepository.getPosts() + + // then - exception should be thrown + } +} diff --git a/app/src/test/java/com/picpay/desafio/android/domain/usecase/GetPostsUseCaseTest.kt b/app/src/test/java/com/picpay/desafio/android/domain/usecase/GetPostsUseCaseTest.kt new file mode 100644 index 000000000..1ea1280a2 --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/domain/usecase/GetPostsUseCaseTest.kt @@ -0,0 +1,45 @@ +package com.picpay.desafio.android.domain.usecase + +import com.picpay.desafio.android.domain.model.Post +import com.picpay.desafio.android.domain.repository.PostRepository +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +class GetPostsUseCaseTest { + + private val postRepository = mock(PostRepository::class.java) + + private val getPostsUseCase = GetPostsUseCase(postRepository) + + @Test + fun `invoke should return posts from repository`() = runBlocking { + // given + val expectedPosts = listOf( + Post(1, 1, "Test Title 1", "Test Body 1"), + Post(2, 2, "Test Title 2", "Test Body 2") + ) + `when`(postRepository.getPosts()).thenReturn(expectedPosts) + + // when + val result = getPostsUseCase() + + // then + assertEquals(expectedPosts, result) + } + + @Test + fun `invoke should return empty list when repository returns empty list`() = runBlocking { + // given + val expectedPosts = emptyList() + `when`(postRepository.getPosts()).thenReturn(expectedPosts) + + // when + val result = getPostsUseCase() + + // then + assertEquals(expectedPosts, result) + } +} diff --git a/app/src/test/java/com/picpay/desafio/android/presentation/posts/viewmodel/PostsViewModelTest.kt b/app/src/test/java/com/picpay/desafio/android/presentation/posts/viewmodel/PostsViewModelTest.kt new file mode 100644 index 000000000..e39dfd61c --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/presentation/posts/viewmodel/PostsViewModelTest.kt @@ -0,0 +1,82 @@ +package com.picpay.desafio.android.presentation.posts.viewmodel + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.picpay.desafio.android.domain.model.Post +import com.picpay.desafio.android.domain.usecase.GetPostsUseCase +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` + +@ExperimentalCoroutinesApi +class PostsViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = UnconfinedTestDispatcher() + + private val getPostsUseCase = mock(GetPostsUseCase::class.java) + + private lateinit var viewModel: PostsViewModel + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + viewModel = PostsViewModel(getPostsUseCase) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `loadPosts should emit Loading then Success when use case succeeds`() = runBlocking { + // given + val expectedPosts = listOf( + Post(1, 1, "Test Title 1", "Test Body 1"), + Post(2, 2, "Test Title 2", "Test Body 2") + ) + `when`(getPostsUseCase()).thenReturn(expectedPosts) + + // when + viewModel.loadPosts() + + // then + assertTrue(viewModel.state.value is PostsState.Success) + val successState = viewModel.state.value as PostsState.Success + assertEquals(expectedPosts, successState.posts) + } + + @Test + fun `loadPosts should emit Loading then Error when use case fails`() = runBlocking { + // given + val expectedError = RuntimeException("Network error") + `when`(getPostsUseCase()).thenThrow(expectedError) + + // when + viewModel.loadPosts() + + // then + assertTrue(viewModel.state.value is PostsState.Error) + val errorState = viewModel.state.value as PostsState.Error + assertEquals("Network error", errorState.message) + } + + @Test + fun `initial state should be Loading when viewModel is created`() { + // then + assertTrue(viewModel.state.value is PostsState.Loading) + } +} diff --git a/build.gradle b/build.gradle index 7d1b94f34..8c28290d6 100644 --- a/build.gradle +++ b/build.gradle @@ -2,42 +2,42 @@ buildscript { ext { - kotlin_version = '1.3.61' - - 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' + kotlin_version = '2.0.21' + + appcompat_version = '1.6.1' + core_ktx_version = '1.12.0' + core_testing_version = '2.2.0' + constraintlayout_version = '2.1.4' + material_version = "1.11.0" + moshi_version = '1.15.0' + retrofit_version = '2.9.0' + okhttp_version = '4.12.0' picasso_version = '2.71828' - circleimageview_version = '3.0.0' + circleimageview_version = '3.1.0' - junit_version = '4.12' - mockito_version = '2.27.0' - mockito_kotlin_version = '2.1.0' + junit_version = '4.13.2' + mockito_version = '5.8.0' + mockito_inline_version = '5.2.0' - test_runner_version = '1.1.1' - espresso_version = '3.1.1' + test_runner_version = '1.5.2' + espresso_version = '3.5.1' - koin_version = "2.0.1" - dagger_version = "2.23.2" - lifecycle_version = "2.2.0" - coroutines_version = "1.3.3" - rxjava_version = "2.2.17" + koin_version = "4.1.0" + dagger_version = "2.48" + lifecycle_version = "2.7.0" + coroutines_version = "1.7.3" + rxjava_version = "2.2.21" rxandroid_version = "2.1.1" - core_ktx_test_version = "1.2.0" + core_ktx_test_version = "1.5.0" } repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.3' + classpath 'com.android.tools.build:gradle:8.2.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -47,7 +47,7 @@ buildscript { allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/gradle.properties b/gradle.properties index 23339e0df..d637a73ff 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,3 +19,5 @@ android.useAndroidX=true android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official +# Suppress unsupported compileSdk warning +android.suppressUnsupportedCompileSdk=35 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 31680f1d6..0c5f72911 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip diff --git a/gradlew b/gradlew old mode 100644 new mode 100755