Uploads: Use StateView for better Loading/Empty rendering

This commit is contained in:
Benoit Marty 2020-05-20 13:24:42 +02:00
parent 2adafbeb03
commit a95102a78f
8 changed files with 125 additions and 133 deletions

View file

@ -21,6 +21,7 @@ import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
import androidx.core.view.isVisible
import im.vector.riotx.R
import kotlinx.android.synthetic.main.view_state.view.*
@ -31,6 +32,7 @@ class StateView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
object Content : State()
object Loading : State()
data class Empty(val title: CharSequence? = null, val image: Drawable? = null, val message: CharSequence? = null) : State()
data class Error(val message: CharSequence? = null) : State()
}
@ -59,34 +61,21 @@ class StateView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
}
private fun update(newState: State) {
progressBar.isVisible = newState is State.Loading
errorView.isVisible = newState is State.Error
emptyView.isVisible = newState is State.Empty
contentView?.isVisible = newState is State.Content
when (newState) {
is State.Content -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.INVISIBLE
contentView?.visibility = View.VISIBLE
}
is State.Loading -> {
progressBar.visibility = View.VISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.INVISIBLE
contentView?.visibility = View.INVISIBLE
}
is State.Content -> Unit
is State.Loading -> Unit
is State.Empty -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.INVISIBLE
emptyView.visibility = View.VISIBLE
emptyImageView.setImageDrawable(newState.image)
emptyMessageView.text = newState.message
emptyTitleView.text = newState.title
contentView?.visibility = View.INVISIBLE
}
is State.Error -> {
progressBar.visibility = View.INVISIBLE
errorView.visibility = View.VISIBLE
emptyView.visibility = View.INVISIBLE
errorMessageView.text = newState.message
contentView?.visibility = View.INVISIBLE
}
}
}

View file

@ -18,6 +18,10 @@ package im.vector.riotx.features.roomprofile.uploads.files
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.matrix.android.api.session.room.uploads.UploadEvent
@ -25,31 +29,37 @@ import im.vector.riotx.R
import im.vector.riotx.core.extensions.cleanup
import im.vector.riotx.core.extensions.configureWith
import im.vector.riotx.core.extensions.trackItemsVisibilityChange
import im.vector.riotx.core.platform.StateView
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsAction
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewModel
import kotlinx.android.synthetic.main.fragment_generic_recycler.*
import kotlinx.android.synthetic.main.fragment_generic_state_view_recycler.*
import javax.inject.Inject
class RoomUploadsFilesFragment @Inject constructor(
private val controller: UploadsFileController
) : VectorBaseFragment(), UploadsFileController.Listener {
) : VectorBaseFragment(),
UploadsFileController.Listener,
StateView.EventCallback {
private val uploadsViewModel by parentFragmentViewModel(RoomUploadsViewModel::class)
override fun getLayoutResId() = R.layout.fragment_generic_recycler
override fun getLayoutResId() = R.layout.fragment_generic_state_view_recycler
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.trackItemsVisibilityChange()
recyclerView.configureWith(controller, showDivider = true)
genericStateViewListStateView.contentView = genericStateViewListRecycler
genericStateViewListStateView.eventCallback = this
genericStateViewListRecycler.trackItemsVisibilityChange()
genericStateViewListRecycler.configureWith(controller, showDivider = true)
controller.listener = this
}
override fun onDestroyView() {
super.onDestroyView()
recyclerView.cleanup()
genericStateViewListRecycler.cleanup()
controller.listener = null
}
@ -58,7 +68,7 @@ class RoomUploadsFilesFragment @Inject constructor(
uploadsViewModel.handle(RoomUploadsAction.Share(uploadEvent))
}
override fun onRetry() {
override fun onRetryClicked() {
uploadsViewModel.handle(RoomUploadsAction.Retry)
}
@ -75,6 +85,29 @@ class RoomUploadsFilesFragment @Inject constructor(
}
override fun invalidate() = withState(uploadsViewModel) { state ->
controller.setData(state)
if (state.fileEvents.isEmpty()) {
when (state.asyncEventsRequest) {
is Loading -> {
genericStateViewListStateView.state = StateView.State.Loading
}
is Fail -> {
genericStateViewListStateView.state = StateView.State.Error(errorFormatter.toHumanReadable(state.asyncEventsRequest.error))
}
is Success -> {
if (state.hasMore) {
// We need to load more items
loadMore()
} else {
genericStateViewListStateView.state = StateView.State.Empty(
title = getString(R.string.uploads_files_no_result),
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_file)
)
}
}
}
} else {
genericStateViewListStateView.state = StateView.State.Content
controller.setData(state)
}
}
}

View file

@ -18,28 +18,20 @@ package im.vector.riotx.features.roomprofile.uploads.files
import com.airbnb.epoxy.TypedEpoxyController
import com.airbnb.epoxy.VisibilityState
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import im.vector.matrix.android.api.session.room.uploads.UploadEvent
import im.vector.riotx.R
import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.core.epoxy.errorWithRetryItem
import im.vector.riotx.core.epoxy.loadingItem
import im.vector.riotx.core.epoxy.noResultItem
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewState
import javax.inject.Inject
class UploadsFileController @Inject constructor(
private val errorFormatter: ErrorFormatter,
private val stringProvider: StringProvider,
private val dateFormatter: VectorDateFormatter
) : TypedEpoxyController<RoomUploadsViewState>() {
interface Listener {
fun onRetry()
fun loadMore()
fun onOpenClicked(uploadEvent: UploadEvent)
fun onDownloadClicked(uploadEvent: UploadEvent)
@ -57,46 +49,15 @@ class UploadsFileController @Inject constructor(
override fun buildModels(data: RoomUploadsViewState?) {
data ?: return
if (data.fileEvents.isEmpty()) {
when (data.asyncEventsRequest) {
is Loading -> {
loadingItem {
id("loading")
}
}
is Fail -> {
errorWithRetryItem {
id("error")
text(errorFormatter.toHumanReadable(data.asyncEventsRequest.error))
listener { listener?.onRetry() }
}
}
is Success -> {
if (data.hasMore) {
// We need to load more items
listener?.loadMore()
loadingItem {
id("loading")
}
} else {
noResultItem {
id("noResult")
text(stringProvider.getString(R.string.uploads_files_no_result))
}
}
}
}
} else {
buildFileItems(data.fileEvents)
buildFileItems(data.fileEvents)
if (data.hasMore) {
loadingItem {
// Always use a different id, because we can be notified several times of visibility state changed
id("loadMore${idx++}")
onVisibilityStateChanged { _, _, visibilityState ->
if (visibilityState == VisibilityState.VISIBLE) {
listener?.loadMore()
}
if (data.hasMore) {
loadingItem {
// Always use a different id, because we can be notified several times of visibility state changed
id("loadMore${idx++}")
onVisibilityStateChanged { _, _, visibilityState ->
if (visibilityState == VisibilityState.VISIBLE) {
listener?.loadMore()
}
}
}

View file

@ -19,37 +19,47 @@ package im.vector.riotx.features.roomprofile.uploads.media
import android.os.Bundle
import android.util.DisplayMetrics
import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.GridLayoutManager
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.riotx.R
import im.vector.riotx.core.extensions.cleanup
import im.vector.riotx.core.extensions.trackItemsVisibilityChange
import im.vector.riotx.core.platform.StateView
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.DimensionConverter
import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsAction
import im.vector.riotx.features.roomprofile.uploads.RoomUploadsViewModel
import kotlinx.android.synthetic.main.fragment_generic_recycler.*
import kotlinx.android.synthetic.main.fragment_generic_state_view_recycler.*
import javax.inject.Inject
class RoomUploadsMediaFragment @Inject constructor(
private val controller: UploadsMediaController,
private val dimensionConverter: DimensionConverter
) : VectorBaseFragment(), UploadsMediaController.Listener {
) : VectorBaseFragment(),
UploadsMediaController.Listener,
StateView.EventCallback {
private val uploadsViewModel by parentFragmentViewModel(RoomUploadsViewModel::class)
override fun getLayoutResId() = R.layout.fragment_generic_recycler
override fun getLayoutResId() = R.layout.fragment_generic_state_view_recycler
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.trackItemsVisibilityChange()
recyclerView.layoutManager = GridLayoutManager(context, getNumberOfColumns())
recyclerView.adapter = controller.adapter
recyclerView.setHasFixedSize(true)
genericStateViewListStateView.contentView = genericStateViewListRecycler
genericStateViewListStateView.eventCallback = this
genericStateViewListRecycler.trackItemsVisibilityChange()
genericStateViewListRecycler.layoutManager = GridLayoutManager(context, getNumberOfColumns())
genericStateViewListRecycler.adapter = controller.adapter
genericStateViewListRecycler.setHasFixedSize(true)
controller.listener = this
}
@ -62,7 +72,7 @@ class RoomUploadsMediaFragment @Inject constructor(
override fun onDestroyView() {
super.onDestroyView()
recyclerView.cleanup()
genericStateViewListRecycler.cleanup()
controller.listener = null
}
@ -78,11 +88,34 @@ class RoomUploadsMediaFragment @Inject constructor(
uploadsViewModel.handle(RoomUploadsAction.LoadMore)
}
override fun onRetry() {
override fun onRetryClicked() {
uploadsViewModel.handle(RoomUploadsAction.Retry)
}
override fun invalidate() = withState(uploadsViewModel) { state ->
controller.setData(state)
if (state.mediaEvents.isEmpty()) {
when (state.asyncEventsRequest) {
is Loading -> {
genericStateViewListStateView.state = StateView.State.Loading
}
is Fail -> {
genericStateViewListStateView.state = StateView.State.Error(errorFormatter.toHumanReadable(state.asyncEventsRequest.error))
}
is Success -> {
if (state.hasMore) {
// We need to load more items
loadMore()
} else {
genericStateViewListStateView.state = StateView.State.Empty(
title = getString(R.string.uploads_media_no_result),
image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_image)
)
}
}
}
} else {
genericStateViewListStateView.state = StateView.State.Content
controller.setData(state)
}
}
}

View file

@ -19,18 +19,12 @@ package im.vector.riotx.features.roomprofile.uploads.media
import android.view.View
import com.airbnb.epoxy.TypedEpoxyController
import com.airbnb.epoxy.VisibilityState
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.uploads.UploadEvent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.errorWithRetryItem
import im.vector.riotx.core.epoxy.noResultItem
import im.vector.riotx.core.epoxy.squareLoadingItem
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.resources.StringProvider
@ -48,7 +42,6 @@ class UploadsMediaController @Inject constructor(
) : TypedEpoxyController<RoomUploadsViewState>() {
interface Listener {
fun onRetry()
fun onOpenImageClicked(view: View, mediaData: ImageContentRenderer.Data)
fun onOpenVideoClicked(view: View, mediaData: VideoContentRenderer.Data)
fun loadMore()
@ -67,46 +60,15 @@ class UploadsMediaController @Inject constructor(
override fun buildModels(data: RoomUploadsViewState?) {
data ?: return
if (data.mediaEvents.isEmpty()) {
when (data.asyncEventsRequest) {
is Loading -> {
squareLoadingItem {
id("loading")
}
}
is Fail -> {
errorWithRetryItem {
id("error")
text(errorFormatter.toHumanReadable(data.asyncEventsRequest.error))
listener { listener?.onRetry() }
}
}
is Success -> {
if (data.hasMore) {
// We need to load more items
listener?.loadMore()
squareLoadingItem {
id("loading")
}
} else {
noResultItem {
id("noResult")
text(stringProvider.getString(R.string.uploads_media_no_result))
}
}
}
}
} else {
buildMediaItems(data.mediaEvents)
buildMediaItems(data.mediaEvents)
if (data.hasMore) {
squareLoadingItem {
// Always use a different id, because we can be notified several times of visibility state changed
id("loadMore${idx++}")
onVisibilityStateChanged { _, _, visibilityState ->
if (visibilityState == VisibilityState.VISIBLE) {
listener?.loadMore()
}
if (data.hasMore) {
squareLoadingItem {
// Always use a different id, because we can be notified several times of visibility state changed
id("loadMore${idx++}")
onVisibilityStateChanged { _, _, visibilityState ->
if (visibilityState == VisibilityState.VISIBLE) {
listener?.loadMore()
}
}
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<im.vector.riotx.core.platform.StateView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/genericStateViewListStateView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/genericStateViewListRecycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="always" />
</im.vector.riotx.core.platform.StateView>

View file

@ -73,6 +73,7 @@
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_gravity="center_horizontal"
android:tint="?riotx_text_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"

View file

@ -1789,11 +1789,11 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
<string name="error_handling_incoming_share">Couldn\'t handle share data</string>
<string name="uploads_media_title">MEDIA</string>
<string name="uploads_media_no_result">There is no media in this room</string>
<string name="uploads_media_no_result">There are no media in this room</string>
<string name="uploads_files_title">FILES</string>
<!-- First parameter is a username and second is a date Example: "Matthew at 12:00 on 01/01/01" -->
<string name="uploads_files_subtitle">%1$s at %2$s</string>
<string name="uploads_files_no_result">There is no files in this room</string>
<string name="uploads_files_no_result">There are no files in this room</string>
<string name="report_content_spam">"It's spam"</string>
<string name="report_content_inappropriate">"It's inappropriate"</string>