Error artwork (#1000)

* Add new Elephant Friend images. Use them in ListsActivity.

* Add error images to AccountListFragment

* Add error images to Timeline & Notifications fragment. Needs rework.

* Introduce BackgroundMessageView. Use it in AccountList.

* Use correct button style for BackgroundMessageView

Co-Authored-By: charlag <charlag@tutanota.com>

* Use BackgroundMessageView

* Add BackgroundMessageView docs

* Re-color and document elephants

* Apply feedback, disable refresh when error is shown

* Fix string typo
This commit is contained in:
Ivan Kupalov 2019-01-28 19:02:31 +01:00 committed by Konrad Pozniak
commit c0c73f5c06
13 changed files with 652 additions and 188 deletions

View file

@ -3,25 +3,31 @@ package com.keylesspalace.tusky
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.appcompat.widget.Toolbar
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.widget.Toolbar
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.LoadingState.*
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.MastoList
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.mikepenz.google_material_typeface_library.GoogleMaterial
import com.mikepenz.iconics.IconicsDrawable
import kotlinx.android.synthetic.main.activity_lists.*
import retrofit2.Call
import retrofit2.Response
import java.io.IOException
import java.lang.ref.WeakReference
import javax.inject.Inject
@ -35,13 +41,17 @@ interface ListsView {
}
data class State(val lists: List<MastoList>, val isLoading: Boolean)
enum class LoadingState {
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
}
data class State(val lists: List<MastoList>, val loadingState: LoadingState)
class ListsViewModel(private val api: MastodonApi) {
private var _view: WeakReference<ListsView>? = null
private val view: ListsView? get() = _view?.get()
private var state = State(listOf(), false)
private var state = State(listOf(), INITIAL)
fun attach(view: ListsView) {
this._view = WeakReference(view)
@ -57,17 +67,23 @@ class ListsViewModel(private val api: MastodonApi) {
view?.openTimeline(id)
}
fun retryLoading() {
loadIfNeeded()
}
private fun loadIfNeeded() {
if (state.isLoading || !state.lists.isEmpty()) return
updateState(state.copy(isLoading = false))
if (state.loadingState == LOADING || !state.lists.isEmpty()) return
updateState(state.copy(loadingState = LOADING))
api.getLists().enqueue(object : retrofit2.Callback<List<MastoList>> {
override fun onResponse(call: Call<List<MastoList>>, response: Response<List<MastoList>>) {
updateState(state.copy(lists = response.body() ?: listOf(), isLoading = false))
updateState(state.copy(lists = response.body() ?: listOf(), loadingState = LOADED))
}
override fun onFailure(call: Call<List<MastoList>>, t: Throwable?) {
updateState(state.copy(isLoading = false))
override fun onFailure(call: Call<List<MastoList>>, err: Throwable?) {
updateState(state.copy(
loadingState = if (err is IOException) ERROR_NETWORK else ERROR_OTHER
))
}
})
}
@ -94,9 +110,6 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
@Inject
lateinit var mastodonApi: MastodonApi
private lateinit var recyclerView: RecyclerView
private lateinit var progressBar: ProgressBar
private lateinit var viewModel: ListsViewModel
private val adapter = ListsAdapter()
@ -105,8 +118,6 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
setContentView(R.layout.activity_lists)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
recyclerView = findViewById(R.id.lists_recycler)
progressBar = findViewById(R.id.progress_bar)
setSupportActionBar(toolbar)
val bar = supportActionBar
@ -116,9 +127,9 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
bar.setDisplayShowHomeEnabled(true)
}
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.addItemDecoration(
listsRecycler.adapter = adapter
listsRecycler.layoutManager = LinearLayoutManager(this)
listsRecycler.addItemDecoration(
DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
viewModel = lastNonConfigurationInstance as? ListsViewModel ?: ListsViewModel(mastodonApi)
@ -137,8 +148,30 @@ class ListsActivity : BaseActivity(), ListsView, Injectable {
override fun update(state: State) {
adapter.update(state.lists)
progressBar.visibility = if (state.isLoading) View.VISIBLE else View.GONE
progressBar.visibility = if (state.loadingState == LOADING) View.VISIBLE else View.GONE
when (state.loadingState) {
INITIAL, LOADING -> messageView.hide()
ERROR_NETWORK -> {
messageView.show()
messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
viewModel.retryLoading()
}
}
ERROR_OTHER -> {
messageView.show()
messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
viewModel.retryLoading()
}
}
LOADED ->
if (state.lists.isEmpty()) {
messageView.show()
messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty,
null)
} else {
messageView.hide()
}
}
}
override fun openTimeline(listId: String) {

View file

@ -16,24 +16,19 @@
package com.keylesspalace.tusky.fragment
import android.os.Bundle
import com.google.android.material.snackbar.Snackbar
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.AccountActivity
import com.keylesspalace.tusky.AccountListActivity.Type
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.adapter.AccountAdapter
import com.keylesspalace.tusky.adapter.BlocksAdapter
import com.keylesspalace.tusky.adapter.FollowAdapter
import com.keylesspalace.tusky.adapter.FollowRequestsAdapter
import com.keylesspalace.tusky.adapter.MutesAdapter
import com.keylesspalace.tusky.adapter.*
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
@ -41,14 +36,15 @@ import com.keylesspalace.tusky.interfaces.AccountActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.view.EndlessOnScrollListener
import kotlinx.android.synthetic.main.fragment_account_list.*
import javax.inject.Inject
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
@ -83,7 +79,7 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
divider.setDrawable(drawable)
recyclerView.addItemDecoration(divider)
adapter = when(type) {
adapter = when (type) {
Type.BLOCKS -> BlocksAdapter(this)
Type.MUTES -> MutesAdapter(this)
Type.FOLLOW_REQUESTS -> FollowRequestsAdapter(this)
@ -143,7 +139,7 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
val mutesAdapter = adapter as MutesAdapter
val unmutedUser = mutesAdapter.removeItem(position)
if(unmutedUser != null) {
if (unmutedUser != null) {
Snackbar.make(recyclerView, R.string.confirmation_unmuted, Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
mutesAdapter.addItem(unmutedUser, position)
@ -193,7 +189,7 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
val blocksAdapter = adapter as BlocksAdapter
val unblockedUser = blocksAdapter.removeItem(position)
if(unblockedUser != null) {
if (unblockedUser != null) {
Snackbar.make(recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG)
.setAction(R.string.action_undo) {
blocksAdapter.addItem(unblockedUser, position)
@ -311,11 +307,36 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
fetching = false
if (adapter.itemCount == 0) {
messageView.show()
messageView.setup(
R.drawable.elephant_friend_empty,
R.string.message_empty,
null
)
} else {
messageView.hide()
}
}
private fun onFetchAccountsFailure(exception: Exception) {
fetching = false
Log.e(TAG, "Fetch failure", exception)
if (adapter.itemCount == 0) {
messageView.show()
if (exception is IOException) {
messageView.setup(R.drawable.elephant_offline, R.string.error_network) {
messageView.hide()
this.fetchAccounts(null)
}
} else {
messageView.setup(R.drawable.elephant_error, R.string.error_generic) {
messageView.hide()
this.fetchAccounts(null)
}
}
}
}
companion object {

View file

@ -16,31 +16,19 @@
package com.keylesspalace.tusky.fragment;
import android.app.Activity;
import androidx.arch.core.util.Function;
import androidx.lifecycle.Lifecycle;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.preference.PreferenceManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.tabs.TabLayout;
import androidx.core.util.Pair;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.tabs.TabLayout;
import com.keylesspalace.tusky.MainActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.adapter.NotificationsAdapter;
@ -64,10 +52,12 @@ import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.util.ViewDataUtils;
import com.keylesspalace.tusky.view.BackgroundMessageView;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
import com.keylesspalace.tusky.viewdata.NotificationViewData;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.io.IOException;
import java.math.BigInteger;
import java.util.Collections;
import java.util.Iterator;
@ -76,7 +66,18 @@ import java.util.Objects;
import javax.inject.Inject;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.arch.core.util.Function;
import androidx.core.util.Pair;
import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import io.reactivex.android.schedulers.AndroidSchedulers;
import kotlin.Unit;
import kotlin.collections.CollectionsKt;
import retrofit2.Call;
import retrofit2.Callback;
@ -125,7 +126,7 @@ public class NotificationsFragment extends SFragment implements
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private ProgressBar progressBar;
private TextView nothingMessageView;
private BackgroundMessageView statusView;
private LinearLayoutManager layoutManager;
private EndlessOnScrollListener scrollListener;
@ -177,7 +178,7 @@ public class NotificationsFragment extends SFragment implements
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout);
recyclerView = rootView.findViewById(R.id.recycler_view);
progressBar = rootView.findViewById(R.id.progress_bar);
nothingMessageView = rootView.findViewById(R.id.nothing_message);
statusView = rootView.findViewById(R.id.statusView);
swipeRefreshLayout.setOnRefreshListener(this);
swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue);
@ -208,20 +209,12 @@ public class NotificationsFragment extends SFragment implements
bottomId = null;
((SimpleItemAnimator) recyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
setupNothingView();
sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1);
return rootView;
}
private void setupNothingView() {
Drawable top = AppCompatResources.getDrawable(Objects.requireNonNull(getContext()),
R.drawable.elephant_friend_empty);
nothingMessageView.setCompoundDrawablesWithIntrinsicBounds(null, top, null, null);
nothingMessageView.setVisibility(View.GONE);
}
private void handleFavEvent(FavoriteEvent event) {
Pair<Integer, Notification> posAndNotification =
findReplyPosition(event.getStatusId());
@ -332,6 +325,8 @@ public class NotificationsFragment extends SFragment implements
@Override
public void onRefresh() {
swipeRefreshLayout.setEnabled(true);
this.statusView.setVisibility(View.GONE);
Either<Placeholder, Notification> first = CollectionsKt.firstOrNull(this.notifications);
String topId;
if (first != null && first.isRight()) {
@ -721,9 +716,9 @@ public class NotificationsFragment extends SFragment implements
}
if (notifications.size() == 0 && adapter.getItemCount() == 0) {
nothingMessageView.setVisibility(View.VISIBLE);
} else {
nothingMessageView.setVisibility(View.GONE);
this.statusView.setVisibility(View.VISIBLE);
this.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null);
}
swipeRefreshLayout.setRefreshing(false);
progressBar.setVisibility(View.GONE);
@ -736,6 +731,22 @@ public class NotificationsFragment extends SFragment implements
new NotificationViewData.Placeholder(false);
notifications.setPairedItem(position, placeholderVD);
adapter.updateItemWithNotify(position, placeholderVD, true);
} else if (this.notifications.isEmpty()) {
this.statusView.setVisibility(View.VISIBLE);
swipeRefreshLayout.setEnabled(false);
if (exception instanceof IOException) {
this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> {
this.progressBar.setVisibility(View.VISIBLE);
this.onRefresh();
return Unit.INSTANCE;
});
} else {
this.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> {
this.progressBar.setVisibility(View.VISIBLE);
this.onRefresh();
return Unit.INSTANCE;
});
}
}
Log.e(TAG, "Fetch failure: " + exception.getMessage());
progressBar.setVisibility(View.GONE);

View file

@ -25,7 +25,6 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.google.android.material.tabs.TabLayout;
@ -56,9 +55,11 @@ import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.PairedList;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.util.ViewDataUtils;
import com.keylesspalace.tusky.view.BackgroundMessageView;
import com.keylesspalace.tusky.view.EndlessOnScrollListener;
import com.keylesspalace.tusky.viewdata.StatusViewData;
import java.io.IOException;
import java.math.BigInteger;
import java.util.Iterator;
import java.util.List;
@ -71,7 +72,6 @@ import javax.inject.Inject;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.arch.core.util.Function;
import androidx.core.util.Pair;
import androidx.lifecycle.Lifecycle;
@ -86,6 +86,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.android.schedulers.AndroidSchedulers;
import kotlin.Unit;
import kotlin.collections.CollectionsKt;
import retrofit2.Call;
import retrofit2.Callback;
@ -137,7 +138,7 @@ public class TimelineFragment extends SFragment implements
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private ProgressBar progressBar;
private TextView nothingMessageView;
private BackgroundMessageView statusView;
private TimelineAdapter adapter;
private Kind kind;
@ -220,13 +221,12 @@ public class TimelineFragment extends SFragment implements
recyclerView = rootView.findViewById(R.id.recycler_view);
swipeRefreshLayout = rootView.findViewById(R.id.swipe_refresh_layout);
progressBar = rootView.findViewById(R.id.progress_bar);
nothingMessageView = rootView.findViewById(R.id.nothing_message);
statusView = rootView.findViewById(R.id.statusView);
setupSwipeRefreshLayout();
setupRecyclerView();
updateAdapter();
setupTimelinePreferences();
setupNothingView();
if (statuses.isEmpty()) {
progressBar.setVisibility(View.VISIBLE);
@ -376,10 +376,15 @@ public class TimelineFragment extends SFragment implements
}
}
if (statuses.size() == 0) {
nothingMessageView.setVisibility(View.VISIBLE);
showNothing();
}
}
private void showNothing() {
statusView.setVisibility(View.VISIBLE);
statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null);
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
@ -502,14 +507,10 @@ public class TimelineFragment extends SFragment implements
super.onDestroyView();
}
private void setupNothingView() {
Drawable top = AppCompatResources.getDrawable(requireContext(), R.drawable.elephant_friend_empty);
nothingMessageView.setCompoundDrawablesWithIntrinsicBounds(null, top, null, null);
nothingMessageView.setVisibility(View.GONE);
}
@Override
public void onRefresh() {
swipeRefreshLayout.setEnabled(true);
this.statusView.setVisibility(View.GONE);
if (this.initialUpdateFailed) {
updateCurrent();
} else {
@ -863,7 +864,6 @@ public class TimelineFragment extends SFragment implements
private void sendFetchTimelineRequest(@Nullable String fromId, @Nullable String uptoId,
final FetchEnd fetchEnd, final int pos) {
if (kind == Kind.HOME) {
TimelineRequestMode mode;
// allow getting old statuses/fallbacks for network only for for bottom loading
@ -872,13 +872,13 @@ public class TimelineFragment extends SFragment implements
} else {
mode = TimelineRequestMode.NETWORK;
}
timelineRepo.getStatuses(fromId, uptoId, LOAD_AT_ONCE, mode)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
(result) -> onFetchTimelineSuccess(result, fetchEnd, pos),
(err) -> onFetchTimelineFailure(new Exception(err), fetchEnd, pos)
);
timelineRepo.getStatuses(fromId, uptoId, LOAD_AT_ONCE, mode)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
(result) -> onFetchTimelineSuccess(result, fetchEnd, pos),
(err) -> onFetchTimelineFailure(new Exception(err), fetchEnd, pos)
);
} else {
Callback<List<Status>> callback = new Callback<List<Status>>() {
@Override
@ -946,10 +946,11 @@ public class TimelineFragment extends SFragment implements
updateBottomLoadingState(fetchEnd);
progressBar.setVisibility(View.GONE);
swipeRefreshLayout.setRefreshing(false);
swipeRefreshLayout.setEnabled(true);
if (this.statuses.size() == 0) {
nothingMessageView.setVisibility(View.VISIBLE);
this.showNothing();
} else {
nothingMessageView.setVisibility(View.GONE);
this.statusView.setVisibility(View.GONE);
}
}
@ -968,6 +969,22 @@ public class TimelineFragment extends SFragment implements
newViewData = new StatusViewData.Placeholder(placeholder.getId(), false);
statuses.setPairedItem(position, newViewData);
updateAdapter();
} else if (this.statuses.isEmpty()) {
swipeRefreshLayout.setEnabled(false);
this.statusView.setVisibility(View.VISIBLE);
if (exception instanceof IOException) {
this.statusView.setup(R.drawable.elephant_offline, R.string.error_network, __ -> {
this.progressBar.setVisibility(View.VISIBLE);
this.onRefresh();
return Unit.INSTANCE;
});
} else {
this.statusView.setup(R.drawable.elephant_error, R.string.error_generic, __ -> {
this.progressBar.setVisibility(View.VISIBLE);
this.onRefresh();
return Unit.INSTANCE;
});
}
}
Log.e(TAG, "Fetch Failure: " + exception.getMessage());

View file

@ -0,0 +1,46 @@
package com.keylesspalace.tusky.view
import android.content.Context
import android.util.AttributeSet
import android.view.Gravity
import android.view.View
import android.widget.LinearLayout
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.visible
import kotlinx.android.synthetic.main.view_background_message.view.*
/**
* This view is used for screens with downloadable content which may fail.
* Can show an image, text and button below them.
*/
class BackgroundMessageView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
init {
View.inflate(context, R.layout.view_background_message, this)
gravity = Gravity.CENTER_HORIZONTAL
orientation = VERTICAL
if (isInEditMode) {
setup(R.drawable.elephant_offline, R.string.error_network) {}
}
}
/**
* Setup image, message and button.
* If [clickListener] is `null` then the button will be hidden.
*/
fun setup(@DrawableRes imageRes: Int, @StringRes messageRes: Int,
clickListener: ((v: View) -> Unit)?) {
messageTextView.setText(messageRes)
messageTextView.setCompoundDrawablesWithIntrinsicBounds(0, imageRes, 0, 0)
button.setOnClickListener(clickListener)
button.visible(clickListener != null)
}
}