Account activity redesign (#662)

* Refactor-all-the-things version of the fix for issue #573

* Migrate SpanUtils to kotlin because why not

* Minimal fix for issue #573

* Add tests for compose spanning

* Clean up code suggestions

* Make FakeSpannable.getSpans implementation less awkward

* Add secondary validation pass for urls

* Address code review feedback

* Fixup type filtering in FakeSpannable again

* Make all mentions in compose activity use the default link color

* new layout for AccountActivity

* fix the light theme

* convert AccountActivity to Kotlin

* introduce AccountViewModel

* Merge branch 'master' into account-activity-redesign

# Conflicts:
#	app/src/main/java/com/keylesspalace/tusky/AccountActivity.java

* add Bot badge to profile

* parse custom emojis in usernames

* add possibility to cancel follow request

* add third tab on profiles

* add account fields to profile

* add support for moved accounts

* set click listener on account moved view

* fix tests

* use 24dp as statusbar size

* add ability to hide reblogs from followed accounts

* add button to edit own account to AccountActivity

* set toolbar top margin programmatically

* fix crash

* add shadow behind statusbar

* introduce ViewExtensions to clean up code

* move code out of offsetChangedListener for perf reasons

* clean up stuff

* add error handling

* improve type safety

* fix ConstraintLayout warning

* remove unneeded ressources

* fix event dispatching

* fix crash in event handling

* set correct emoji on title

* improve some things

* wrap follower/foillowing/status views
main
Konrad Pozniak 6 years ago committed by GitHub
parent 1b7b0f26d7
commit 3b5a7cd916
  1. 2
      app/build.gradle
  2. 3
      app/src/main/java/com/keylesspalace/tusky/AboutActivity.java
  3. 717
      app/src/main/java/com/keylesspalace/tusky/AccountActivity.java
  4. 620
      app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt
  5. 3
      app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt
  6. 3
      app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt
  7. 4
      app/src/main/java/com/keylesspalace/tusky/MainActivity.java
  8. 2
      app/src/main/java/com/keylesspalace/tusky/ReportActivity.java
  9. 56
      app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt
  10. 3
      app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt
  11. 2
      app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt
  12. 40
      app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
  13. 33
      app/src/main/java/com/keylesspalace/tusky/entity/Account.kt
  14. 3
      app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt
  15. 8
      app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java
  16. 8
      app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt
  17. 3
      app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt
  18. 28
      app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java
  19. 36
      app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java
  20. 5
      app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.java
  21. 9
      app/src/main/java/com/keylesspalace/tusky/util/Resource.kt
  22. 19
      app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt
  23. 190
      app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt
  24. 6
      app/src/main/res/drawable-night/avatar_background.xml
  25. 6
      app/src/main/res/drawable-night/profile_badge_background.xml
  26. 7
      app/src/main/res/drawable/account_header_default.xml
  27. 12
      app/src/main/res/drawable/account_header_gradient.xml
  28. 6
      app/src/main/res/drawable/avatar_background.xml
  29. 9
      app/src/main/res/drawable/ic_briefcase.xml
  30. 6
      app/src/main/res/drawable/profile_badge_background.xml
  31. 374
      app/src/main/res/layout/activity_account.xml
  32. 36
      app/src/main/res/layout/item_account_field.xml
  33. 58
      app/src/main/res/layout/view_account_moved.xml
  34. 5
      app/src/main/res/menu/account_toolbar.xml
  35. 1
      app/src/main/res/values-ar/strings.xml
  36. 1
      app/src/main/res/values-ca/strings.xml
  37. 3
      app/src/main/res/values-de/strings.xml
  38. 1
      app/src/main/res/values-es/strings.xml
  39. 1
      app/src/main/res/values-fr/strings.xml
  40. 1
      app/src/main/res/values-hu/strings.xml
  41. 1
      app/src/main/res/values-ja/strings.xml
  42. 5
      app/src/main/res/values-night/styles.xml
  43. 1
      app/src/main/res/values-nl/strings.xml
  44. 1
      app/src/main/res/values-oc/strings.xml
  45. 1
      app/src/main/res/values-pl/strings.xml
  46. 1
      app/src/main/res/values-pt-rBR/strings.xml
  47. 1
      app/src/main/res/values-ru/strings.xml
  48. 1
      app/src/main/res/values-ta/strings.xml
  49. 1
      app/src/main/res/values-tr/strings.xml
  50. 1
      app/src/main/res/values-zh-rCN/strings.xml
  51. 1
      app/src/main/res/values-zh-rHK/strings.xml
  52. 1
      app/src/main/res/values-zh-rMO/strings.xml
  53. 1
      app/src/main/res/values-zh-rSG/strings.xml
  54. 1
      app/src/main/res/values-zh-rTW/strings.xml
  55. 2
      app/src/main/res/values/colors.xml
  56. 4
      app/src/main/res/values/dimens.xml
  57. 10
      app/src/main/res/values/strings.xml
  58. 8
      app/src/main/res/values/styles.xml
  59. 3
      app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt
  60. 3
      app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt

@ -75,6 +75,8 @@ dependencies {
implementation "com.android.support:support-emoji:$supportLibraryVersion"
implementation "com.android.support:support-emoji-appcompat:$supportLibraryVersion"
implementation "de.c1710:filemojicompat:1.0.5"
// architecture components
implementation 'android.arch.lifecycle:extensions:1.1.1'
//room
implementation 'android.arch.persistence.room:runtime:1.1.0'
kapt 'android.arch.persistence.room:compiler:1.1.0'

@ -69,8 +69,7 @@ public class AboutActivity extends BaseActivity implements Injectable {
}
private void viewAccount(String id) {
Intent intent = new Intent(this, AccountActivity.class);
intent.putExtra("id", id);
Intent intent = AccountActivity.getIntent(this, id);
startActivity(intent);
}

@ -1,717 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.preference.PreferenceManager;
import android.support.annotation.AttrRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.design.widget.AppBarLayout;
import android.support.design.widget.CollapsingToolbarLayout;
import android.support.design.widget.FloatingActionButton;
import android.support.design.widget.Snackbar;
import android.support.design.widget.TabLayout;
import android.support.text.emoji.EmojiCompat;
import android.support.v4.app.Fragment;
import android.support.v4.content.LocalBroadcastManager;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.ViewPager;
import android.support.v7.app.ActionBar;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import com.keylesspalace.tusky.appstore.EventHub;
import com.keylesspalace.tusky.appstore.BlockEvent;
import com.keylesspalace.tusky.appstore.MuteEvent;
import com.keylesspalace.tusky.appstore.UnfollowEvent;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Relationship;
import com.keylesspalace.tusky.interfaces.ActionButtonActivity;
import com.keylesspalace.tusky.interfaces.LinkListener;
import com.keylesspalace.tusky.pager.AccountPagerAdapter;
import com.keylesspalace.tusky.util.Assert;
import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.ThemeUtils;
import com.pkmmte.view.CircularImageView;
import com.squareup.picasso.Picasso;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.support.HasSupportFragmentInjector;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public final class AccountActivity extends BottomSheetActivity implements ActionButtonActivity,
HasSupportFragmentInjector {
private static final String TAG = "AccountActivity"; // logging tag
private enum FollowState {
NOT_FOLLOWING,
FOLLOWING,
REQUESTED,
}
@Inject
public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector;
@Inject
public EventHub appstore;
private String accountId;
private FollowState followState;
private boolean blocking;
private boolean muting;
private boolean isSelf;
private Account loadedAccount;
private CircularImageView avatar;
private ImageView header;
private FloatingActionButton floatingBtn;
private Button followBtn;
private TextView followsYouView;
private TabLayout tabLayout;
private ImageView accountLockedView;
private View container;
private TextView followersTextView;
private TextView followingTextView;
private TextView statusesTextView;
private boolean hideFab;
private int oldOffset;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_account);
avatar = findViewById(R.id.account_avatar);
header = findViewById(R.id.account_header);
floatingBtn = findViewById(R.id.floating_btn);
followBtn = findViewById(R.id.follow_btn);
followsYouView = findViewById(R.id.account_follows_you);
tabLayout = findViewById(R.id.tab_layout);
accountLockedView = findViewById(R.id.account_locked);
container = findViewById(R.id.activity_account);
followersTextView = findViewById(R.id.followers_tv);
followingTextView = findViewById(R.id.following_tv);
statusesTextView = findViewById(R.id.statuses_btn);
if (savedInstanceState != null) {
accountId = savedInstanceState.getString("accountId");
followState = (FollowState) savedInstanceState.getSerializable("followState");
blocking = savedInstanceState.getBoolean("blocking");
muting = savedInstanceState.getBoolean("muting");
} else {
Intent intent = getIntent();
accountId = intent.getStringExtra("id");
followState = FollowState.NOT_FOLLOWING;
blocking = false;
muting = false;
}
loadedAccount = null;
// Setup the toolbar.
final Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setTitle(null);
actionBar.setDisplayHomeAsUpEnabled(true);
actionBar.setDisplayShowHomeEnabled(true);
}
hideFab = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("fabHide", false);
// Add a listener to change the toolbar icon color when it enters/exits its collapsed state.
AppBarLayout appBarLayout = findViewById(R.id.account_app_bar_layout);
final CollapsingToolbarLayout collapsingToolbar = findViewById(R.id.collapsing_toolbar);
appBarLayout.addOnOffsetChangedListener(new AppBarLayout.OnOffsetChangedListener() {
@AttrRes
int priorAttribute = R.attr.account_toolbar_icon_tint_uncollapsed;
@Override
public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) {
@AttrRes int attribute;
if (collapsingToolbar.getHeight() + verticalOffset
< 2 * ViewCompat.getMinimumHeight(collapsingToolbar)) {
toolbar.setTitleTextColor(ThemeUtils.getColor(AccountActivity.this,
android.R.attr.textColorPrimary));
toolbar.setSubtitleTextColor(ThemeUtils.getColor(AccountActivity.this,
android.R.attr.textColorSecondary));
attribute = R.attr.account_toolbar_icon_tint_collapsed;
} else {
toolbar.setTitleTextColor(Color.TRANSPARENT);
toolbar.setSubtitleTextColor(Color.TRANSPARENT);
attribute = R.attr.account_toolbar_icon_tint_uncollapsed;
}
if (attribute != priorAttribute) {
priorAttribute = attribute;
Context context = toolbar.getContext();
ThemeUtils.setDrawableTint(context, toolbar.getNavigationIcon(), attribute);
ThemeUtils.setDrawableTint(context, toolbar.getOverflowIcon(), attribute);
}
if (floatingBtn != null && hideFab && !isSelf && !blocking) {
if (verticalOffset > oldOffset) {
floatingBtn.show();
}
if (verticalOffset < oldOffset) {
floatingBtn.hide();
}
}
oldOffset = verticalOffset;
}
});
// Initialise the default UI states.
floatingBtn.hide();
followBtn.setVisibility(View.GONE);
followsYouView.setVisibility(View.GONE);
// Obtain information to fill out the profile.
obtainAccount();
AccountEntity activeAccount = accountManager.getActiveAccount();
if (accountId.equals(activeAccount.getAccountId())) {
isSelf = true;
} else {
isSelf = false;
obtainRelationships();
}
// Setup the tabs and timeline pager.
AccountPagerAdapter adapter = new AccountPagerAdapter(getSupportFragmentManager(),
accountId);
String[] pageTitles = {
getString(R.string.title_statuses),
getString(R.string.title_media)
};
adapter.setPageTitles(pageTitles);
final ViewPager viewPager = findViewById(R.id.pager);
int pageMargin = getResources().getDimensionPixelSize(R.dimen.tab_page_margin);
viewPager.setPageMargin(pageMargin);
Drawable pageMarginDrawable = ThemeUtils.getDrawable(this, R.attr.tab_page_margin_drawable,
R.drawable.tab_page_margin_dark);
viewPager.setPageMarginDrawable(pageMarginDrawable);
viewPager.setAdapter(adapter);
viewPager.setOffscreenPageLimit(0);
tabLayout.setupWithViewPager(viewPager);
View.OnClickListener accountListClickListener = v -> {
AccountListActivity.Type type;
switch (v.getId()) {
case R.id.followers_tv:
type = AccountListActivity.Type.FOLLOWERS;
break;
case R.id.following_tv:
type = AccountListActivity.Type.FOLLOWING;
break;
default:
throw new AssertionError();
}
Intent intent = AccountListActivity.newIntent(AccountActivity.this, type,
accountId);
startActivity(intent);
};
followersTextView.setOnClickListener(accountListClickListener);
followingTextView.setOnClickListener(accountListClickListener);
statusesTextView.setOnClickListener(v -> {
// Make nice ripple effect on tab
//noinspection ConstantConditions
tabLayout.getTabAt(0).select();
final View poorTabView = ((ViewGroup) tabLayout.getChildAt(0)).getChildAt(0);
poorTabView.setPressed(true);
tabLayout.postDelayed(() -> poorTabView.setPressed(false), 300);
});
}
@Override
protected void onSaveInstanceState(Bundle outState) {
outState.putString("accountId", accountId);
outState.putSerializable("followState", followState);
outState.putBoolean("blocking", blocking);
outState.putBoolean("muting", muting);
super.onSaveInstanceState(outState);
}
private void obtainAccount() {
mastodonApi.account(accountId).enqueue(new Callback<Account>() {
@Override
public void onResponse(@NonNull Call<Account> call,
@NonNull Response<Account> response) {
if (response.isSuccessful()) {
onObtainAccountSuccess(response.body());
} else {
onObtainAccountFailure();
}
}
@Override
public void onFailure(@NonNull Call<Account> call, @NonNull Throwable t) {
onObtainAccountFailure();
}
});
}
private void onObtainAccountSuccess(Account account) {
loadedAccount = account;
TextView username = findViewById(R.id.account_username);
TextView displayName = findViewById(R.id.account_display_name);
TextView note = findViewById(R.id.account_note);
String usernameFormatted = String.format(
getString(R.string.status_username_format), account.getUsername());
username.setText(usernameFormatted);
displayName.setText(account.getName());
if (getSupportActionBar() != null) {
getSupportActionBar().setTitle(EmojiCompat.get().process(account.getName()));
String subtitle = String.format(getString(R.string.status_username_format),
account.getUsername());
getSupportActionBar().setSubtitle(subtitle);
}
LinkHelper.setClickableText(note, account.getNote(), null, new LinkListener() {
@Override
public void onViewTag(String tag) {
Intent intent = new Intent(AccountActivity.this, ViewTagActivity.class);
intent.putExtra("hashtag", tag);
startActivity(intent);
}
@Override
public void onViewAccount(String id) {
Intent intent = new Intent(AccountActivity.this, AccountActivity.class);
intent.putExtra("id", id);
startActivity(intent);
}
@Override
public void onViewUrl(String url) {
viewUrl(url);
}
});
if (account.getLocked()) {
accountLockedView.setVisibility(View.VISIBLE);
} else {
accountLockedView.setVisibility(View.GONE);
}
Picasso.with(this)
.load(account.getAvatar())
.placeholder(R.drawable.avatar_default)
.into(avatar);
Picasso.with(this)
.load(account.getHeader())
.placeholder(R.drawable.account_header_default)
.into(header);
NumberFormat numberFormat = NumberFormat.getNumberInstance();
String followersCount = numberFormat.format(account.getFollowersCount());
String followingCount = numberFormat.format(account.getFollowingCount());
String statusesCount = numberFormat.format(account.getStatusesCount());
followersTextView.setText(getString(R.string.title_x_followers, followersCount));
followingTextView.setText(getString(R.string.title_x_following, followingCount));
statusesTextView.setText(getString(R.string.title_x_statuses, statusesCount));
}
private void onObtainAccountFailure() {
Snackbar.make(tabLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry, v -> obtainAccount())
.show();
}
private void obtainRelationships() {
List<String> ids = new ArrayList<>(1);
ids.add(accountId);
mastodonApi.relationships(ids).enqueue(new Callback<List<Relationship>>() {
@Override
public void onResponse(@NonNull Call<List<Relationship>> call,
@NonNull Response<List<Relationship>> response) {
List<Relationship> relationships = response.body();
if (response.isSuccessful() && relationships != null) {
Relationship relationship = relationships.get(0);
onObtainRelationshipsSuccess(relationship);
} else {
onObtainRelationshipsFailure(new Exception(response.message()));
}
}
@Override
public void onFailure(@NonNull Call<List<Relationship>> call, @NonNull Throwable t) {
onObtainRelationshipsFailure((Exception) t);
}
});
}
private void onObtainRelationshipsSuccess(Relationship relation) {
if (relation.getFollowing()) {
followState = FollowState.FOLLOWING;
} else if (relation.getRequested()) {
followState = FollowState.REQUESTED;
} else {
followState = FollowState.NOT_FOLLOWING;
}
this.blocking = relation.getBlocking();
this.muting = relation.getMuting();
if (relation.getFollowedBy()) {
followsYouView.setVisibility(View.VISIBLE);
} else {
followsYouView.setVisibility(View.GONE);
}
updateButtons();
}
private void updateFollowButton(Button button) {
switch (followState) {
case NOT_FOLLOWING: {
button.setText(R.string.action_follow);
break;
}
case REQUESTED: {
button.setText(R.string.state_follow_requested);
break;
}
case FOLLOWING: {
button.setText(R.string.action_unfollow);
break;
}
}
}
private void updateButtons() {
invalidateOptionsMenu();
if (!isSelf && !blocking) {
floatingBtn.show();
followBtn.setVisibility(View.VISIBLE);
updateFollowButton(followBtn);
floatingBtn.setOnClickListener(v -> mention());
followBtn.setOnClickListener(v -> {
switch (followState) {
case NOT_FOLLOWING: {
follow(accountId);
break;
}
case REQUESTED: {
showFollowRequestPendingDialog();
break;
}
case FOLLOWING: {
showUnfollowWarningDialog();
break;
}
}
updateFollowButton(followBtn);
});
} else {
floatingBtn.hide();
followBtn.setVisibility(View.GONE);
}
}
private void onObtainRelationshipsFailure(Exception exception) {
Log.e(TAG, "Could not obtain relationships. " + exception.getMessage());
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.account_toolbar, menu);
return super.onCreateOptionsMenu(menu);
}
private String getFollowAction() {
switch (followState) {
default:
case NOT_FOLLOWING:
return getString(R.string.action_follow);
case REQUESTED:
case FOLLOWING:
return getString(R.string.action_unfollow);
}
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
if (!isSelf) {
MenuItem follow = menu.findItem(R.id.action_follow);
follow.setTitle(getFollowAction());
follow.setVisible(followState != FollowState.REQUESTED);
MenuItem block = menu.findItem(R.id.action_block);
String title;
if (blocking) {
title = getString(R.string.action_unblock);
} else {
title = getString(R.string.action_block);
}
block.setTitle(title);
MenuItem mute = menu.findItem(R.id.action_mute);
if (muting) {
title = getString(R.string.action_unmute);
} else {
title = getString(R.string.action_mute);
}
mute.setTitle(title);
} else {
// It shouldn't be possible to block or follow yourself.
menu.removeItem(R.id.action_follow);
menu.removeItem(R.id.action_block);
menu.removeItem(R.id.action_mute);
}
return super.onPrepareOptionsMenu(menu);
}
private void follow(final String id) {
Callback<Relationship> cb = new Callback<Relationship>() {
@Override
public void onResponse(@NonNull Call<Relationship> call,
@NonNull Response<Relationship> response) {
Relationship relationship = response.body();
if (response.isSuccessful() && relationship != null) {
if (relationship.getFollowing()) {
followState = FollowState.FOLLOWING;
} else if (relationship.getRequested()) {
followState = FollowState.REQUESTED;
Snackbar.make(container, R.string.state_follow_requested,
Snackbar.LENGTH_LONG).show();
} else {
followState = FollowState.NOT_FOLLOWING;
appstore.dispatch(new UnfollowEvent(id));
}
updateButtons();
} else {
onFollowFailure(id);
}
}
@Override
public void onFailure(@NonNull Call<Relationship> call, @NonNull Throwable t) {
onFollowFailure(id);
}
};
Assert.expect(followState != FollowState.REQUESTED);
switch (followState) {
case NOT_FOLLOWING: {
mastodonApi.followAccount(id).enqueue(cb);
break;
}
case FOLLOWING: {
mastodonApi.unfollowAccount(id).enqueue(cb);
break;
}
}
}
private void onFollowFailure(final String id) {
View.OnClickListener listener = v -> follow(id);
Snackbar.make(container, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry, listener)
.show();
}
private void showFollowRequestPendingDialog() {
new AlertDialog.Builder(this)
.setMessage(R.string.dialog_message_follow_request)
.setPositiveButton(android.R.string.ok, null)
.show();
}
private void showUnfollowWarningDialog() {
DialogInterface.OnClickListener unfollowListener = (dialogInterface, i) -> follow(accountId);
new AlertDialog.Builder(this)
.setMessage(R.string.dialog_unfollow_warning)
.setPositiveButton(android.R.string.ok, unfollowListener)
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void block(final String id) {
Callback<Relationship> cb = new Callback<Relationship>() {
@Override
public void onResponse(@NonNull Call<Relationship> call,
@NonNull Response<Relationship> response) {
Relationship relationship = response.body();
if (response.isSuccessful() && relationship != null) {
appstore.dispatch(new BlockEvent(id));
blocking = relationship.getBlocking();
updateButtons();
} else {
onBlockFailure(id);
}
}
@Override
public void onFailure(@NonNull Call<Relationship> call, @NonNull Throwable t) {
onBlockFailure(id);
}
};
if (blocking) {
mastodonApi.unblockAccount(id).enqueue(cb);
} else {
mastodonApi.blockAccount(id).enqueue(cb);
}
}
private void onBlockFailure(final String id) {
View.OnClickListener listener = v -> block(id);
Snackbar.make(container, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry, listener)
.show();
}
private void mute(final String id) {
Callback<Relationship> cb = new Callback<Relationship>() {
@Override
public void onResponse(@NonNull Call<Relationship> call,
@NonNull Response<Relationship> response) {
Relationship relationship = response.body();
if (response.isSuccessful() && relationship != null) {
appstore.dispatch(new MuteEvent(id));
muting = relationship.getMuting();
updateButtons();
} else {
onMuteFailure(id);
}
}
@Override
public void onFailure(@NonNull Call<Relationship> call, @NonNull Throwable t) {
onMuteFailure(id);
}
};
if (muting) {
mastodonApi.unmuteAccount(id).enqueue(cb);
} else {
mastodonApi.muteAccount(id).enqueue(cb);
}
}
private void onMuteFailure(final String id) {
View.OnClickListener listener = v -> mute(id);
Snackbar.make(container, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry, listener)
.show();
}
private boolean mention() {
if (loadedAccount == null) {
// If the account isn't loaded yet, eat the input.
return false;
}
Intent intent = new ComposeActivity.IntentBuilder()
.mentionedUsernames(Collections.singleton(loadedAccount.getUsername()))
.build(this);
startActivity(intent);
return true;
}
private void broadcast(String action, String id) {
Intent intent = new Intent(action);
intent.putExtra("id", id);
LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(intent);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case android.R.id.home: {
onBackPressed();
return true;
}
case R.id.action_mention: {
return mention();
}
case R.id.action_open_in_web: {
if (loadedAccount == null) {
// If the account isn't loaded yet, eat the input.
return false;
}
LinkHelper.openLink(loadedAccount.getUrl(), this);
return true;
}
case R.id.action_follow: {
follow(accountId);
return true;
}
case R.id.action_block: {
block(accountId);
return true;
}
case R.id.action_mute: {
mute(accountId);
return true;
}
}
return super.onOptionsItemSelected(item);
}
@Nullable
@Override
public FloatingActionButton getActionButton() {
if (!isSelf && !blocking) {
return floatingBtn;
}
return null;
}
@Override
public AndroidInjector<Fragment> supportFragmentInjector() {
return dispatchingAndroidInjector;
}
}

@ -0,0 +1,620 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.animation.ArgbEvaluator
import android.app.Activity
import android.app.AlertDialog
import android.arch.lifecycle.Observer
import android.arch.lifecycle.ViewModelProviders
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.PorterDuff
import android.os.Build
import android.os.Bundle
import android.preference.PreferenceManager
import android.support.annotation.AttrRes
import android.support.annotation.ColorInt
import android.support.annotation.Px
import android.support.design.widget.*
import android.support.text.emoji.EmojiCompat
import android.support.v4.app.Fragment
import android.support.v4.content.ContextCompat
import android.support.v4.view.ViewCompat
import android.support.v4.widget.TextViewCompat
import android.support.v7.widget.LinearLayoutManager
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import com.keylesspalace.tusky.adapter.AccountFieldAdapter
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.pager.AccountPagerAdapter
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.view.RoundedTransformation
import com.keylesspalace.tusky.viewmodel.AccountViewModel
import com.squareup.picasso.Picasso
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.support.HasSupportFragmentInjector
import kotlinx.android.synthetic.main.activity_account.*
import kotlinx.android.synthetic.main.view_account_moved.*
import java.text.NumberFormat
import javax.inject.Inject
class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportFragmentInjector, LinkListener {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
@Inject
lateinit var viewModelFactory: ViewModelFactory
private lateinit var viewModel: AccountViewModel
private val accountFieldAdapter = AccountFieldAdapter(this)
private lateinit var accountId: String
private var followState: FollowState? = null
private var blocking: Boolean = false
private var muting: Boolean = false
private var showingReblogs: Boolean = false
private var isSelf: Boolean = false
private var loadedAccount: Account? = null
// fields for scroll animation
private var hideFab: Boolean = false
private var oldOffset: Int = 0
@ColorInt
private var toolbarColor: Int = 0
@ColorInt
private var backgroundColor: Int = 0
@ColorInt
private var statusBarColorTransparent: Int = 0
@ColorInt
private var statusBarColorOpaque: Int = 0
@ColorInt
private var textColorPrimary: Int = 0
@ColorInt
private var textColorSecondary: Int = 0
@Px
private var avatarSize: Float = 0f
@Px
private var titleVisibleHeight: Int = 0
private enum class FollowState {
NOT_FOLLOWING,
FOLLOWING,
REQUESTED
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProviders.of(this, viewModelFactory)[AccountViewModel::class.java]
viewModel.accountData.observe(this, Observer<Resource<Account>> {
when (it) {
is Success -> onAccountChanged(it.data)
is Error -> {
Snackbar.make(accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { reload() }
.show()
}
}
})
viewModel.relationshipData.observe(this, Observer<Resource<Relationship>> {
val relation = it?.data
if (relation != null) {
onRelationshipChanged(relation)
}
if (it is Error) {
Snackbar.make(accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG)
.setAction(R.string.action_retry) { reload() }
.show()
}
})
val decorView = window.decorView
decorView.systemUiVisibility = decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = Color.TRANSPARENT
}
setContentView(R.layout.activity_account)
val intent = intent
accountId = intent.getStringExtra(KEY_ACCOUNT_ID)
followState = FollowState.NOT_FOLLOWING
blocking = false
muting = false
loadedAccount = null
// set toolbar top margin according to system window insets
ViewCompat.setOnApplyWindowInsetsListener(accountCoordinatorLayout) { _, insets ->
val top = insets.systemWindowInsetTop
val toolbarParams = accountToolbar.layoutParams as CollapsingToolbarLayout.LayoutParams
toolbarParams.topMargin = top
insets.consumeSystemWindowInsets()
}
// Setup the toolbar.
setSupportActionBar(accountToolbar)
supportActionBar?.title = null
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
hideFab = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("fabHide", false)
toolbarColor = ThemeUtils.getColor(this, R.attr.toolbar_background_color)
backgroundColor = ThemeUtils.getColor(this, android.R.attr.colorBackground)
statusBarColorTransparent = ContextCompat.getColor(this, R.color.header_background_filter)
statusBarColorOpaque = ThemeUtils.getColor(this, R.attr.colorPrimaryDark)
textColorPrimary = ThemeUtils.getColor(this, android.R.attr.textColorPrimary)
textColorSecondary = ThemeUtils.getColor(this, android.R.attr.textColorSecondary)
avatarSize = resources.getDimensionPixelSize(R.dimen.account_activity_avatar_size).toFloat()
titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height)
ThemeUtils.setDrawableTint(this, accountToolbar.navigationIcon, R.attr.account_toolbar_icon_tint_uncollapsed)
ThemeUtils.setDrawableTint(this, accountToolbar.overflowIcon, R.attr.account_toolbar_icon_tint_uncollapsed)
// Add a listener to change the toolbar icon color when it enters/exits its collapsed state.
accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {
@AttrRes var priorAttribute = R.attr.account_toolbar_icon_tint_uncollapsed
override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
@AttrRes val attribute = if (titleVisibleHeight + verticalOffset < 0) {
accountToolbar.setTitleTextColor(textColorPrimary)
accountToolbar.setSubtitleTextColor(textColorSecondary)
R.attr.account_toolbar_icon_tint_collapsed
} else {
accountToolbar.setTitleTextColor(Color.TRANSPARENT)
accountToolbar.setSubtitleTextColor(Color.TRANSPARENT)
R.attr.account_toolbar_icon_tint_uncollapsed
}
if (attribute != priorAttribute) {
priorAttribute = attribute
val context = accountToolbar.context
ThemeUtils.setDrawableTint(context, accountToolbar.navigationIcon, attribute)
ThemeUtils.setDrawableTint(context, accountToolbar.overflowIcon, attribute)
}
if (hideFab && !isSelf && !blocking) {
if (verticalOffset > oldOffset) {
accountFloatingActionButton.show()
}
if (verticalOffset < oldOffset) {
accountFloatingActionButton.hide()
}
}
oldOffset = verticalOffset
val scaledAvatarSize = (avatarSize + verticalOffset) / avatarSize
accountAvatarImageView.scaleX = scaledAvatarSize
accountAvatarImageView.scaleY = scaledAvatarSize
accountAvatarImageView.visible(scaledAvatarSize > 0)
var transparencyPercent = Math.abs(verticalOffset) / titleVisibleHeight.toFloat()
if (transparencyPercent > 1) transparencyPercent = 1f
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int
}
val evaluatedToolbarColor = argbEvaluator.evaluate(transparencyPercent, Color.TRANSPARENT, toolbarColor) as Int
val evaluatedTabBarColor = argbEvaluator.evaluate(transparencyPercent, backgroundColor, toolbarColor) as Int
accountToolbar.setBackgroundColor(evaluatedToolbarColor)
accountHeaderInfoContainer.setBackgroundColor(evaluatedTabBarColor)
accountTabLayout.setBackgroundColor(evaluatedTabBarColor)
}
})
// Initialise the default UI states.
accountFloatingActionButton.hide()
accountFollowButton.hide()
accountFollowsYouTextView.hide()
// Obtain information to fill out the profile.
viewModel.obtainAccount(accountId)
val activeAccount = accountManager.activeAccount
if (accountId == activeAccount?.accountId) {
isSelf = true
updateButtons()
} else {
isSelf = false
viewModel.obtainRelationship(accountId)
}
// setup the RecyclerView for the account fields
accountFieldList.isNestedScrollingEnabled = false
accountFieldList.layoutManager = LinearLayoutManager(this)
accountFieldList.adapter = accountFieldAdapter
// Setup the tabs and timeline pager.
val adapter = AccountPagerAdapter(supportFragmentManager, accountId)
val pageTitles = arrayOf(getString(R.string.title_statuses), getString(R.string.title_statuses_with_replies), getString(R.string.title_media))
adapter.setPageTitles(pageTitles)
accountFragmentViewPager.pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
val pageMarginDrawable = ThemeUtils.getDrawable(this, R.attr.tab_page_margin_drawable,
R.drawable.tab_page_margin_dark)
accountFragmentViewPager.setPageMarginDrawable(pageMarginDrawable)
accountFragmentViewPager.adapter = adapter
accountFragmentViewPager.offscreenPageLimit = 2
accountTabLayout.setupWithViewPager(accountFragmentViewPager)
val accountListClickListener = { v: View ->
val type = when (v.id) {
R.id.accountFollowers-> AccountListActivity.Type.FOLLOWERS
R.id.accountFollowing -> AccountListActivity.Type.FOLLOWING
else -> throw AssertionError()
}
val accountListIntent = AccountListActivity.newIntent(this, type, accountId)
startActivity(accountListIntent)
}
accountFollowers.setOnClickListener(accountListClickListener)
accountFollowing.setOnClickListener(accountListClickListener)
accountStatuses.setOnClickListener {
// Make nice ripple effect on tab
accountTabLayout.getTabAt(0)!!.select()
val poorTabView = (accountTabLayout.getChildAt(0) as ViewGroup).getChildAt(0)
poorTabView.isPressed = true
accountTabLayout.postDelayed({ poorTabView.isPressed = false }, 300)
}
}
private fun onAccountChanged(account: Account?) {
if (account != null) {
loadedAccount = account
val usernameFormatted = getString(R.string.status_username_format, account.username)
accountUsernameTextView.text = usernameFormatted
accountDisplayNameTextView.text = CustomEmojiHelper.emojifyString(account.name, account.emojis, accountDisplayNameTextView)
if (supportActionBar != null) {
supportActionBar?.title = EmojiCompat.get().process(account.name)
val subtitle = String.format(getString(R.string.status_username_format),
account.username)
supportActionBar?.subtitle = subtitle
}
val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView)
LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this)
accountLockedImageView.visible(account.locked)
accountBadgeTextView.visible(account.bot)
Picasso.with(this)
.load(account.avatar)
.transform(RoundedTransformation(25f))
.placeholder(R.drawable.avatar_default)
.into(accountAvatarImageView)
Picasso.with(this)
.load(account.header)
.into(accountHeaderImageView)
accountFieldAdapter.fields = account.fields
accountFieldAdapter.emojis = account.emojis
accountFieldAdapter.notifyDataSetChanged()
if (account.moved != null) {
val movedAccount = account.moved
accountMovedView.show()
// necessary because accountMovedView is now replaced in layout hierachy
findViewById<View>(R.id.accountMovedView).setOnClickListener {
onViewAccount(movedAccount.id)
}
accountMovedDisplayName.text = movedAccount.name
accountMovedUsername.text = getString(R.string.status_username_format, movedAccount.username)
Picasso.with(this)
.load(movedAccount.avatar)
.transform(RoundedTransformation(25f))
.placeholder(R.drawable.avatar_default)
.into(accountMovedAvatar)
accountMovedText.text = getString(R.string.account_moved_description, movedAccount.displayName)
// this is necessary because API 19 can't handle vector compound drawables
val movedIcon = ContextCompat.getDrawable(this, R.drawable.ic_briefcase)?.mutate()
val textColor = ThemeUtils.getColor(this, android.R.attr.textColorTertiary)
movedIcon?.setColorFilter(textColor, PorterDuff.Mode.SRC_IN)
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(accountMovedText, movedIcon, null, null, null)
accountFollowers.hide()
accountFollowing.hide()
accountStatuses.hide()
accountTabLayout.hide()
accountFragmentViewPager.hide()
accountTabBottomShadow.hide()
}
val numberFormat = NumberFormat.getNumberInstance()
accountFollowersTextView.text = numberFormat.format(account.followersCount)
accountFollowingTextView.text = numberFormat.format(account.followingCount)
accountStatusesTextView.text = numberFormat.format(account.statusesCount)
accountFloatingActionButton.setOnClickListener { _ -> mention() }
accountFollowButton.setOnClickListener { _ ->
if (isSelf) {
val intent = Intent(this@AccountActivity, EditProfileActivity::class.java)
startActivityForResult(intent, EDIT_ACCOUNT)
return@setOnClickListener
}
when (followState) {
AccountActivity.FollowState.NOT_FOLLOWING -> {
viewModel.changeFollowState(accountId)
}
AccountActivity.FollowState.REQUESTED -> {
showFollowRequestPendingDialog()
}
AccountActivity.FollowState.FOLLOWING -> {
showUnfollowWarningDialog()
}
}
updateFollowButton()
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
//reload account when returning from EditProfileActivity
if(requestCode == EDIT_ACCOUNT && resultCode == Activity.RESULT_OK) {
viewModel.obtainAccount(accountId, true)
}
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putString(KEY_ACCOUNT_ID, accountId)
super.onSaveInstanceState(outState)
}
private fun onRelationshipChanged(relation: Relationship) {
followState = when {
relation.following -> FollowState.FOLLOWING
relation.requested -> FollowState.REQUESTED
else -> FollowState.NOT_FOLLOWING
}
blocking = relation.blocking
muting = relation.muting
showingReblogs = relation.showingReblogs
accountFollowsYouTextView.visible(relation.followedBy)
updateButtons()
}
private fun reload() {
viewModel.obtainAccount(accountId, true)
viewModel.obtainRelationship(accountId)
}
private fun updateFollowButton() {
if(isSelf) {
accountFollowButton.setText(R.string.action_edit_own_profile)
return
}
when (followState) {
AccountActivity.FollowState.NOT_FOLLOWING -> {
accountFollowButton.setText(R.string.action_follow)
}
AccountActivity.FollowState.REQUESTED -> {
accountFollowButton.setText(R.string.state_follow_requested)
}
AccountActivity.FollowState.FOLLOWING -> {
accountFollowButton.setText(R.string.action_unfollow)
}
}
}
private fun updateButtons() {
invalidateOptionsMenu()
if (!blocking && loadedAccount?.moved == null) {
accountFollowButton.show()
updateFollowButton()
if(isSelf) {
accountFloatingActionButton.hide()
} else {
accountFloatingActionButton.show()
}
} else {
accountFloatingActionButton.hide()
accountFollowButton.hide()
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.account_toolbar, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
if (!isSelf) {
val follow = menu.findItem(R.id.action_follow)
follow.title = if (followState == FollowState.NOT_FOLLOWING) {
getString(R.string.action_follow)
} else {
getString(R.string.action_unfollow)
}
follow.isVisible = followState != FollowState.REQUESTED
val block = menu.findItem(R.id.action_block)
block.title = if (blocking) {
getString(R.string.action_unblock)
} else {
getString(R.string.action_block)
}
val mute = menu.findItem(R.id.action_mute)
mute.title = if (muting) {
getString(R.string.action_unmute)
} else {
getString(R.string.action_mute)
}
if (followState == FollowState.FOLLOWING) {
val showReblogs = menu.findItem(R.id.action_show_reblogs)
showReblogs.title = if (showingReblogs) {
getString(R.string.action_hide_reblogs)
} else {
getString(R.string.action_show_reblogs)
}
} else {
menu.removeItem(R.id.action_show_reblogs)
}
} else {
// It shouldn't be possible to block or follow yourself.
menu.removeItem(R.id.action_follow)
menu.removeItem(R.id.action_block)
menu.removeItem(R.id.action_mute)
menu.removeItem(R.id.action_show_reblogs)
}
return super.onPrepareOptionsMenu(menu)
}
private fun showFollowRequestPendingDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.dialog_message_cancel_follow_request)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState(accountId) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showUnfollowWarningDialog() {
AlertDialog.Builder(this)
.setMessage(R.string.dialog_unfollow_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState(accountId) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun mention() {
loadedAccount?.let {
val intent = ComposeActivity.IntentBuilder()
.mentionedUsernames(setOf(it.username))
.build(this)
startActivity(intent)
}
}
override fun onViewTag(tag: String) {
val intent = Intent(this, ViewTagActivity::class.java)
intent.putExtra("hashtag", tag)
startActivity(intent)
}
override fun onViewAccount(id: String) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra("id", id)
startActivity(intent)
}
override fun onViewUrl(url: String) {
viewUrl(url)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
onBackPressed()
return true
}
R.id.action_mention -> {
mention()
return true
}
R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input.
if (loadedAccount != null) {
LinkHelper.openLink(loadedAccount?.url, this)
}
return true
}
R.id.action_follow -> {
viewModel.changeFollowState(accountId)
return true
}
R.id.action_block -> {
viewModel.changeBlockState(accountId)
return true
}
R.id.action_mute -> {
viewModel.changeMuteState(accountId)
return true
}
R.id.action_show_reblogs -> {
viewModel.changeShowReblogsState(accountId)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun getActionButton(): FloatingActionButton? {
return if (!isSelf && !blocking) {
accountFloatingActionButton
} else null
}