From 63f9d99390ec167dcc065e496a9b94a00e371d8e Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Mon, 18 Jun 2018 13:26:18 +0200 Subject: [PATCH] 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 --- app/build.gradle | 2 + .../keylesspalace/tusky/AboutActivity.java | 3 +- .../keylesspalace/tusky/AccountActivity.java | 717 ------------------ .../keylesspalace/tusky/AccountActivity.kt | 620 +++++++++++++++ .../tusky/BottomSheetActivity.kt | 3 +- .../tusky/EditProfileActivity.kt | 3 +- .../com/keylesspalace/tusky/MainActivity.java | 4 +- .../keylesspalace/tusky/ReportActivity.java | 2 +- .../tusky/adapter/AccountFieldAdapter.kt | 56 ++ .../keylesspalace/tusky/di/AppComponent.kt | 3 +- .../keylesspalace/tusky/di/NetworkModule.kt | 2 +- .../tusky/di/ViewModelFactory.kt | 40 + .../com/keylesspalace/tusky/entity/Account.kt | 33 +- .../tusky/entity/Relationship.kt | 3 +- .../tusky/fragment/AccountListFragment.java | 8 +- .../tusky/fragment/AccountMediaFragment.kt | 8 +- .../tusky/fragment/SearchFragment.kt | 3 +- .../tusky/fragment/TimelineFragment.java | 28 +- .../tusky/network/MastodonApi.java | 36 +- .../tusky/pager/AccountPagerAdapter.java | 5 +- .../com/keylesspalace/tusky/util/Resource.kt | 9 + .../tusky/util/ViewExtensions.kt | 19 + .../tusky/viewmodel/AccountViewModel.kt | 190 +++++ .../res/drawable-night/avatar_background.xml | 6 + .../profile_badge_background.xml | 6 + .../res/drawable/account_header_default.xml | 7 - .../res/drawable/account_header_gradient.xml | 12 - .../main/res/drawable/avatar_background.xml | 6 + app/src/main/res/drawable/ic_briefcase.xml | 9 + .../res/drawable/profile_badge_background.xml | 6 + app/src/main/res/layout/activity_account.xml | 396 ++++++---- .../main/res/layout/item_account_field.xml | 36 + .../main/res/layout/view_account_moved.xml | 58 ++ app/src/main/res/menu/account_toolbar.xml | 5 + app/src/main/res/values-ar/strings.xml | 1 - app/src/main/res/values-ca/strings.xml | 1 - app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 1 - app/src/main/res/values-fr/strings.xml | 1 - app/src/main/res/values-hu/strings.xml | 1 - app/src/main/res/values-ja/strings.xml | 1 - app/src/main/res/values-night/styles.xml | 5 +- app/src/main/res/values-nl/strings.xml | 1 - app/src/main/res/values-oc/strings.xml | 1 - app/src/main/res/values-pl/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values-ta/strings.xml | 1 - app/src/main/res/values-tr/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rHK/strings.xml | 1 - app/src/main/res/values-zh-rMO/strings.xml | 1 - app/src/main/res/values-zh-rSG/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/colors.xml | 2 - app/src/main/res/values/dimens.xml | 4 +- app/src/main/res/values/strings.xml | 10 +- app/src/main/res/values/styles.xml | 8 +- .../tusky/BottomSheetActivityTest.kt | 3 +- .../tusky/ComposeActivityTest.kt | 3 +- 60 files changed, 1422 insertions(+), 978 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/AccountActivity.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/Resource.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt create mode 100644 app/src/main/res/drawable-night/avatar_background.xml create mode 100644 app/src/main/res/drawable-night/profile_badge_background.xml delete mode 100644 app/src/main/res/drawable/account_header_default.xml delete mode 100644 app/src/main/res/drawable/account_header_gradient.xml create mode 100644 app/src/main/res/drawable/avatar_background.xml create mode 100644 app/src/main/res/drawable/ic_briefcase.xml create mode 100644 app/src/main/res/drawable/profile_badge_background.xml create mode 100644 app/src/main/res/layout/item_account_field.xml create mode 100644 app/src/main/res/layout/view_account_moved.xml diff --git a/app/build.gradle b/app/build.gradle index 888cd8feb..b5c6c6708 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' diff --git a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.java b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.java index 46df8d585..4e4e47539 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AboutActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AboutActivity.java @@ -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); } diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java deleted file mode 100644 index 96f877dac..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java +++ /dev/null @@ -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 . */ - -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 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() { - @Override - public void onResponse(@NonNull Call call, - @NonNull Response response) { - if (response.isSuccessful()) { - onObtainAccountSuccess(response.body()); - } else { - onObtainAccountFailure(); - } - } - - @Override - public void onFailure(@NonNull Call 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 ids = new ArrayList<>(1); - ids.add(accountId); - mastodonApi.relationships(ids).enqueue(new Callback>() { - @Override - public void onResponse(@NonNull Call> call, - @NonNull Response> response) { - List 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> 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 cb = new Callback() { - @Override - public void onResponse(@NonNull Call call, - @NonNull Response 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 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 cb = new Callback() { - @Override - public void onResponse(@NonNull Call call, - @NonNull Response 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 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 cb = new Callback() { - @Override - public void onResponse(@NonNull Call call, - @NonNull Response 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 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 supportFragmentInjector() { - return dispatchingAndroidInjector; - } - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt new file mode 100644 index 000000000..24edd1736 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt @@ -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 . */ + +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 + @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> { + 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> { + 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(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 + } + + override fun supportFragmentInjector(): AndroidInjector { + return dispatchingAndroidInjector + } + + companion object { + + private const val EDIT_ACCOUNT = 1457 + + private const val KEY_ACCOUNT_ID = "id" + private val argbEvaluator = ArgbEvaluator() + + @JvmStatic + fun getIntent(context: Context, accountId: String): Intent { + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(KEY_ACCOUNT_ID, accountId) + return intent + } + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index b464b9c72..2c6c3a7b4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -117,8 +117,7 @@ abstract class BottomSheetActivity : BaseActivity() { } open fun viewAccount(id: String) { - val intent = Intent(this, AccountActivity::class.java) - intent.putExtra("id", id) + val intent = AccountActivity.getIntent(this, id) startActivity(intent) } diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 19153a8f9..162f4e26d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -155,7 +155,6 @@ class EditProfileActivity : BaseActivity(), Injectable { if (!headerChanged) { Picasso.with(headerPreview.context) .load(me.header) - .placeholder(R.drawable.account_header_default) .into(headerPreview) } } @@ -289,6 +288,7 @@ class EditProfileActivity : BaseActivity(), Injectable { if (displayName == null && note == null && locked == null && avatar == null && header == null) { /** if nothing has changed, there is no need to make a network request */ + setResult(Activity.RESULT_OK) finish() return } @@ -302,6 +302,7 @@ class EditProfileActivity : BaseActivity(), Injectable { privatePreferences.edit() .putBoolean("refreshProfileHeader", true) .apply() + setResult(Activity.RESULT_OK) finish() } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java index eed7db9ab..4bf392c8b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.java @@ -393,8 +393,7 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut //open profile when active image was clicked if (current && activeAccount != null) { - Intent intent = new Intent(MainActivity.this, AccountActivity.class); - intent.putExtra("id", activeAccount.getAccountId()); + Intent intent = AccountActivity.getIntent(this, activeAccount.getAccountId()); startActivity(intent); return true; } @@ -478,7 +477,6 @@ public final class MainActivity extends BottomSheetActivity implements ActionBut Picasso.with(MainActivity.this) .load(me.getHeader()) - .placeholder(R.drawable.account_header_default) .into(background); accountManager.updateActiveAccount(me); diff --git a/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java b/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java index b2c970437..3a2e04eb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java @@ -189,7 +189,7 @@ public class ReportActivity extends BaseActivity implements Injectable { onFetchStatusesFailure((Exception) t); } }; - mastodonApi.accountStatuses(accountId, null, null, null, null) + mastodonApi.accountStatuses(accountId, null, null, null, null, null) .enqueue(callback); } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt new file mode 100644 index 000000000..2f1ece543 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt @@ -0,0 +1,56 @@ +/* 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 . */ + +package com.keylesspalace.tusky.adapter + +import android.support.v7.widget.RecyclerView +import android.view.LayoutInflater +import android.view.ViewGroup +import android.view.View +import android.widget.TextView +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Field +import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.CustomEmojiHelper +import com.keylesspalace.tusky.util.LinkHelper +import kotlinx.android.synthetic.main.item_account_field.view.* + +class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter() { + + var emojis: List = emptyList() + var fields: List = emptyList() + + override fun getItemCount(): Int { + return fields.size + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountFieldAdapter.ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_account_field, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: AccountFieldAdapter.ViewHolder, position: Int) { + viewHolder.nameTextView.text = fields[position].name + val emojifiedValue = CustomEmojiHelper.emojifyText(fields[position].value, emojis, viewHolder.valueTextView) + LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener) + } + + class ViewHolder(rootView: View) : RecyclerView.ViewHolder(rootView) { + val nameTextView: TextView = rootView.accountFieldName + val valueTextView: TextView = rootView.accountFieldValue + } + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index c3851ad58..d47e879af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -33,7 +33,8 @@ import javax.inject.Singleton AndroidInjectionModule::class, ActivitiesModule::class, ServicesModule::class, - BroadcastReceiverModule::class + BroadcastReceiverModule::class, + ViewModelModule::class ]) interface AppComponent { @Component.Builder diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index d7418ad69..3f34306c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -75,7 +75,7 @@ class NetworkModule { .apply { addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) if (BuildConfig.DEBUG) { - addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BASIC)) + addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) } } .build() diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt new file mode 100644 index 000000000..95015e60f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -0,0 +1,40 @@ +// from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 + +package com.keylesspalace.tusky.di + +import android.arch.lifecycle.ViewModel +import android.arch.lifecycle.ViewModelProvider +import com.keylesspalace.tusky.viewmodel.AccountViewModel +import dagger.Binds +import dagger.MapKey +import dagger.Module +import dagger.multibindings.IntoMap +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton +import kotlin.reflect.KClass + +@Singleton +class ViewModelFactory @Inject constructor(private val viewModels: MutableMap, Provider>) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T = viewModels[modelClass]?.get() as T +} + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +@kotlin.annotation.Retention(AnnotationRetention.RUNTIME) +@MapKey +internal annotation class ViewModelKey(val value: KClass) + +@Module +abstract class ViewModelModule { + + @Binds + internal abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory + + @Binds + @IntoMap + @ViewModelKey(AccountViewModel::class) + internal abstract fun accountViewModel(viewModel: AccountViewModel): ViewModel + + //Add more ViewModels here +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index 14c415c00..878cc5e17 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.entity -import android.annotation.SuppressLint import android.os.Parcel import android.os.Parcelable import android.text.Spanned @@ -26,7 +25,6 @@ import kotlinx.android.parcel.Parceler import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.WriteWith -@SuppressLint("ParcelCreator") @Parcelize data class Account( val id: String, @@ -41,7 +39,11 @@ data class Account( @SerializedName("followers_count") val followersCount: Int, @SerializedName("following_count") val followingCount: Int, @SerializedName("statuses_count") val statusesCount: Int, - val source: AccountSource? + val source: AccountSource?, + val bot: Boolean, + val emojis: List = emptyList(), + val fields: List = emptyList(), + val moved: Account? = null ) : Parcelable { @@ -62,20 +64,25 @@ data class Account( return account?.id == this.id } - object SpannedParceler : Parceler { - override fun create(parcel: Parcel) = HtmlUtils.fromHtml(parcel.readString()) - - override fun Spanned.write(parcel: Parcel, flags: Int) { - parcel.writeString(HtmlUtils.toHtml(this)) - } - } - } @Parcelize -@SuppressLint("ParcelCreator") data class AccountSource( val privacy: Status.Visibility, val sensitive: Boolean, val note: String -): Parcelable \ No newline at end of file +): Parcelable + +@Parcelize +data class Field ( + val name:String, + val value: @WriteWith() Spanned +): Parcelable + +object SpannedParceler : Parceler { + override fun create(parcel: Parcel): Spanned = HtmlUtils.fromHtml(parcel.readString()) + + override fun Spanned.write(parcel: Parcel, flags: Int) { + parcel.writeString(HtmlUtils.toHtml(this)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt index 35e0737de..3da157038 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt @@ -23,5 +23,6 @@ data class Relationship ( @SerializedName("followed_by") val followedBy: Boolean, val blocking: Boolean, val muting: Boolean, - val requested: Boolean + val requested: Boolean, + @SerializedName("showing_reblogs") val showingReblogs: Boolean ) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java index 50ad7d1de..017bc52ce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java @@ -167,9 +167,11 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi @Override public void onViewAccount(String id) { - Intent intent = new Intent(getContext(), AccountActivity.class); - intent.putExtra("id", id); - startActivity(intent); + Context context = getContext(); + if(context != null) { + Intent intent = AccountActivity.getIntent(context, id); + startActivity(intent); + } } @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt index 9f0757148..6f632b03c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt @@ -156,10 +156,10 @@ class AccountMediaFragment : BaseFragment(), Injectable { if (fetchingStatus != FetchingStatus.NOT_FETCHING) return@setOnRefreshListener currentCall = if (statuses.isEmpty()) { fetchingStatus = FetchingStatus.INITIAL_FETCHING - api.accountStatuses(accountId, null, null, null, true) + api.accountStatuses(accountId, null, null, null, null, true) } else { fetchingStatus = FetchingStatus.REFRESHING - api.accountStatuses(accountId, null, statuses[0].id, null, true) + api.accountStatuses(accountId, null, statuses[0].id, null, null, true) } currentCall?.enqueue(callback) @@ -179,7 +179,7 @@ class AccountMediaFragment : BaseFragment(), Injectable { statuses.lastOrNull()?.let { last -> Log.d(TAG, "Requesting statuses with max_id: ${last.id}, (bottom)") fetchingStatus = FetchingStatus.FETCHING_BOTTOM - currentCall = api.accountStatuses(accountId, last.id, null, null, true) + currentCall = api.accountStatuses(accountId, last.id, null, null, null, true) currentCall?.enqueue(bottomCallback) } } @@ -195,7 +195,7 @@ class AccountMediaFragment : BaseFragment(), Injectable { val accountId = arguments?.getString(ACCOUNT_ID_ARG) if (fetchingStatus == FetchingStatus.NOT_FETCHING && statuses.isEmpty()) { fetchingStatus = FetchingStatus.INITIAL_FETCHING - currentCall = api.accountStatuses(accountId, null, null, null, true) + currentCall = api.accountStatuses(accountId, null, null, null, null, true) currentCall?.enqueue(callback) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt index afca8516e..9aca783e7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SearchFragment.kt @@ -217,8 +217,7 @@ class SearchFragment : SFragment(), StatusActionListener, Injectable { } override fun onViewAccount(id: String) { - val intent = Intent(context, AccountActivity::class.java) - intent.putExtra("id", id) + val intent = AccountActivity.getIntent(requireContext(), id) startActivity(intent) } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java index 1030d1bd8..b65f3d573 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -103,6 +103,7 @@ public class TimelineFragment extends SFragment implements PUBLIC_FEDERATED, TAG, USER, + USER_WITH_REPLIES, FAVOURITES, LIST } @@ -200,7 +201,7 @@ public class TimelineFragment extends SFragment implements Bundle savedInstanceState) { Bundle arguments = Objects.requireNonNull(getArguments()); kind = Kind.valueOf(arguments.getString(KIND_ARG)); - if (kind == Kind.TAG || kind == Kind.USER || kind == Kind.LIST) { + if (kind == Kind.TAG || kind == Kind.USER || kind == Kind.USER_WITH_REPLIES|| kind == Kind.LIST) { hashtagOrId = arguments.getString(HASHTAG_OR_ID_ARG); } @@ -309,14 +310,20 @@ public class TimelineFragment extends SFragment implements removeAllByAccountId(id); } } else if (event instanceof BlockEvent) { - String id = ((BlockEvent) event).getAccountId(); - removeAllByAccountId(id); + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES) { + String id = ((BlockEvent) event).getAccountId(); + removeAllByAccountId(id); + } } else if (event instanceof MuteEvent) { - String id = ((MuteEvent) event).getAccountId(); - removeAllByAccountId(id); + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES) { + String id = ((MuteEvent) event).getAccountId(); + removeAllByAccountId(id); + } } else if (event instanceof StatusDeletedEvent) { - String id = ((StatusDeletedEvent) event).getStatusId(); - deleteStatusById(id); + if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES) { + String id = ((StatusDeletedEvent) event).getStatusId(); + deleteStatusById(id); + } } else if (event instanceof StatusComposedEvent) { Status status = ((StatusComposedEvent) event).getStatus(); handleStatusComposeEvent(status); @@ -587,7 +594,7 @@ public class TimelineFragment extends SFragment implements @Override public void onViewAccount(String id) { - if (kind == Kind.USER && hashtagOrId.equals(id)) { + if ((kind == Kind.USER || kind == Kind.USER_WITH_REPLIES) && hashtagOrId.equals(id)) { /* If already viewing an account page, then any requests to view that account page * should be ignored. */ return; @@ -724,7 +731,9 @@ public class TimelineFragment extends SFragment implements case TAG: return api.hashtagTimeline(tagOrId, null, fromId, uptoId, LOAD_AT_ONCE); case USER: - return api.accountStatuses(tagOrId, fromId, uptoId, LOAD_AT_ONCE, null); + return api.accountStatuses(tagOrId, fromId, uptoId, LOAD_AT_ONCE, true, null); + case USER_WITH_REPLIES: + return api.accountStatuses(tagOrId, fromId, uptoId, LOAD_AT_ONCE, null, null); case FAVOURITES: return api.favourites(fromId, uptoId, LOAD_AT_ONCE); case LIST: @@ -1024,6 +1033,7 @@ public class TimelineFragment extends SFragment implements case PUBLIC_LOCAL: break; case USER: + case USER_WITH_REPLIES: if (status.getAccount().getId().equals(hashtagOrId)) { break; } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java index aeb82d85b..e55be6a7e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -37,6 +37,7 @@ import okhttp3.MultipartBody; import okhttp3.RequestBody; import okhttp3.ResponseBody; import retrofit2.Call; +import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.Field; import retrofit2.http.FormUrlEncoded; @@ -60,12 +61,14 @@ public interface MastodonApi { @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + @GET("api/v1/timelines/public") Call> publicTimeline( @Query("local") Boolean local, @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + @GET("api/v1/timelines/tag/{hashtag}") Call> hashtagTimeline( @Path("hashtag") String hashtag, @@ -73,6 +76,7 @@ public interface MastodonApi { @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + @GET("api/v1/timelines/list/{listId}") Call> listTimeline( @Path("listId") String listId, @@ -85,17 +89,21 @@ public interface MastodonApi { @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + @GET("api/v1/notifications") Call> notificationsWithAuth( @Header("Authorization") String auth, @Header(DOMAIN_HEADER) String domain); + @POST("api/v1/notifications/clear") Call clearNotifications(); + @GET("api/v1/notifications/{id}") Call notification(@Path("id") String notificationId); @Multipart @POST("api/v1/media") Call uploadMedia(@Part MultipartBody.Part file); + @FormUrlEncoded @PUT("api/v1/media/{mediaId}") Call updateMedia(@Path("mediaId") String mediaId, @@ -113,30 +121,39 @@ public interface MastodonApi { @Field("sensitive") Boolean sensitive, @Field("media_ids[]") List mediaIds, @Header("Idempotency-Key") String idempotencyKey); + @GET("api/v1/statuses/{id}") Call status(@Path("id") String statusId); + @GET("api/v1/statuses/{id}/context") Call statusContext(@Path("id") String statusId); + @GET("api/v1/statuses/{id}/reblogged_by") Call> statusRebloggedBy( @Path("id") String statusId, @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + @GET("api/v1/statuses/{id}/favourited_by") Call> statusFavouritedBy( @Path("id") String statusId, @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + @DELETE("api/v1/statuses/{id}") Call deleteStatus(@Path("id") String statusId); + @POST("api/v1/statuses/{id}/reblog") Call reblogStatus(@Path("id") String statusId); + @POST("api/v1/statuses/{id}/unreblog") Call unreblogStatus(@Path("id") String statusId); + @POST("api/v1/statuses/{id}/favourite") Call favouriteStatus(@Path("id") String statusId); + @POST("api/v1/statuses/{id}/unfavourite") Call unfavouriteStatus(@Path("id") String statusId); @@ -166,9 +183,8 @@ public interface MastodonApi { * @param maxId Only statuses with ID less than maxID will be returned * @param sinceId Only statuses with ID bigger than sinceID will be returned * @param limit Limit returned statuses (current API limits: default - 20, max - 40) - * @param onlyMedia Should server return only statuses which contain media. Caution! The server - * works in a weird way so if any value if present at this field it will be - * interpreted as "true". Pass null to return all statuses. + * @param excludeReplies only return statuses that are no replies + * @param onlyMedia only return statuses that have media attached * @return */ @GET("api/v1/accounts/{id}/statuses") @@ -177,29 +193,39 @@ public interface MastodonApi { @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit, + @Nullable @Query("exclude_replies") Boolean excludeReplies, @Nullable @Query("only_media") Boolean onlyMedia); + @GET("api/v1/accounts/{id}/followers") Call> accountFollowers( @Path("id") String accountId, @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + @GET("api/v1/accounts/{id}/following") Call> accountFollowing( @Path("id") String accountId, @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + + @FormUrlEncoded @POST("api/v1/accounts/{id}/follow") - Call followAccount(@Path("id") String accountId); + Call followAccount(@Path("id") String accountId, @Field("reblogs") boolean showReblogs); + @POST("api/v1/accounts/{id}/unfollow") Call unfollowAccount(@Path("id") String accountId); + @POST("api/v1/accounts/{id}/block") Call blockAccount(@Path("id") String accountId); + @POST("api/v1/accounts/{id}/unblock") Call unblockAccount(@Path("id") String accountId); + @POST("api/v1/accounts/{id}/mute") Call muteAccount(@Path("id") String accountId); + @POST("api/v1/accounts/{id}/unmute") Call unmuteAccount(@Path("id") String accountId); @@ -229,8 +255,10 @@ public interface MastodonApi { @Query("max_id") String maxId, @Query("since_id") String sinceId, @Query("limit") Integer limit); + @POST("api/v1/follow_requests/{id}/authorize") Call authorizeFollowRequest(@Path("id") String accountId); + @POST("api/v1/follow_requests/{id}/reject") Call rejectFollowRequest(@Path("id") String accountId); diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.java b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.java index f031186f5..4545f289b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.java @@ -42,6 +42,9 @@ public class AccountPagerAdapter extends FragmentPagerAdapter { return TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId); } case 1: { + return TimelineFragment.newInstance(TimelineFragment.Kind.USER_WITH_REPLIES, accountId); + } + case 2: { return AccountMediaFragment.newInstance(accountId); } default: { @@ -52,7 +55,7 @@ public class AccountPagerAdapter extends FragmentPagerAdapter { @Override public int getCount() { - return 2; + return 3; } @Override diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt new file mode 100644 index 000000000..14e458a1c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt @@ -0,0 +1,9 @@ +package com.keylesspalace.tusky.util + +sealed class Resource(open val data: T?) + +class Loading (override val data: T? = null) : Resource(data) + +class Success (override val data: T? = null) : Resource(data) + +class Error (override val data: T? = null, val errorMessage: String? = null): Resource(data) \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt new file mode 100644 index 000000000..d8ef39bc7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt @@ -0,0 +1,19 @@ +package com.keylesspalace.tusky.util + +import android.view.View + +fun View.show() { + this.visibility = View.VISIBLE +} + +fun View.hide() { + this.visibility = View.GONE +} + +fun View.visible(visible: Boolean) { + this.visibility = if(visible) { + View.VISIBLE + } else { + View.GONE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt new file mode 100644 index 000000000..e12b084ef --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountViewModel.kt @@ -0,0 +1,190 @@ +package com.keylesspalace.tusky.viewmodel + +import android.arch.lifecycle.MutableLiveData +import android.arch.lifecycle.ViewModel +import com.keylesspalace.tusky.appstore.BlockEvent +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.MuteEvent +import com.keylesspalace.tusky.appstore.UnfollowEvent +import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Relationship +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Error +import com.keylesspalace.tusky.util.Loading +import com.keylesspalace.tusky.util.Resource +import com.keylesspalace.tusky.util.Success +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import javax.inject.Inject + +class AccountViewModel @Inject constructor( + private val mastodonApi: MastodonApi, + private val eventHub: EventHub + ): ViewModel() { + + val accountData = MutableLiveData>() + val relationshipData = MutableLiveData>() + + private val callList: MutableList> = mutableListOf() + + + fun obtainAccount(accountId: String, reload: Boolean = false) { + if(accountData.value == null || reload) { + + accountData.postValue(Loading()) + + val call = mastodonApi.account(accountId) + call.enqueue(object : Callback { + override fun onResponse(call: Call, + response: Response) { + if (response.isSuccessful) { + accountData.postValue(Success(response.body())) + } else { + accountData.postValue(Error()) + } + } + + override fun onFailure(call: Call, t: Throwable) { + accountData.postValue(Error()) + } + }) + + callList.add(call) + } + } + + fun obtainRelationship(accountId: String, reload: Boolean = false) { + if(relationshipData.value == null || reload) { + + relationshipData.postValue(Loading()) + + val ids = listOf(accountId) + val call = mastodonApi.relationships(ids) + call.enqueue(object : Callback> { + override fun onResponse(call: Call>, + response: Response>) { + val relationships = response.body() + if (response.isSuccessful && relationships != null) { + val relationship = relationships[0] + relationshipData.postValue(Success(relationship)) + } else { + relationshipData.postValue(Error()) + } + } + + override fun onFailure(call: Call>, t: Throwable) { + relationshipData.postValue(Error()) + } + }) + + callList.add(call) + } + } + + fun changeFollowState(id: String) { + if (relationshipData.value?.data?.following == true) { + changeRelationship(RelationShipAction.UNFOLLOW, id) + } else { + changeRelationship(RelationShipAction.FOLLOW, id) + } + } + + fun changeBlockState(id: String) { + if (relationshipData.value?.data?.blocking == true) { + changeRelationship(RelationShipAction.UNBLOCK, id) + } else { + changeRelationship(RelationShipAction.BLOCK, id) + } + } + + fun changeMuteState(id: String) { + if (relationshipData.value?.data?.muting == true) { + changeRelationship(RelationShipAction.UNMUTE, id) + } else { + changeRelationship(RelationShipAction.MUTE, id) + } + } + + fun changeShowReblogsState(id: String) { + if (relationshipData.value?.data?.showingReblogs == true) { + changeRelationship(RelationShipAction.FOLLOW, id, false) + } else { + changeRelationship(RelationShipAction.FOLLOW, id, true) + } + } + + private fun changeRelationship(relationshipAction: RelationShipAction, id: String, showReblogs: Boolean = true) { + val relation = relationshipData.value?.data + val account = accountData.value?.data + + if(relation != null && account != null) { + // optimistically post new state for faster response + + val newRelation = when(relationshipAction) { + RelationShipAction.FOLLOW -> { + if (account.locked) { + relation.copy(requested = true) + } else { + relation.copy(following = true) + } + } + RelationShipAction.UNFOLLOW -> relation.copy(following = false) + RelationShipAction.BLOCK -> relation.copy(blocking = true) + RelationShipAction.UNBLOCK -> relation.copy(blocking = false) + RelationShipAction.MUTE -> relation.copy(muting = true) + RelationShipAction.UNMUTE -> relation.copy(muting = false) + } + relationshipData.postValue(Loading(newRelation)) + } + + val callback = object : Callback { + override fun onResponse(call: Call, + response: Response) { + val relationship = response.body() + if (response.isSuccessful && relationship != null) { + relationshipData.postValue(Success(relationship)) + + when (relationshipAction) { + RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(id)) + RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(id)) + RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(id)) + else -> {} + } + + } else { + relationshipData.postValue(Error(relation)) + } + + } + + override fun onFailure(call: Call, t: Throwable) { + relationshipData.postValue(Error(relation)) + } + } + + val call = when(relationshipAction) { + RelationShipAction.FOLLOW -> mastodonApi.followAccount(id, showReblogs) + RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(id) + RelationShipAction.BLOCK -> mastodonApi.blockAccount(id) + RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(id) + RelationShipAction.MUTE -> mastodonApi.muteAccount(id) + RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(id) + } + + call.enqueue(callback) + callList.add(call) + + } + + override fun onCleared() { + callList.forEach { + it.cancel() + } + } + + enum class RelationShipAction { + FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable-night/avatar_background.xml b/app/src/main/res/drawable-night/avatar_background.xml new file mode 100644 index 000000000..4fb7dbda1 --- /dev/null +++ b/app/src/main/res/drawable-night/avatar_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/profile_badge_background.xml b/app/src/main/res/drawable-night/profile_badge_background.xml new file mode 100644 index 000000000..083ae544b --- /dev/null +++ b/app/src/main/res/drawable-night/profile_badge_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/account_header_default.xml b/app/src/main/res/drawable/account_header_default.xml deleted file mode 100644 index 51629946c..000000000 --- a/app/src/main/res/drawable/account_header_default.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/account_header_gradient.xml b/app/src/main/res/drawable/account_header_gradient.xml deleted file mode 100644 index 9a66c8e51..000000000 --- a/app/src/main/res/drawable/account_header_gradient.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/avatar_background.xml b/app/src/main/res/drawable/avatar_background.xml new file mode 100644 index 000000000..848919782 --- /dev/null +++ b/app/src/main/res/drawable/avatar_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_briefcase.xml b/app/src/main/res/drawable/ic_briefcase.xml new file mode 100644 index 000000000..eeb80619d --- /dev/null +++ b/app/src/main/res/drawable/ic_briefcase.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/profile_badge_background.xml b/app/src/main/res/drawable/profile_badge_background.xml new file mode 100644 index 000000000..65a457e7e --- /dev/null +++ b/app/src/main/res/drawable/profile_badge_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_account.xml b/app/src/main/res/layout/activity_account.xml index b2fa3fca7..b131f5eee 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -2,229 +2,295 @@ + android:fillViewport="true"> + android:layout_height="wrap_content"> - + + + android:layout_marginTop="180dp" + android:paddingEnd="16dp" + android:paddingStart="16dp"> + +