3430: Make list refresh/retry consistent (#3474)

* 3430: Make list refresh/retry consistent

* 3430: Add swipe-to-refresh and use states in filter lists
This commit is contained in:
UlrichKu 2023-03-30 19:29:42 +02:00 committed by GitHub
parent 57ed98f305
commit 23381d45d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 117 additions and 59 deletions

View file

@ -101,6 +101,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
DividerItemDecoration(this, DividerItemDecoration.VERTICAL) DividerItemDecoration(this, DividerItemDecoration.VERTICAL)
) )
binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
lifecycleScope.launch { lifecycleScope.launch {
viewModel.state.collect(this@ListsActivity::update) viewModel.state.collect(this@ListsActivity::update)
} }
@ -166,6 +169,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector {
private fun update(state: ListsViewModel.State) { private fun update(state: ListsViewModel.State) {
adapter.submitList(state.lists) adapter.submitList(state.lists)
binding.progressBar.visible(state.loadingState == LOADING) binding.progressBar.visible(state.loadingState == LOADING)
binding.swipeRefreshLayout.isRefreshing = state.loadingState == LOADING
when (state.loadingState) { when (state.loadingState) {
INITIAL, LOADING -> binding.messageView.hide() INITIAL, LOADING -> binding.messageView.hide()
ERROR_NETWORK -> { ERROR_NETWORK -> {

View file

@ -89,9 +89,11 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
val layoutManager = LinearLayoutManager(view.context) val layoutManager = LinearLayoutManager(view.context)
binding.recyclerView.layoutManager = layoutManager binding.recyclerView.layoutManager = layoutManager
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL))
binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
val pm = PreferenceManager.getDefaultSharedPreferences(view.context) val pm = PreferenceManager.getDefaultSharedPreferences(view.context)
val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
@ -287,6 +289,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
return return
} }
fetching = true fetching = true
binding.swipeRefreshLayout.isRefreshing = true
if (fromId != null) { if (fromId != null) {
binding.recyclerView.post { adapter.setBottomLoading(true) } binding.recyclerView.post { adapter.setBottomLoading(true) }
@ -295,6 +298,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
try { try {
val response = getFetchCallByListType(fromId) val response = getFetchCallByListType(fromId)
if (!response.isSuccessful) { if (!response.isSuccessful) {
onFetchAccountsFailure(Exception(response.message())) onFetchAccountsFailure(Exception(response.message()))
return@launch return@launch
@ -317,6 +321,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) { private fun onFetchAccountsSuccess(accounts: List<TimelineAccount>, linkHeader: String?) {
adapter.setBottomLoading(false) adapter.setBottomLoading(false)
binding.swipeRefreshLayout.isRefreshing = false
val links = HttpHeaderLink.parse(linkHeader) val links = HttpHeaderLink.parse(linkHeader)
val next = HttpHeaderLink.findByRelationType(links, "next") val next = HttpHeaderLink.findByRelationType(links, "next")
@ -366,6 +371,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct
private fun onFetchAccountsFailure(throwable: Throwable) { private fun onFetchAccountsFailure(throwable: Throwable) {
fetching = false fetching = false
binding.swipeRefreshLayout.isRefreshing = false
Log.e(TAG, "Fetch failure", throwable) Log.e(TAG, "Fetch failure", throwable)
if (adapter.itemCount == 0) { if (adapter.itemCount == 0) {

View file

@ -141,9 +141,13 @@ class ConversationsFragment :
binding.statusView.show() binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) { if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network, null) binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
refreshContent()
}
} else { } else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic, null) binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
refreshContent()
}
} }
} }
is LoadState.Loading -> { is LoadState.Loading -> {

View file

@ -12,8 +12,8 @@ import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
class FiltersActivity : BaseActivity(), FiltersListener { class FiltersActivity : BaseActivity(), FiltersListener {
@ -33,10 +33,14 @@ class FiltersActivity : BaseActivity(), FiltersListener {
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
binding.addFilterButton.setOnClickListener { binding.addFilterButton.setOnClickListener {
launchEditFilterActivity() launchEditFilterActivity()
} }
binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() }
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
setTitle(R.string.pref_title_timeline_filters) setTitle(R.string.pref_title_timeline_filters)
} }
@ -48,41 +52,46 @@ class FiltersActivity : BaseActivity(), FiltersListener {
private fun observeViewModel() { private fun observeViewModel() {
lifecycleScope.launch { lifecycleScope.launch {
viewModel.filters.collect { filters -> viewModel.state.collect { state ->
binding.filtersView.show() binding.progressBar.visible(state.loadingState == FiltersViewModel.LoadingState.LOADING)
binding.addFilterButton.show() binding.swipeRefreshLayout.isRefreshing = state.loadingState == FiltersViewModel.LoadingState.LOADING
binding.filterProgressBar.hide() binding.addFilterButton.visible(state.loadingState == FiltersViewModel.LoadingState.LOADED)
refreshFilterDisplay(filters)
}
}
lifecycleScope.launch { when (state.loadingState) {
viewModel.error.collect { error -> FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide()
if (error is IOException) { FiltersViewModel.LoadingState.ERROR_NETWORK -> {
binding.filterMessageView.setup( binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
R.drawable.elephant_offline, loadFilters()
R.string.error_network }
) { loadFilters() } binding.messageView.show()
} else { }
binding.filterMessageView.setup( FiltersViewModel.LoadingState.ERROR_OTHER -> {
R.drawable.elephant_error, binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
R.string.error_generic loadFilters()
) { loadFilters() } }
binding.messageView.show()
}
FiltersViewModel.LoadingState.LOADED -> {
if (state.filters.isEmpty()) {
binding.messageView.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
binding.messageView.show()
} else {
binding.messageView.hide()
binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters)
binding.filtersList.show()
}
}
} }
} }
} }
} }
private fun refreshFilterDisplay(filters: List<Filter>) {
binding.filtersView.adapter = FiltersAdapter(this, filters)
}
private fun loadFilters() { private fun loadFilters() {
binding.filterMessageView.hide() binding.filtersList.hide()
binding.filtersView.hide()
binding.addFilterButton.hide()
binding.filterProgressBar.show()
viewModel.load() viewModel.load()
} }

View file

@ -9,6 +9,7 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException import retrofit2.HttpException
@ -18,27 +19,39 @@ class FiltersViewModel @Inject constructor(
private val api: MastodonApi, private val api: MastodonApi,
private val eventHub: EventHub private val eventHub: EventHub
) : ViewModel() { ) : ViewModel() {
val filters: MutableStateFlow<List<Filter>> = MutableStateFlow(listOf())
val error: MutableStateFlow<Throwable?> = MutableStateFlow(null) enum class LoadingState {
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
}
data class State(val filters: List<Filter>, val loadingState: LoadingState)
val state: Flow<State> get() = _state
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
fun load() { fun load() {
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
viewModelScope.launch { viewModelScope.launch {
api.getFilters().fold( api.getFilters().fold(
{ filters -> { filters ->
this@FiltersViewModel.filters.value = filters this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
}, },
{ throwable -> { throwable ->
if (throwable is HttpException && throwable.code() == 404) { if (throwable is HttpException && throwable.code() == 404) {
api.getFiltersV1().fold( api.getFiltersV1().fold(
{ filters -> { filters ->
this@FiltersViewModel.filters.value = filters.map { it.toFilter() } this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
}, },
{ throwable -> { throwable ->
error.value = throwable // TODO log errors (also below)
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
} }
) )
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER)
} else { } else {
error.value = throwable this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_NETWORK)
} }
} }
) )
@ -49,7 +62,7 @@ class FiltersViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
api.deleteFilter(filter.id).fold( api.deleteFilter(filter.id).fold(
{ {
filters.value = filters.value.filter { it.id != filter.id } this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
for (context in filter.context) { for (context in filter.context) {
eventHub.dispatch(PreferenceChangedEvent(context)) eventHub.dispatch(PreferenceChangedEvent(context))
} }
@ -58,7 +71,7 @@ class FiltersViewModel @Inject constructor(
if (throwable is HttpException && throwable.code() == 404) { if (throwable is HttpException && throwable.code() == 404) {
api.deleteFilterV1(filter.id).fold( api.deleteFilterV1(filter.id).fold(
{ {
filters.value = filters.value.filter { it.id != filter.id } this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED)
}, },
{ {
Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show()

View file

@ -245,9 +245,13 @@ class TimelineFragment :
binding.statusView.show() binding.statusView.show()
if ((loadState.refresh as LoadState.Error).error is IOException) { if ((loadState.refresh as LoadState.Error).error is IOException) {
binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) binding.statusView.setup(R.drawable.elephant_offline, R.string.error_network) {
onRefresh()
}
} else { } else {
binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) binding.statusView.setup(R.drawable.elephant_error, R.string.error_generic) {
onRefresh()
}
} }
} }
is LoadState.Loading -> { is LoadState.Loading -> {

View file

@ -10,21 +10,27 @@
android:id="@+id/includedToolbar" android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" /> layout="@layout/toolbar_basic" />
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/filtersView" android:id="@+id/swipeRefreshLayout"
android:layout_marginTop="?attr/actionBarSize"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
/> <androidx.recyclerview.widget.RecyclerView
android:id="@+id/filtersList"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.keylesspalace.tusky.view.BackgroundMessageView <com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/filterMessageView" android:id="@+id/messageView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" /> android:layout_gravity="center" />
<ProgressBar <ProgressBar
android:id="@+id/filterProgressBar" android:id="@+id/progressBar"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" /> android:layout_gravity="center" />
@ -36,8 +42,7 @@
android:layout_margin="16dp" android:layout_margin="16dp"
android:contentDescription="@string/filter_addition_title" android:contentDescription="@string/filter_addition_title"
android:src="@drawable/ic_plus_24dp" android:src="@drawable/ic_plus_24dp"
app:layout_anchor="@id/filtersView" app:layout_anchor="@id/filtersList"
app:layout_anchorGravity="bottom|end" /> app:layout_anchorGravity="bottom|end" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -13,14 +13,20 @@
android:id="@+id/includedToolbar" android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" /> layout="@layout/toolbar_basic" />
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/listsRecycler" android:id="@+id/swipeRefreshLayout"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent" app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/includedToolbar" /> app:layout_constraintTop_toBottomOf="@id/includedToolbar">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/listsRecycler"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar <ProgressBar
android:id="@+id/progressBar" android:id="@+id/progressBar"

View file

@ -4,10 +4,17 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/recyclerView" android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.keylesspalace.tusky.view.BackgroundMessageView <com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/messageView" android:id="@+id/messageView"
@ -16,4 +23,4 @@
android:layout_gravity="center" android:layout_gravity="center"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" /> tools:visibility="visible" />
</FrameLayout> </FrameLayout>