Merge pull request #6 from hakodeveloper/acronyms/add-presentation-layer

Acronyms feature: Add presentation layer
This commit is contained in:
Carlos Martinez
2021-06-17 13:29:52 -04:00
committed by GitHub
28 changed files with 448 additions and 71 deletions

View File

@@ -1,7 +1,7 @@
package dev.carlos.acronyms
import android.app.Application
import dev.carlos.shortform.di.acronymsModule
import dev.carlos.shortform.di.shortformModule
import dev.carlos.acronyms.di.appModules
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
@@ -26,7 +26,7 @@ class MainApplication : Application() {
modules(
listOf(
appModules,
acronymsModule
shortformModule
)
)
}

View File

@@ -1,6 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main_navigation"
app:startDestination="@id/shortform_navigation"
>
<include app:graph="@navigation/shortform_navigation" />
</navigation>

View File

@@ -0,0 +1,10 @@
package dev.carlos.core.domain.network
import androidx.annotation.StringRes
import dev.carlos.core.R
enum class RequestError(@StringRes val message: Int) {
NO_NETWORK(R.string.network_error_no_network),
BAD_RESPONSE(R.string.network_error_bad_response),
UNKNOWN_PROBLEM(R.string.network_error_unknown)
}

View File

@@ -3,5 +3,6 @@ package dev.carlos.core.domain.network
sealed class RequestState {
class Success<out T>(val data: T) : RequestState()
object Loading : RequestState()
class Error(val throwable: Throwable) : RequestState()
class Error(val type: RequestError) : RequestState()
object Empty : RequestState()
}

View File

@@ -5,7 +5,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
fun <T> LiveData<T>.observeNonNull(owner: LifecycleOwner, func: (T) -> Unit) {
observe(owner, Observer {
observe(owner, {
it?.let {
func(it)
}

View File

@@ -0,0 +1,5 @@
package dev.carlos.core.extensions
import java.util.*
fun String.capitalize() = replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }

View File

@@ -0,0 +1,17 @@
package dev.carlos.core.viewmodel
import androidx.lifecycle.ViewModel
import io.reactivex.disposables.CompositeDisposable
abstract class RxViewModel : ViewModel() {
protected val compositeDisposable by lazy { CompositeDisposable() }
override fun onCleared() {
super.onCleared()
clearCompositeDisposable()
}
private fun clearCompositeDisposable() {
compositeDisposable.clear()
}
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape >
<solid android:color="@color/card_background" />
<corners android:radius="4dp" />
</shape>
</item>
</layer-list>

View File

@@ -7,4 +7,7 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="soft_background">#EEEEEE</color>
<color name="transparent">#00000000</color>
<color name="card_background">#FFFFFF</color>
</resources>

View File

@@ -1,3 +1,5 @@
<resources>
<string name="app_name">core</string>
<string name="network_error_bad_response">Respuesta inesperada</string>
<string name="network_error_no_network">Sin conexión</string>
<string name="network_error_unknown">Error desconocido</string>
</resources>

View File

@@ -1,16 +0,0 @@
package dev.carlos.shortform.data
import dev.carlos.shortform.data.cloud.AcronymsRemoteSource
import dev.carlos.shortform.data.models.ShortformModel
import dev.carlos.shortform.data.models.toShortformModel
import dev.carlos.shortform.domain.AcronymsRepository
import io.reactivex.Single
class AcronymsDataRepository(
private val remoteDatasource: AcronymsRemoteSource
) : AcronymsRepository {
override fun getAcronymDefinition(acronym: String): Single<ShortformModel> {
return remoteDatasource.getAcronymDefinition(acronym).map { it.single()?.toShortformModel() }
}
}

View File

@@ -0,0 +1,16 @@
package dev.carlos.shortform.data
import dev.carlos.shortform.data.cloud.ShortformRemoteSource
import dev.carlos.shortform.data.models.ShortformModel
import dev.carlos.shortform.data.models.toShortformModel
import dev.carlos.shortform.domain.ShortformRepository
import io.reactivex.Single
class ShortformDataRepository(
private val remoteDatasource: ShortformRemoteSource
) : ShortformRepository {
override fun getShortformDefinition(acronym: String): Single<ShortformModel> {
return remoteDatasource.getShortformDefinition(acronym).map { it.single().toShortformModel() }
}
}

View File

@@ -3,6 +3,6 @@ package dev.carlos.shortform.data.cloud
import dev.carlos.shortform.data.cloud.model.ShortformRemote
import io.reactivex.Single
interface AcronymsRemoteSource {
fun getAcronymDefinition(acronym: String): Single<List<ShortformRemote>>
interface ShortformRemoteSource {
fun getShortformDefinition(acronym: String): Single<List<ShortformRemote>>
}

View File

@@ -1,11 +0,0 @@
package dev.carlos.shortform.data.cloud.retrofit
import dev.carlos.shortform.data.cloud.AcronymsRemoteSource
import dev.carlos.shortform.data.cloud.model.ShortformRemote
import io.reactivex.Single
class AcronymsRemoteDatasource(private val acronymsService: AcronymsService) : AcronymsRemoteSource {
override fun getAcronymDefinition(acronym: String): Single<List<ShortformRemote>> {
return acronymsService.getAcronymDefinition(acronym)
}
}

View File

@@ -0,0 +1,11 @@
package dev.carlos.shortform.data.cloud.retrofit
import dev.carlos.shortform.data.cloud.ShortformRemoteSource
import dev.carlos.shortform.data.cloud.model.ShortformRemote
import io.reactivex.Single
class ShortformRemoteDatasource(private val shortformService: ShortformService) : ShortformRemoteSource {
override fun getShortformDefinition(acronym: String): Single<List<ShortformRemote>> {
return shortformService.getShortformDefinition(acronym)
}
}

View File

@@ -5,7 +5,7 @@ import io.reactivex.Single
import retrofit2.http.GET
import retrofit2.http.Query
interface AcronymsService {
interface ShortformService {
@GET("/software/acromine/dictionary.py")
fun getAcronymDefinition(@Query("sf") acronym: String): Single<List<ShortformRemote>>
fun getShortformDefinition(@Query("sf") acronym: String): Single<List<ShortformRemote>>
}

View File

@@ -1,25 +0,0 @@
package dev.carlos.shortform.di
import dev.carlos.core.domain.network.RemoteClient
import dev.carlos.core.scheduler.Scheduler
import dev.carlos.core.scheduler.SchedulerProvider
import dev.carlos.shortform.data.AcronymsDataRepository
import dev.carlos.shortform.data.cloud.AcronymsRemoteSource
import dev.carlos.shortform.data.cloud.retrofit.AcronymsRemoteDatasource
import dev.carlos.shortform.data.cloud.retrofit.AcronymsService
import dev.carlos.shortform.domain.AcronymsRepository
import dev.carlos.shortform.domain.GetAcronymDefinition
import org.koin.dsl.module
val acronymsModule = module {
single<Scheduler> { SchedulerProvider() }
single {
get<RemoteClient>().getClient(AcronymsService::class.java)
}
factory<AcronymsRemoteSource> { AcronymsRemoteDatasource(get()) }
factory<AcronymsRepository> { AcronymsDataRepository(get()) }
factory { GetAcronymDefinition(get(), get()) }
}

View File

@@ -0,0 +1,29 @@
package dev.carlos.shortform.di
import dev.carlos.core.domain.network.RemoteClient
import dev.carlos.core.scheduler.Scheduler
import dev.carlos.core.scheduler.SchedulerProvider
import dev.carlos.shortform.data.ShortformDataRepository
import dev.carlos.shortform.data.cloud.ShortformRemoteSource
import dev.carlos.shortform.data.cloud.retrofit.ShortformRemoteDatasource
import dev.carlos.shortform.data.cloud.retrofit.ShortformService
import dev.carlos.shortform.domain.ShortformRepository
import dev.carlos.shortform.domain.GetShortformDefinition
import dev.carlos.shortform.viewmodels.ShortformViewmodel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val shortformModule = module {
single<Scheduler> { SchedulerProvider() }
single {
get<RemoteClient>().getClient(ShortformService::class.java)
}
factory<ShortformRemoteSource> { ShortformRemoteDatasource(get()) }
factory<ShortformRepository> { ShortformDataRepository(get()) }
factory { GetShortformDefinition(get(), get()) }
viewModel { ShortformViewmodel(get()) }
}

View File

@@ -5,11 +5,11 @@ import dev.carlos.core.extensions.runOnIo
import dev.carlos.core.scheduler.Scheduler
import io.reactivex.Single
class GetAcronymDefinition(
private val acronymsRepository: AcronymsRepository,
class GetShortformDefinition(
private val shortformRepository: ShortformRepository,
private val scheduler: Scheduler
) {
fun getAcronymDefinition(acronym: String): Single<ShortformModel> {
return acronymsRepository.getAcronymDefinition(acronym).runOnIo(scheduler)
fun getShortformDefinition(shortform: String): Single<ShortformModel> {
return shortformRepository.getShortformDefinition(shortform).runOnIo(scheduler)
}
}

View File

@@ -4,6 +4,6 @@ import dev.carlos.shortform.data.models.ShortformModel
import io.reactivex.Flowable
import io.reactivex.Single
interface AcronymsRepository {
fun getAcronymDefinition(acronym: String): Single<ShortformModel>
interface ShortformRepository {
fun getShortformDefinition(acronym: String): Single<ShortformModel>
}

View File

@@ -0,0 +1,90 @@
package dev.carlos.shortform.feature
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import dev.carlos.core.domain.network.RequestError
import dev.carlos.core.domain.network.RequestState
import dev.carlos.core.extensions.gone
import dev.carlos.core.extensions.observeNonNull
import dev.carlos.core.extensions.visible
import dev.carlos.shortform.R
import dev.carlos.shortform.data.models.ShortformModel
import dev.carlos.shortform.databinding.FragmentShortformDefinitionBinding
import dev.carlos.shortform.viewmodels.ShortformViewmodel
import dev.carlos.shortform.widgets.LongformAdapter
import org.koin.androidx.viewmodel.ext.android.viewModel
class ShortformFragment : Fragment() {
private val viewModel: ShortformViewmodel by viewModel()
private val listAdapter by lazy { LongformAdapter() }
private lateinit var binding: FragmentShortformDefinitionBinding
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View = inflater.inflate(R.layout.fragment_shortform_definition, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setBinding(view)
setRecycler()
setObservers()
}
private fun setBinding(view: View) {
binding = FragmentShortformDefinitionBinding.bind(view)
}
private fun setObservers() {
viewModel.acronymDefinition.observeNonNull(this) {
handleResult(it)
}
}
private fun handleResult(state: RequestState) {
when (state) {
is RequestState.Success<*> -> handleSuccess(state.data as ShortformModel)
is RequestState.Empty -> handleEmpty()
is RequestState.Error -> handleError(state.type)
is RequestState.Loading -> handleLoading()
}
}
private fun handleLoading() {
binding.shortformDefinitionLoading.visible()
binding.shortformDefinitionError.gone()
}
private fun handleSuccess(shortform: ShortformModel) {
binding.shortformDefinitionError.gone()
binding.shortformDefinitionLoading.gone()
listAdapter.addAll(shortform.results)
}
private fun handleEmpty() {
binding.shortformDefinitionErrorLabel.text = getString(R.string.shortform_definition_no_definition)
binding.shortformDefinitionError.visible()
binding.shortformDefinitionLoading.gone()
}
private fun handleError(error: RequestError) {
binding.shortformDefinitionErrorLabel.text = getString(error.message)
binding.shortformDefinitionError.visible()
binding.shortformDefinitionLoading.gone()
}
private fun setRecycler() {
binding.shortformDefinitionRecycler.apply {
layoutManager = LinearLayoutManager(context)
adapter = listAdapter.apply {
onItemClick = {
Toast.makeText(context, it.value, Toast.LENGTH_SHORT).show()
}
}
}
}
}

View File

@@ -0,0 +1,37 @@
package dev.carlos.shortform.viewmodels
import androidx.lifecycle.MutableLiveData
import dev.carlos.core.domain.network.RequestError
import dev.carlos.core.domain.network.RequestState
import dev.carlos.core.viewmodel.RxViewModel
import dev.carlos.shortform.data.models.ShortformModel
import dev.carlos.shortform.domain.GetShortformDefinition
import retrofit2.HttpException
import java.net.UnknownHostException
class ShortformViewmodel(
private val getAcronymsDefinition: GetShortformDefinition
) : RxViewModel() {
val acronymDefinition = MutableLiveData<RequestState>()
fun fetchAcronymDefinition(acronym: String) {
val disposable = getAcronymsDefinition.getShortformDefinition(acronym)
.doOnSubscribe { acronymDefinition.postValue(RequestState.Loading) }
.subscribe(::handleSuccess, ::handleError)
compositeDisposable.add(disposable)
}
private fun handleSuccess(definition: ShortformModel) {
acronymDefinition.postValue(RequestState.Success(definition))
}
private fun handleError(exception: Throwable) {
when (exception) {
is NoSuchElementException -> acronymDefinition.postValue(RequestState.Empty)
is UnknownHostException -> acronymDefinition.postValue(RequestState.Error(RequestError.NO_NETWORK))
is HttpException -> acronymDefinition.postValue(RequestState.Error(RequestError.BAD_RESPONSE))
else -> acronymDefinition.postValue(RequestState.Error(RequestError.UNKNOWN_PROBLEM))
}
}
}

View File

@@ -0,0 +1,60 @@
package dev.carlos.shortform.widgets
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import dev.carlos.core.extensions.autoNotify
import dev.carlos.core.extensions.capitalize
import dev.carlos.shortform.R
import dev.carlos.shortform.data.models.ShortformModel
import dev.carlos.shortform.databinding.ItemLongformCardBinding
import kotlin.properties.Delegates
class LongformAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items by Delegates.observable(emptyList<ShortformModel.LongformModel>()) { _, oldList, newList ->
autoNotify(oldList, newList) { old, new -> old.value == new.value }
notifyDataSetChanged()
}
var onItemClick: (ShortformModel.LongformModel) -> Unit = { }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
LongformViewHolder(
LayoutInflater
.from(parent.context)
.inflate(R.layout.item_longform_card, parent, false),
onItemClick
)
fun getItem(position: Int) = items[position]
fun addAll(list: List<ShortformModel.LongformModel>) {
items = list
}
override fun getItemCount() = items.size
override fun onBindViewHolder(viewholder: RecyclerView.ViewHolder, position: Int) =
when (viewholder) {
is LongformViewHolder -> viewholder.bind(items[position])
else -> throw NoWhenBranchMatchedException("Undefined viewholder")
}
}
class LongformViewHolder(
private val view: View,
private val onItemClick: (ShortformModel.LongformModel) -> Unit
) : RecyclerView.ViewHolder(view) {
private val binding = ItemLongformCardBinding.bind(view)
fun bind(longform: ShortformModel.LongformModel) = with(view) {
binding.itemLongformName.text = longform.value.capitalize()
binding.itemLongformSince.text = view.resources.getString(R.string.item_longform_since, longform.since)
binding.itemLongformContainer.setOnClickListener {
onItemClick(longform)
}
}
}

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<EditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:hint="@string/shortform_definition_search"
android:textSize="18sp"
android:autofillHints="no"
android:id="@+id/shortform_definition_search_field"
android:layout_weight="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/shortform_definition_recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/shortform_definition_search_field"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
/>
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/shortform_definition_error"
android:visibility="gone"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/white"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/shortform_definition_search_field"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
>
<TextView
android:id="@+id/shortform_definition_error_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
tools:text="Error"
/>
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:visibility="gone"
android:id="@+id/shortform_definition_loading"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="@color/white"
android:gravity="center"
app:layout_constraintTop_toBottomOf="@id/shortform_definition_search_field"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
>
<ProgressBar
android:id="@+id/shortform_definition_loading_bar"
android:indeterminate="true"
android:indeterminateBehavior="repeat"
android:layout_width="36dp"
android:layout_height="36dp"
/>
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/item_longform_container"
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:background="@drawable/bg_card"
android:elevation="2dp"
android:orientation="vertical">
<TextView
android:id="@+id/item_longform_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="12dp"
android:textSize="18sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Adenovirus receptor" />
<TextView
android:id="@+id/item_longform_since"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
app:layout_constraintStart_toStartOf="@+id/item_longform_name"
app:layout_constraintTop_toBottomOf="@+id/item_longform_name"
tools:text="Since: 1997" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/shortform_navigation"
app:startDestination="@id/shortformFragment"
>
<fragment
android:id="@+id/shortformFragment"
android:name="dev.carlos.shortform.feature.ShortformFragment"
tools:layout="@layout/fragment_shortform_definition"
android:label="Acronyms"
/>
</navigation>

View File

@@ -1,3 +1,5 @@
<resources>
<string name="app_name">acronyms</string>
<string name="item_longform_since">Desde %1$d</string>
<string name="shortform_definition_search">Buscar acrónimo</string>
<string name="shortform_definition_no_definition">No existe una definición</string>
</resources>