diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 45b565415..88ea3aa1e 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,8 +1,5 @@ - - diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..61a9130cd --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 7ac24c777..526b4c25c 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,8 +1,10 @@ + - + + + + diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 7f68460d8..000000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index a7fbdc0e9..1789353a2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,6 +18,7 @@ android { vectorDrawables.useSupportLibrary = true testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + //testInstrumentationRunner "com.picpay.desafio.android.user.MockTestRunner" } buildTypes { debug {} @@ -62,6 +63,7 @@ dependencies { implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" @@ -89,4 +91,23 @@ dependencies { androidTestImplementation "androidx.test:runner:$test_runner_version" androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" androidTestImplementation "androidx.test:core-ktx:$core_ktx_test_version" + + implementation 'com.squareup.okhttp3:logging-interceptor:4.9.1' + + def room_version = "2.4.2" + implementation "androidx.room:room-runtime:$room_version" + kapt "androidx.room:room-compiler:2.4.2" + annotationProcessor "androidx.room:room-compiler:2.4.2" + + testImplementation "io.mockk:mockk:1.11.0" + + testImplementation 'org.hamcrest:hamcrest-all:1.3' + + def paging_version = "3.0.0" + implementation("androidx.paging:paging-runtime:$paging_version") + + androidTestImplementation 'com.jakewharton.espresso:okhttp3-idling-resource:1.0.0' + + androidTestImplementation 'com.android.support.test:rules:1.0.2' + androidTestImplementation 'com.android.support.test:runner:1.0.2' } diff --git a/app/src/androidTest/assets/success_response.json b/app/src/androidTest/assets/success_response.json new file mode 100644 index 000000000..4e692942c --- /dev/null +++ b/app/src/androidTest/assets/success_response.json @@ -0,0 +1,8 @@ +[ + { + "id": 1001, + "name": "Eduardo Santos", + "img": "https://randomuser.me/api/portraits/men/9.jpg", + "username": "@eduardo.santos" + } +] \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt b/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt index e4a4978eb..1d2ac5e27 100644 --- a/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt +++ b/app/src/androidTest/java/com/picpay/desafio/android/MainActivityTest.kt @@ -1,16 +1,20 @@ package com.picpay.desafio.android -import androidx.lifecycle.Lifecycle import androidx.test.core.app.launchActivity import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.platform.app.InstrumentationRegistry -import okhttp3.mockwebserver.Dispatcher +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.jakewharton.espresso.OkHttp3IdlingResource +import com.picpay.desafio.android.ui.UserListActivity +import com.picpay.desafio.android.user.FileReader +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before import org.junit.Test @@ -18,51 +22,42 @@ class MainActivityTest { private val server = MockWebServer() - private val context = InstrumentationRegistry.getInstrumentation().targetContext - - @Test - fun shouldDisplayTitle() { - launchActivity().apply { - val expectedTitle = context.getString(R.string.title) - - moveToState(Lifecycle.State.RESUMED) + @Before + fun setUp() { + server.start(8080) + IdlingRegistry.getInstance().register( + OkHttp3IdlingResource.create( + "okhttp", + getClient() + ) + ) + } - onView(withText(expectedTitle)).check(matches(isDisplayed())) - } + fun getClient(): OkHttpClient { + val logging = HttpLoggingInterceptor() + logging.level = HttpLoggingInterceptor.Level.BODY + return OkHttpClient.Builder() + .addInterceptor(logging) + .build() } + @Test - fun shouldDisplayListItem() { - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - return when (request.path) { - "/users" -> successResponse - else -> errorResponse - } - } - } + fun must_display_list_when_api_return_success() { + server.enqueue( + MockResponse().setResponseCode(200) + .setBody(FileReader.readStringFromFile("success_response.json")) + ) - server.start(serverPort) + launchActivity().apply { + onView(withId(R.id.user_list_recyclerView)).check(matches(isDisplayed())) - launchActivity().apply { - // TODO("validate if list displays items returned by server") } + } + @After + fun tearDown(){ server.close() } - companion object { - private const val serverPort = 8080 - - private val successResponse by lazy { - val body = - "[{\"id\":1001,\"name\":\"Eduardo Santos\",\"img\":\"https://randomuser.me/api/portraits/men/9.jpg\",\"username\":\"@eduardo.santos\"}]" - - MockResponse() - .setResponseCode(200) - .setBody(body) - } - - private val errorResponse by lazy { MockResponse().setResponseCode(404) } - } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/user/FileReader.kt b/app/src/androidTest/java/com/picpay/desafio/android/user/FileReader.kt new file mode 100644 index 000000000..f80cd92dc --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/user/FileReader.kt @@ -0,0 +1,11 @@ +package com.picpay.desafio.android.user + +import androidx.test.platform.app.InstrumentationRegistry + +object FileReader { + fun readStringFromFile(fileName: String): String { + + val assets = InstrumentationRegistry.getInstrumentation().context.assets + return assets.open(fileName).reader().readText() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/user/MockTestRunner.kt b/app/src/androidTest/java/com/picpay/desafio/android/user/MockTestRunner.kt new file mode 100644 index 000000000..8710ed897 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/user/MockTestRunner.kt @@ -0,0 +1,13 @@ +package com.picpay.desafio.android.user + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner + +class MockTestRunner : AndroidJUnitRunner () { + + override fun newApplication (cl: ClassLoader ?, className: String ?, + context: Context ?) : Application { + return super .newApplication(cl, UserTestApp:: class .java.name, context) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/picpay/desafio/android/user/UserTestApp.kt b/app/src/androidTest/java/com/picpay/desafio/android/user/UserTestApp.kt new file mode 100644 index 000000000..40cbec079 --- /dev/null +++ b/app/src/androidTest/java/com/picpay/desafio/android/user/UserTestApp.kt @@ -0,0 +1,29 @@ +package com.picpay.desafio.android.user + +import com.picpay.desafio.android.AppApplication +import com.picpay.desafio.android.di.URL_BASE +import com.picpay.desafio.android.di.appModules +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin + +class UserTestApp: AppApplication() { + + override fun onCreate() { + super.onCreate() + + startKoin { + androidContext(this@UserTestApp) + androidLogger() + modules(appModules) + properties(mapOf(URL_BASE to "http://localhost:8080")) + + } + } + + var url = "http://localhost:8080" + + fun getBaseUrl () : String { + return url + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7bdf2ce38..94a6ad7a9 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/AppApplication.kt b/app/src/main/java/com/picpay/desafio/android/AppApplication.kt new file mode 100644 index 000000000..b6010936c --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/AppApplication.kt @@ -0,0 +1,22 @@ +package com.picpay.desafio.android + +import android.app.Application +import com.picpay.desafio.android.di.URL_BASE +import com.picpay.desafio.android.di.appModules +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin + +open class AppApplication : Application() { + override fun onCreate() { + super.onCreate() + + startKoin { + androidContext(this@AppApplication) + androidLogger() + modules(appModules) + properties(mapOf(URL_BASE to "https://609a908e0f5a13001721b74e.mockapi.io/picpay/api/")) + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/MainActivity.kt b/app/src/main/java/com/picpay/desafio/android/MainActivity.kt deleted file mode 100644 index 2447de98d..000000000 --- a/app/src/main/java/com/picpay/desafio/android/MainActivity.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.picpay.desafio.android - -import android.view.View -import android.widget.ProgressBar -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.gson.Gson -import com.google.gson.GsonBuilder -import okhttp3.OkHttpClient -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory - -class MainActivity : AppCompatActivity(R.layout.activity_main) { - - private lateinit var recyclerView: RecyclerView - private lateinit var progressBar: ProgressBar - private lateinit var adapter: UserListAdapter - - private val url = "https://609a908e0f5a13001721b74e.mockapi.io/picpay/api/" - - private val gson: Gson by lazy { GsonBuilder().create() } - - private val okHttp: OkHttpClient by lazy { - OkHttpClient.Builder() - .build() - } - - private val retrofit: Retrofit by lazy { - Retrofit.Builder() - .baseUrl(url) - .client(okHttp) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - } - - private val service: PicPayService by lazy { - retrofit.create(PicPayService::class.java) - } - - override fun onResume() { - super.onResume() - - recyclerView = findViewById(R.id.recyclerView) - progressBar = findViewById(R.id.user_list_progress_bar) - - adapter = UserListAdapter() - recyclerView.adapter = adapter - recyclerView.layoutManager = LinearLayoutManager(this) - - progressBar.visibility = View.VISIBLE - service.getUsers() - .enqueue(object : Callback> { - override fun onFailure(call: Call>, t: Throwable) { - val message = getString(R.string.error) - - progressBar.visibility = View.GONE - recyclerView.visibility = View.GONE - - Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT) - .show() - } - - override fun onResponse(call: Call>, response: Response>) { - progressBar.visibility = View.GONE - - adapter.users = response.body()!! - } - }) - } -} diff --git a/app/src/main/java/com/picpay/desafio/android/User.kt b/app/src/main/java/com/picpay/desafio/android/User.kt deleted file mode 100644 index aa28171c9..000000000 --- a/app/src/main/java/com/picpay/desafio/android/User.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.picpay.desafio.android - -import android.os.Parcelable -import com.google.gson.annotations.SerializedName -import kotlinx.android.parcel.Parcelize - -@Parcelize -data class User( - @SerializedName("img") val img: String, - @SerializedName("name") val name: String, - @SerializedName("id") val id: Int, - @SerializedName("username") val username: String -) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt b/app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt deleted file mode 100644 index 538c98a4a..000000000 --- a/app/src/main/java/com/picpay/desafio/android/UserListAdapter.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.picpay.desafio.android - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView - -class UserListAdapter : RecyclerView.Adapter() { - - var users = emptyList() - set(value) { - val result = DiffUtil.calculateDiff( - UserListDiffCallback( - field, - value - ) - ) - result.dispatchUpdatesTo(this) - field = value - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserListItemViewHolder { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.list_item_user, parent, false) - - return UserListItemViewHolder(view) - } - - override fun onBindViewHolder(holder: UserListItemViewHolder, position: Int) { - holder.bind(users[position]) - } - - override fun getItemCount(): Int = users.size -} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt b/app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt deleted file mode 100644 index 7c734d37b..000000000 --- a/app/src/main/java/com/picpay/desafio/android/UserListDiffCallback.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.picpay.desafio.android - -import androidx.recyclerview.widget.DiffUtil -import com.picpay.desafio.android.User - -class UserListDiffCallback( - private val oldList: List, - private val newList: List -) : DiffUtil.Callback() { - - override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return oldList[oldItemPosition].username.equals(newList[newItemPosition].username) - } - - override fun getOldListSize(): Int { - return oldList.size - } - - override fun getNewListSize(): Int { - return newList.size - } - - override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { - return true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/UserListItemViewHolder.kt b/app/src/main/java/com/picpay/desafio/android/UserListItemViewHolder.kt deleted file mode 100644 index 1d8240eb3..000000000 --- a/app/src/main/java/com/picpay/desafio/android/UserListItemViewHolder.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.picpay.desafio.android - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.squareup.picasso.Callback -import com.squareup.picasso.Picasso -import kotlinx.android.synthetic.main.list_item_user.view.* - -class UserListItemViewHolder( - itemView: View -) : RecyclerView.ViewHolder(itemView) { - - fun bind(user: User) { - itemView.name.text = user.name - itemView.username.text = user.username - itemView.progressBar.visibility = View.VISIBLE - Picasso.get() - .load(user.img) - .error(R.drawable.ic_round_account_circle) - .into(itemView.picture, object : Callback { - override fun onSuccess() { - itemView.progressBar.visibility = View.GONE - } - - override fun onError(e: Exception?) { - itemView.progressBar.visibility = View.GONE - } - }) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/database/UserDatabase.kt b/app/src/main/java/com/picpay/desafio/android/database/UserDatabase.kt new file mode 100644 index 000000000..dc1d442bb --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/database/UserDatabase.kt @@ -0,0 +1,12 @@ +package com.picpay.desafio.android.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.picpay.desafio.android.database.dao.UserDao +import com.picpay.desafio.android.repository.model.UserLocal + +@Database(entities = [UserLocal::class], version = 1, exportSchema = false) +abstract class UserDatabase : RoomDatabase() { + + abstract fun userDao() : UserDao +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/database/dao/UserDao.kt b/app/src/main/java/com/picpay/desafio/android/database/dao/UserDao.kt new file mode 100644 index 000000000..7afc82483 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/database/dao/UserDao.kt @@ -0,0 +1,17 @@ +package com.picpay.desafio.android.database.dao +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy.REPLACE +import androidx.room.Query +import com.picpay.desafio.android.repository.model.UserLocal + +@Dao +interface UserDao { + + @Insert(onConflict = REPLACE) + fun insert(user: List) + + @Query("SELECT * from userlocal ") + fun getUser() : LiveData?> +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/di/AppModule.kt b/app/src/main/java/com/picpay/desafio/android/di/AppModule.kt new file mode 100644 index 000000000..d85f2d579 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/di/AppModule.kt @@ -0,0 +1,84 @@ +package com.picpay.desafio.android.di + +import androidx.room.Room +import com.picpay.desafio.android.database.dao.UserDao +import com.picpay.desafio.android.database.UserDatabase +import com.picpay.desafio.android.repository.local.UserLocalDataSource +import com.picpay.desafio.android.repository.local.UserLocalDataSourceImp +import com.picpay.desafio.android.repository.remote.UserRemoteDataSource +import com.picpay.desafio.android.repository.remote.UserRemoteDataSourceImp +import com.picpay.desafio.android.repository.UserRepository +import com.picpay.desafio.android.repository.UserRepositoryImp +import com.picpay.desafio.android.ui.UserListViewModel +import com.picpay.desafio.android.repository.remote.webclient.PicPayService +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +const val URL_BASE = "chave" +private const val NOME_BANCO_DE_DADOS = "user.db" + +val retrofitModule = module { + single { + val property = getProperty(URL_BASE) + Retrofit.Builder() + .baseUrl(property) + .client(get()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + single { get().create(PicPayService::class.java) } + + single { + val logging = HttpLoggingInterceptor() + logging.level = HttpLoggingInterceptor.Level.BODY + OkHttpClient.Builder() + .addInterceptor(logging) + .build() + } +} + +val databaseModule = module { + single { + Room.databaseBuilder( + get(), + UserDatabase::class.java, + NOME_BANCO_DE_DADOS + ).build() + } +} + +val daoModule = module { +single { get().userDao()} +} + +val viewModule = module { + viewModel { UserListViewModel(get())} +} + +val repositoryModule = module { + single { UserRepositoryImp(get(), get())} +} + +val localModule = module { + single { UserLocalDataSourceImp(get())} +} + +val remoteModule = module { + single { UserRemoteDataSourceImp(get())} +} + +val appModules = listOf( + retrofitModule, + viewModule, + repositoryModule, + daoModule, + databaseModule, + localModule, + remoteModule +) + diff --git a/app/src/main/java/com/picpay/desafio/android/repository/UserRepository.kt b/app/src/main/java/com/picpay/desafio/android/repository/UserRepository.kt new file mode 100644 index 000000000..c70056e0b --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/UserRepository.kt @@ -0,0 +1,9 @@ +package com.picpay.desafio.android.repository + +import androidx.lifecycle.LiveData +import com.picpay.desafio.android.repository.model.UserLocal + +interface UserRepository { + fun getUsers(success: () -> Unit, failure: (String) -> Unit) + fun getUserDao(): LiveData?> +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/repository/UserRepositoryImp.kt b/app/src/main/java/com/picpay/desafio/android/repository/UserRepositoryImp.kt new file mode 100644 index 000000000..f008911fb --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/UserRepositoryImp.kt @@ -0,0 +1,25 @@ +package com.picpay.desafio.android.repository + +import androidx.lifecycle.LiveData +import com.picpay.desafio.android.repository.local.UserLocalDataSource +import com.picpay.desafio.android.repository.model.UserLocal +import com.picpay.desafio.android.repository.remote.UserRemoteDataSource + +class UserRepositoryImp( + private val userRemoteDataSource: UserRemoteDataSource, + private val userLocalDataSource: UserLocalDataSource +) : UserRepository { + + override fun getUsers(success: () -> Unit, failure: (String) -> Unit) { + + userRemoteDataSource.getUser(success = { users -> + userLocalDataSource.insert(users) + success() + }, failure = failure) + + } + + override fun getUserDao(): LiveData?> { + return userLocalDataSource.getUser() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/repository/local/UserLocalDataSource.kt b/app/src/main/java/com/picpay/desafio/android/repository/local/UserLocalDataSource.kt new file mode 100644 index 000000000..b5a13e555 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/local/UserLocalDataSource.kt @@ -0,0 +1,10 @@ +package com.picpay.desafio.android.repository.local + +import androidx.lifecycle.LiveData +import com.picpay.desafio.android.repository.model.User +import com.picpay.desafio.android.repository.model.UserLocal + +interface UserLocalDataSource { + fun insert(users: List) + fun getUser(): LiveData?> +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/repository/local/UserLocalDataSourceImp.kt b/app/src/main/java/com/picpay/desafio/android/repository/local/UserLocalDataSourceImp.kt new file mode 100644 index 000000000..d773e4a7d --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/local/UserLocalDataSourceImp.kt @@ -0,0 +1,33 @@ +package com.picpay.desafio.android.repository.local + +import androidx.lifecycle.LiveData +import com.picpay.desafio.android.database.dao.UserDao +import com.picpay.desafio.android.repository.model.User +import com.picpay.desafio.android.repository.model.UserLocal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class UserLocalDataSourceImp(private val dao: UserDao) : UserLocalDataSource { + + override fun insert(users: List) { + CoroutineScope(Dispatchers.IO).launch { + dao.insert(convertForUserLocalList(users)) + } + } + + override fun getUser(): LiveData?> { + return dao.getUser() + } + + fun convertForUserLocalList(users: List): List { + return users.map { user -> + UserLocal( + img = user.img, + name = user.name, + id = user.id, + username = user.username + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/repository/model/User.kt b/app/src/main/java/com/picpay/desafio/android/repository/model/User.kt new file mode 100644 index 000000000..ec0de15e0 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/model/User.kt @@ -0,0 +1,13 @@ +package com.picpay.desafio.android.repository.model + +import android.os.Parcelable +import com.google.gson.annotations.SerializedName +import kotlinx.android.parcel.Parcelize + +@Parcelize +data class User( + @SerializedName("img") val img: String?, + @SerializedName("name") val name: String?, + @SerializedName("id") val id: Int?, + @SerializedName("username") val username: String? +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/repository/model/UserLocal.kt b/app/src/main/java/com/picpay/desafio/android/repository/model/UserLocal.kt new file mode 100644 index 000000000..b0277e0cd --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/model/UserLocal.kt @@ -0,0 +1,11 @@ +package com.picpay.desafio.android.repository.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class UserLocal(val img: String?, + @PrimaryKey + val id: Int?, + val name: String?, + val username: String?) \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/repository/model/UserResponse.kt b/app/src/main/java/com/picpay/desafio/android/repository/model/UserResponse.kt new file mode 100644 index 000000000..a3f8f8951 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/model/UserResponse.kt @@ -0,0 +1,7 @@ +package com.picpay.desafio.android.repository.model + +sealed class UserResponse{ + object Success : UserResponse() + data class Failure (val error: String): UserResponse() + object Loading: UserResponse() +} diff --git a/app/src/main/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSource.kt b/app/src/main/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSource.kt new file mode 100644 index 000000000..dede56a17 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSource.kt @@ -0,0 +1,8 @@ +package com.picpay.desafio.android.repository.remote + +import com.picpay.desafio.android.repository.model.User + +interface UserRemoteDataSource { + + fun getUser(success: (List)-> Unit, failure: (String) -> Unit) +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSourceImp.kt b/app/src/main/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSourceImp.kt new file mode 100644 index 000000000..08f38b37d --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSourceImp.kt @@ -0,0 +1,32 @@ +package com.picpay.desafio.android.repository.remote + +import com.picpay.desafio.android.repository.model.User +import com.picpay.desafio.android.repository.remote.webclient.PicPayService +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class UserRemoteDataSourceImp(private val service: PicPayService) : UserRemoteDataSource { + + override fun getUser(success: (List) -> Unit, failure: (String) -> Unit) { + val call = service.getUsers() + call.enqueue(object : Callback> { + override fun onResponse(call: Call>, response: Response>) { + if (response.isSuccessful) { + val user = response.body() + if (user != null) { + success(user) + } + }else{ + failure(response.message()) + } + } + override fun onFailure(call: Call>, t: Throwable) { + t.message?.let { + failure(it) + } + } + + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/PicPayService.kt b/app/src/main/java/com/picpay/desafio/android/repository/remote/webclient/PicPayService.kt similarity index 52% rename from app/src/main/java/com/picpay/desafio/android/PicPayService.kt rename to app/src/main/java/com/picpay/desafio/android/repository/remote/webclient/PicPayService.kt index c26edac1f..68f7ba87b 100644 --- a/app/src/main/java/com/picpay/desafio/android/PicPayService.kt +++ b/app/src/main/java/com/picpay/desafio/android/repository/remote/webclient/PicPayService.kt @@ -1,5 +1,6 @@ -package com.picpay.desafio.android +package com.picpay.desafio.android.repository.remote.webclient +import com.picpay.desafio.android.repository.model.User import retrofit2.Call import retrofit2.http.GET diff --git a/app/src/main/java/com/picpay/desafio/android/ui/UserListActivity.kt b/app/src/main/java/com/picpay/desafio/android/ui/UserListActivity.kt new file mode 100644 index 000000000..f80fcf2cd --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/ui/UserListActivity.kt @@ -0,0 +1,67 @@ +package com.picpay.desafio.android.ui + +import android.view.View +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.R +import com.picpay.desafio.android.ui.adapter.UserListAdapter +import com.picpay.desafio.android.repository.model.UserResponse +import org.koin.androidx.viewmodel.ext.android.viewModel + +class UserListActivity : AppCompatActivity(R.layout.activity_user_list) { + + private lateinit var recyclerView: RecyclerView + private val progressBar by lazy { + findViewById(R.id.user_list_progress_bar) + } + private val userListEmpty by lazy { + findViewById(R.id.user_list_empty) + } + private lateinit var adapter: UserListAdapter + private val viewModel: UserListViewModel by viewModel() + + override fun onResume() { + super.onResume() + configureRecyclerView() + getUsers() + } + + private fun getUsers() { + tryLoadListaLocal() + tryLoadListaApi() + } + + private fun tryLoadListaLocal() { + viewModel.getUser().observe(this, Observer { + if (it.isNullOrEmpty()) { + userListEmpty.visibility = View.VISIBLE + userListEmpty.text = getString(R.string.listaVazia) + } else { + adapter.differ.submitList(it) + } + }) + } + + private fun tryLoadListaApi() { + viewModel.liveData.observe(this, Observer { + when (it) { + is UserResponse.Failure -> { + progressBar.visibility = View.GONE + Toast.makeText(this, it.error, Toast.LENGTH_LONG).show() + } + is UserResponse.Loading -> progressBar.visibility = View.VISIBLE + is UserResponse.Success -> progressBar.visibility = View.GONE + } + }) + } + + private fun configureRecyclerView() { + recyclerView = findViewById(R.id.user_list_recyclerView) + adapter = UserListAdapter() + recyclerView.adapter = adapter + } +} diff --git a/app/src/main/java/com/picpay/desafio/android/ui/UserListViewModel.kt b/app/src/main/java/com/picpay/desafio/android/ui/UserListViewModel.kt new file mode 100644 index 000000000..871843460 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/ui/UserListViewModel.kt @@ -0,0 +1,26 @@ +package com.picpay.desafio.android.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.picpay.desafio.android.repository.model.UserLocal +import com.picpay.desafio.android.repository.UserRepository +import com.picpay.desafio.android.repository.model.UserResponse + +class UserListViewModel(private val repository: UserRepository) : ViewModel() { + + private val _userResponseLiveData = MutableLiveData(UserResponse.Loading) + val liveData: LiveData = _userResponseLiveData + + init { + repository.getUsers(success = { + _userResponseLiveData.postValue(UserResponse.Success) + }, failure = { + _userResponseLiveData.postValue(UserResponse.Failure(it)) + }) + } + + fun getUser(): LiveData?> { + return repository.getUserDao() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/picpay/desafio/android/ui/adapter/UserListAdapter.kt b/app/src/main/java/com/picpay/desafio/android/ui/adapter/UserListAdapter.kt new file mode 100644 index 000000000..b597e7d60 --- /dev/null +++ b/app/src/main/java/com/picpay/desafio/android/ui/adapter/UserListAdapter.kt @@ -0,0 +1,76 @@ +package com.picpay.desafio.android.ui.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.picpay.desafio.android.R +import com.picpay.desafio.android.repository.model.UserLocal +import com.squareup.picasso.Callback +import com.squareup.picasso.Picasso +import kotlinx.android.synthetic.main.list_item_user.view.* + +class UserListAdapter : PagingDataAdapter(differCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserListViewHolder { + return UserListViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.list_item_user, parent, false) + ) + } + + override fun onBindViewHolder(holder: UserListViewHolder, position: Int) { + + val user = differ.currentList[position] + holder.bind(user) + } + + override fun getItemCount(): Int { + return differ.currentList.size + } + + inner class UserListViewHolder(itemVew: View) : RecyclerView.ViewHolder(itemVew) { + + fun bind(user: UserLocal) { + showName(user) + configureImage(user) + } + + private fun configureImage(user: UserLocal) { + 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 + } + }) + } + + private fun showName(user: UserLocal) { + itemView.name.text = user.name + itemView.username.text = user.username + } + } + + val differ = AsyncListDiffer(this, differCallback) +} + +private val differCallback = object : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: UserLocal, newItem: UserLocal): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: UserLocal, newItem: UserLocal): Boolean { + return oldItem.equals(newItem) + } + +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_user_list.xml similarity index 56% rename from app/src/main/res/layout/activity_main.xml rename to app/src/main/res/layout/activity_user_list.xml index 487ac549e..2d563d2e9 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_user_list.xml @@ -1,21 +1,25 @@ - + tools:context=".ui.UserListActivity"> + android:orientation="vertical" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> + android:layout_height="match_parent"> + + + + app:layout_constraintEnd_toEndOf="@+id/user_list_recyclerView" + app:layout_constraintStart_toStartOf="@+id/user_list_recyclerView" + app:layout_constraintTop_toTopOf="@+id/user_list_recyclerView" + app:layout_constraintVertical_bias="0.52" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39df3169e..555d2d856 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,6 @@ PicPay Contatos Ocorreu um erro. Tente novamente. + Lista de contatos vazia diff --git a/app/src/test/java/com/picpay/desafio/android/ExampleService.kt b/app/src/test/java/com/picpay/desafio/android/ExampleService.kt index 0199c5e4a..2d8413b35 100644 --- a/app/src/test/java/com/picpay/desafio/android/ExampleService.kt +++ b/app/src/test/java/com/picpay/desafio/android/ExampleService.kt @@ -1,5 +1,8 @@ package com.picpay.desafio.android +import com.picpay.desafio.android.repository.model.User +import com.picpay.desafio.android.repository.remote.webclient.PicPayService + class ExampleService( private val service: PicPayService ) { diff --git a/app/src/test/java/com/picpay/desafio/android/ExampleServiceTest.kt b/app/src/test/java/com/picpay/desafio/android/ExampleServiceTest.kt index 843c0e776..c80af1fb6 100644 --- a/app/src/test/java/com/picpay/desafio/android/ExampleServiceTest.kt +++ b/app/src/test/java/com/picpay/desafio/android/ExampleServiceTest.kt @@ -2,6 +2,8 @@ package com.picpay.desafio.android import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever +import com.picpay.desafio.android.repository.model.User +import com.picpay.desafio.android.repository.remote.webclient.PicPayService import junit.framework.Assert.assertEquals import org.junit.Test import retrofit2.Call diff --git a/app/src/test/java/com/picpay/desafio/android/repository/UserRepositoryImpTest.kt b/app/src/test/java/com/picpay/desafio/android/repository/UserRepositoryImpTest.kt new file mode 100644 index 000000000..9e983f445 --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/repository/UserRepositoryImpTest.kt @@ -0,0 +1,57 @@ +package com.picpay.desafio.android.repository + +import com.picpay.desafio.android.database.dao.UserDao +import com.picpay.desafio.android.repository.local.UserLocalDataSource +import com.picpay.desafio.android.repository.local.UserLocalDataSourceImp +import com.picpay.desafio.android.repository.model.User +import com.picpay.desafio.android.repository.remote.UserRemoteDataSource +import com.picpay.desafio.android.repository.remote.UserRemoteDataSourceImp +import com.picpay.desafio.android.repository.remote.webclient.PicPayService +import io.mockk.* +import org.junit.Test + +class UserRepositoryImpTest { + + private var picPayServiceMock: PicPayService = mockk() + private var userDaoMock: UserDao = mockk() + + private val userRemoteDataSource: UserRemoteDataSource = spyk(UserRemoteDataSourceImp(picPayServiceMock)) + private val userLocalDataSource: UserLocalDataSource = spyk(UserLocalDataSourceImp(userDaoMock)) + + private val repository: UserRepository = spyk(UserRepositoryImp(userRemoteDataSource, userLocalDataSource)) + + @Test + fun must_insert_list_when_api_return_success() { + val sucess: () -> Unit = {} + val sucessSlot = slot<() -> Unit>() + val failure: (String) -> Unit = {} + + every { userRemoteDataSource.getUser(success = any(), failure = any()) } answers { + firstArg<(List) -> Unit>().invoke(listOf()) + } + coEvery { userLocalDataSource.insert(any()) } answers {} + + run { repository.getUsers(success = sucess, failure = failure) } + + coVerify { userLocalDataSource.insert(any()) } + verify { repository.getUsers(success = capture(sucessSlot), failure = any()) } + + } + + @Test + fun must_notify_failure_when_api_return_failure() { + val sucess: () -> Unit = {} + val failure: (String) -> Unit = {} + val failureSlot = slot<(String) -> Unit>() + failureSlot.captured = failure + + every { userRemoteDataSource.getUser(any(), any()) } answers { + secondArg<(String) -> Unit>().invoke("teste") + } + + run { repository.getUsers(success = sucess, failure = failure) } + + verify { repository.getUsers(success = any(), failure = capture(failureSlot)) } + + } +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/repository/local/UserLocalDataSourceImpTest.kt b/app/src/test/java/com/picpay/desafio/android/repository/local/UserLocalDataSourceImpTest.kt new file mode 100644 index 000000000..6d5b4b141 --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/repository/local/UserLocalDataSourceImpTest.kt @@ -0,0 +1,24 @@ +package com.picpay.desafio.android.repository.local + +import com.picpay.desafio.android.database.dao.UserDao +import com.picpay.desafio.android.repository.model.User +import com.picpay.desafio.android.repository.model.UserLocal +import io.mockk.mockk +import org.junit.Assert +import org.junit.Test + +class UserLocalDataSourceImpTest { + + private val daoMock: UserDao = mockk() + private val userLocalDataSourceImp: UserLocalDataSourceImp = UserLocalDataSourceImp(daoMock) + + @Test + fun must_devolver_list_user_local_when_receive_list_user() { + val userList = arrayListOf(User(img = "", name = "Roberta", id = 1, username = "Maria")) + val userListLocal = arrayListOf(UserLocal(img = "", name = "Roberta", id = 1, username = "Maria")) + + val listConverted = userLocalDataSourceImp.convertForUserLocalList(userList) + + Assert.assertEquals(userListLocal, listConverted) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSourceImpTest.kt b/app/src/test/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSourceImpTest.kt new file mode 100644 index 000000000..4840bfc46 --- /dev/null +++ b/app/src/test/java/com/picpay/desafio/android/repository/remote/UserRemoteDataSourceImpTest.kt @@ -0,0 +1,93 @@ +package com.picpay.desafio.android.repository.remote + +import com.picpay.desafio.android.repository.model.User +import com.picpay.desafio.android.repository.remote.webclient.PicPayService +import io.mockk.* +import org.junit.Test +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class UserRemoteDataSourceImpTest{ + private var picPayServiceMock: PicPayService = mockk() + private val userRemoteDataSource: UserRemoteDataSource = spyk(UserRemoteDataSourceImp(picPayServiceMock)) + + @Test + fun must_return_success_when_api_return_success() { + val success: (List) -> Unit = {} + val successSlot = slot<(List) -> Unit>() + val failure: (String) -> Unit = {} + + val callMock: Call> = mockk() + val responseMock: Response> = mockk() + + every { picPayServiceMock.getUsers() } returns callMock + every { callMock.enqueue(any()) } answers { + (args[0] as Callback>).apply { + onResponse(mockk(), responseMock) + } + } + + responseMock.apply { + every { isSuccessful } answers { true } + every { body() } returns mockk() + } + + run { userRemoteDataSource.getUser(success, failure) } + + verify { userRemoteDataSource.getUser(capture(successSlot), failure) } + } + + @Test + fun must_return_failure_when_api_return_failure() { + val success: (List) -> Unit = {} + val failure: (String) -> Unit = {} + val failureSlot = slot<(String) -> Unit>() + failureSlot.captured = failure + + val callMock: Call> = mockk() + val responseMock: Response> = mockk() + + every { picPayServiceMock.getUsers() } returns callMock + every { callMock.enqueue(any()) } answers { + (args[0] as Callback>).apply { + onResponse(mockk(), responseMock) + } + } + + responseMock.apply { + every { isSuccessful } answers { false } + every { message() } answers {""} + } + + run { userRemoteDataSource.getUser(success = success, failure = failure) } + + verify { userRemoteDataSource.getUser(success = any(), failure = capture(failureSlot)) } + } + + @Test + fun must_devolve_failure_when_the_connection_with_the_api_failure(){ + val success : (List) -> Unit = {} + val failure : (String) -> Unit = {} + val failureSlot = slot<(String) -> Unit>() + failureSlot.captured = failure + + val callMock: Call> = mockk() + val throwableMock: Throwable = mockk() + + every { picPayServiceMock.getUsers() } returns callMock + every { callMock.enqueue(any()) } answers { + (args[0] as Callback>).apply { + onFailure(mockk(), throwableMock) + } + } + + throwableMock.apply { + every { message } answers {""} + } + + run { userRemoteDataSource.getUser(success, failure) } + verify { userRemoteDataSource.getUser(any(), capture(failureSlot)) } + + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7d1b94f34..21a2c0c92 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ buildscript { koin_version = "2.0.1" dagger_version = "2.23.2" lifecycle_version = "2.2.0" - coroutines_version = "1.3.3" + coroutines_version = "1.4.3" rxjava_version = "2.2.17" rxandroid_version = "2.1.1" core_ktx_test_version = "1.2.0" @@ -34,6 +34,7 @@ buildscript { repositories { google() jcenter() + mavenCentral() } dependencies { diff --git a/gradlew b/gradlew old mode 100644 new mode 100755