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