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

View File

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

View File

@@ -3,4 +3,4 @@
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">www.nactem.ac.uk</domain> <domain includeSubdomains="true">www.nactem.ac.uk</domain>
</domain-config> </domain-config>
</network-security-config> </network-security-config>

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 { sealed class RequestState {
class Success<out T>(val data: T) : RequestState() class Success<out T>(val data: T) : RequestState()
object Loading : 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 import androidx.lifecycle.Observer
fun <T> LiveData<T>.observeNonNull(owner: LifecycleOwner, func: (T) -> Unit) { fun <T> LiveData<T>.observeNonNull(owner: LifecycleOwner, func: (T) -> Unit) {
observe(owner, Observer { observe(owner, {
it?.let { it?.let {
func(it) 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="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
</resources> <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> <resources>
<string name="app_name">core</string> <string name="network_error_bad_response">Respuesta inesperada</string>
</resources> <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 dev.carlos.shortform.data.cloud.model.ShortformRemote
import io.reactivex.Single import io.reactivex.Single
interface AcronymsRemoteSource { interface ShortformRemoteSource {
fun getAcronymDefinition(acronym: String): Single<List<ShortformRemote>> 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.GET
import retrofit2.http.Query import retrofit2.http.Query
interface AcronymsService { interface ShortformService {
@GET("/software/acromine/dictionary.py") @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 dev.carlos.core.scheduler.Scheduler
import io.reactivex.Single import io.reactivex.Single
class GetAcronymDefinition( class GetShortformDefinition(
private val acronymsRepository: AcronymsRepository, private val shortformRepository: ShortformRepository,
private val scheduler: Scheduler private val scheduler: Scheduler
) { ) {
fun getAcronymDefinition(acronym: String): Single<ShortformModel> { fun getShortformDefinition(shortform: String): Single<ShortformModel> {
return acronymsRepository.getAcronymDefinition(acronym).runOnIo(scheduler) 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.Flowable
import io.reactivex.Single import io.reactivex.Single
interface AcronymsRepository { interface ShortformRepository {
fun getAcronymDefinition(acronym: String): Single<ShortformModel> 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> <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> </resources>