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:
parent
57ed98f305
commit
23381d45d7
9 changed files with 117 additions and 59 deletions
|
@ -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 -> {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -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() }
|
||||
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.filterMessageView.setup(
|
||||
R.drawable.elephant_error,
|
||||
R.string.error_generic
|
||||
) { loadFilters() }
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 -> {
|
||||
|
|
|
@ -10,21 +10,27 @@
|
|||
android:id="@+id/includedToolbar"
|
||||
layout="@layout/toolbar_basic" />
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_marginTop="?attr/actionBarSize"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/filtersView"
|
||||
android:id="@+id/filtersList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
/>
|
||||
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>
|
|
@ -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"
|
||||
|
|
|
@ -4,11 +4,18 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="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"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
Loading…
Reference in a new issue