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

View file

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

View file

@ -141,9 +141,13 @@ class ConversationsFragment :
binding.statusView.show()
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 {
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 -> {

View file

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

View file

@ -9,6 +9,7 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import retrofit2.HttpException
@ -18,27 +19,39 @@ class FiltersViewModel @Inject constructor(
private val api: MastodonApi,
private val eventHub: EventHub
) : 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() {
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)
viewModelScope.launch {
api.getFilters().fold(
{ filters ->
this@FiltersViewModel.filters.value = filters
this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED)
},
{ throwable ->
if (throwable is HttpException && throwable.code() == 404) {
api.getFiltersV1().fold(
{ filters ->
this@FiltersViewModel.filters.value = filters.map { it.toFilter() }
this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED)
},
{ 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 {
error.value = throwable
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_NETWORK)
}
}
)
@ -49,7 +62,7 @@ class FiltersViewModel @Inject constructor(
viewModelScope.launch {
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) {
eventHub.dispatch(PreferenceChangedEvent(context))
}
@ -58,7 +71,7 @@ class FiltersViewModel @Inject constructor(
if (throwable is HttpException && throwable.code() == 404) {
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()

View file

@ -245,9 +245,13 @@ class TimelineFragment :
binding.statusView.show()
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 {
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 -> {

View file

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

View file

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

View file

@ -4,10 +4,17 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
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
android:id="@+id/messageView"