diff --git a/app/build.gradle b/app/build.gradle index e0a8d68a4..729801e06 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' android { compileSdkVersion 26 @@ -58,6 +59,7 @@ dependencies { compile "com.mikepenz:google-material-typeface:3.0.1.0.original@aar" compile "com.theartofdev.edmodo:android-image-cropper:2.5.1" compile 'com.evernote:android-job:1.2.0' + implementation 'com.android.support.constraint:constraint-layout:1.0.2' //room compile "android.arch.persistence.room:runtime:1.0.0-rc1" @@ -67,5 +69,9 @@ dependencies { androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { exclude group: 'com.android.support', module: 'support-annotations' }) + compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" +} +repositories { + mavenCentral() } \ No newline at end of file diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java index be0257ea0..e815fcd66 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.java @@ -41,6 +41,7 @@ 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; @@ -51,8 +52,8 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.pager.AccountPagerAdapter; import com.keylesspalace.tusky.receiver.TimelineReceiver; -import com.keylesspalace.tusky.util.LinkHelper; 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; @@ -66,7 +67,7 @@ import retrofit2.Call; import retrofit2.Callback; import retrofit2.Response; -public class AccountActivity extends BaseActivity implements ActionButtonActivity { +public final class AccountActivity extends BaseActivity implements ActionButtonActivity { private static final String TAG = "AccountActivity"; // logging tag private enum FollowState { @@ -81,6 +82,7 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit private boolean muting; private boolean isSelf; private Account loadedAccount; + private CircularImageView avatar; private ImageView header; private FloatingActionButton floatingBtn; @@ -89,6 +91,10 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit private TabLayout tabLayout; private ImageView accountLockedView; private View container; + private TextView followersTextView; + private TextView followingTextView; + private TextView statusesTextView; + private boolean hideFab; private int oldOffset; @@ -105,6 +111,9 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit 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"); @@ -139,7 +148,8 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit 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; + @AttrRes + int priorAttribute = R.attr.account_toolbar_icon_tint_uncollapsed; @Override public void onOffsetChanged(AppBarLayout appBarLayout, int verticalOffset) { @@ -147,10 +157,10 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit 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)); + 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 { @@ -166,7 +176,7 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit ThemeUtils.setDrawableTint(context, toolbar.getOverflowIcon(), attribute); } - if(floatingBtn != null && hideFab && !isSelf && !blocking) { + if (floatingBtn != null && hideFab && !isSelf && !blocking) { if (verticalOffset > oldOffset) { floatingBtn.show(); } @@ -196,28 +206,62 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit } // Setup the tabs and timeline pager. - AccountPagerAdapter adapter = new AccountPagerAdapter(getSupportFragmentManager(), this, + AccountPagerAdapter adapter = new AccountPagerAdapter(getSupportFragmentManager(), accountId); String[] pageTitles = { - getString(R.string.title_statuses), - getString(R.string.title_follows), - getString(R.string.title_followers) + getString(R.string.title_statuses), + getString(R.string.title_media) }; adapter.setPageTitles(pageTitles); - ViewPager viewPager = findViewById(R.id.pager); + 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); - for (int i = 0; i < tabLayout.getTabCount(); i++) { - TabLayout.Tab tab = tabLayout.getTabAt(i); - if (tab != null) { - tab.setCustomView(adapter.getTabView(i, tabLayout)); + + View.OnClickListener accountListClickListener = new View.OnClickListener() { + @Override + public void onClick(View 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(new View.OnClickListener() { + @Override + public void onClick(View 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(new Runnable() { + @Override + public void run() { + poorTabView.setPressed(false); + } + }, 300); + } + }); } @Override @@ -306,11 +350,17 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit // Add counts to the tabs in the TabLayout. String[] counts = { - nf.format(Integer.parseInt(account.statusesCount)), - nf.format(Integer.parseInt(account.followingCount)), - nf.format(Integer.parseInt(account.followersCount)), + nf.format(Integer.parseInt(account.statusesCount)), + "" }; + long followersCount = Long.parseLong(account.followersCount); + long followingCount = Long.parseLong(account.followingCount); + long statusesCount = Long.parseLong(account.statusesCount); + 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)); + for (int i = 0; i < tabLayout.getTabCount(); i++) { TabLayout.Tab tab = tabLayout.getTabAt(i); if (tab != null) { @@ -397,7 +447,7 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit private void updateButtons() { invalidateOptionsMenu(); - if(!isSelf && !blocking) { + if (!isSelf && !blocking) { floatingBtn.show(); followBtn.setVisibility(View.VISIBLE); @@ -449,9 +499,11 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit private String getFollowAction() { switch (followState) { default: - case NOT_FOLLOWING: return getString(R.string.action_follow); + case NOT_FOLLOWING: + return getString(R.string.action_follow); case REQUESTED: - case FOLLOWING: return getString(R.string.action_unfollow); + case FOLLOWING: + return getString(R.string.action_unfollow); } } @@ -517,8 +569,14 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit Assert.expect(followState != FollowState.REQUESTED); switch (followState) { - case NOT_FOLLOWING: { mastodonApi.followAccount(id).enqueue(cb); break; } - case FOLLOWING: { mastodonApi.unfollowAccount(id).enqueue(cb); break; } + case NOT_FOLLOWING: { + mastodonApi.followAccount(id).enqueue(cb); + break; + } + case FOLLOWING: { + mastodonApi.unfollowAccount(id).enqueue(cb); + break; + } } } @@ -655,7 +713,6 @@ public class AccountActivity extends BaseActivity implements ActionButtonActivit .mentionedUsernames(Collections.singleton(loadedAccount.username)) .build(this); startActivity(intent); - startActivity(intent); return true; } diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.java b/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.java index 735c16126..c9ad79d4e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/AccountListActivity.java @@ -15,8 +15,10 @@ package com.keylesspalace.tusky; +import android.content.Context; import android.content.Intent; import android.os.Bundle; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentTransaction; @@ -26,11 +28,27 @@ import android.view.MenuItem; import com.keylesspalace.tusky.fragment.AccountListFragment; -public class AccountListActivity extends BaseActivity { +public final class AccountListActivity extends BaseActivity { + + private static final String TYPE_EXTRA = "type"; + private static final String ARG_EXTRA = "arg"; + + public static Intent newIntent(@NonNull Context context, @NonNull Type type, + @Nullable String argument) { + Intent intent = new Intent(context, AccountListActivity.class); + intent.putExtra(TYPE_EXTRA, type); + if (argument != null) { + intent.putExtra(ARG_EXTRA, argument); + } + return intent; + } + enum Type { BLOCKS, MUTES, FOLLOW_REQUESTS, + FOLLOWERS, + FOLLOWING, } @Override @@ -51,29 +69,55 @@ public class AccountListActivity extends BaseActivity { ActionBar bar = getSupportActionBar(); if (bar != null) { switch (type) { - case BLOCKS: { bar.setTitle(getString(R.string.title_blocks)); break; } - case MUTES: { bar.setTitle(getString(R.string.title_mutes)); break; } + case BLOCKS: { + bar.setTitle(getString(R.string.title_blocks)); + break; + } + case MUTES: { + bar.setTitle(getString(R.string.title_mutes)); + break; + } case FOLLOW_REQUESTS: { bar.setTitle(getString(R.string.title_follow_requests)); break; } + case FOLLOWERS: + bar.setTitle(getString(R.string.title_followers)); + break; + case FOLLOWING: + bar.setTitle(getString(R.string.title_follows)); } bar.setDisplayHomeAsUpEnabled(true); bar.setDisplayShowHomeEnabled(true); } FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); - AccountListFragment.Type fragmentType; + AccountListFragment fragment; switch (type) { default: - case BLOCKS: { fragmentType = AccountListFragment.Type.BLOCKS; break; } - case MUTES: { fragmentType = AccountListFragment.Type.MUTES; break; } + case BLOCKS: { + fragment = AccountListFragment.newInstance(AccountListFragment.Type.BLOCKS); + break; + } + case MUTES: { + fragment = AccountListFragment.newInstance(AccountListFragment.Type.MUTES); + break; + } + case FOLLOWERS: { + String argument = intent.getStringExtra(ARG_EXTRA); + fragment = AccountListFragment.newInstance(AccountListFragment.Type.FOLLOWERS, argument); + break; + } + case FOLLOWING: { + String argument = intent.getStringExtra(ARG_EXTRA); + fragment = AccountListFragment.newInstance(AccountListFragment.Type.FOLLOWS, argument); + break; + } case FOLLOW_REQUESTS: { - fragmentType = AccountListFragment.Type.FOLLOW_REQUESTS; + fragment = AccountListFragment.newInstance(AccountListFragment.Type.FOLLOW_REQUESTS); break; } } - Fragment fragment = AccountListFragment.newInstance(fragmentType); fragmentTransaction.replace(R.id.fragment_container, fragment); fragmentTransaction.commit(); } diff --git a/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java b/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java index b6332ea15..cecfae754 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ReportActivity.java @@ -182,7 +182,7 @@ public class ReportActivity extends BaseActivity { onFetchStatusesFailure((Exception) t); } }; - mastodonApi.accountStatuses(accountId, null, null, null) + mastodonApi.accountStatuses(accountId, null, null, null, null) .enqueue(callback); } 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 a428e028d..9bd5aa3c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.java @@ -61,6 +61,9 @@ import retrofit2.Response; public class AccountListFragment extends BaseFragment implements AccountActionListener { private static final String TAG = "AccountList"; // logging tag + public AccountListFragment() { + } + public enum Type { FOLLOWS, FOLLOWERS, @@ -75,13 +78,11 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi private RecyclerView recyclerView; private EndlessOnScrollListener scrollListener; private AccountAdapter adapter; - private TabLayout.OnTabSelectedListener onTabSelectedListener; private MastodonApi api; private boolean bottomLoading; private int bottomFetches; private boolean topLoading; private int topFetches; - private boolean hideFab; public static AccountListFragment newInstance(Type type) { Bundle arguments = new Bundle(); @@ -112,7 +113,7 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { + @Nullable Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_account_list, container, false); @@ -152,86 +153,21 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi super.onActivityCreated(savedInstanceState); BaseActivity activity = (BaseActivity) getActivity(); - if (jumpToTopAllowed()) { - TabLayout layout = activity.findViewById(R.id.tab_layout); - onTabSelectedListener = new TabLayout.OnTabSelectedListener() { - @Override - public void onTabSelected(TabLayout.Tab tab) {} - - @Override - public void onTabUnselected(TabLayout.Tab tab) {} - - @Override - public void onTabReselected(TabLayout.Tab tab) { - jumpToTop(); - } - }; - layout.addOnTabSelectedListener(onTabSelectedListener); - } - /* MastodonApi on the base activity is only guaranteed to be initialised after the parent * activity is created, so everything needing to access the api object has to be delayed * until here. */ api = activity.mastodonApi; - - - if (actionButtonPresent()) { - /* Use a modified scroll listener that both loads more statuses as it goes, and hides - * the follow button on down-scroll. */ - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getContext()); - hideFab = preferences.getBoolean("fabHide", false); - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onScrolled(RecyclerView view, int dx, int dy) { - super.onScrolled(view, dx, dy); - - ActionButtonActivity actionButtonActivity = (ActionButtonActivity) getActivity(); - FloatingActionButton composeButton = actionButtonActivity.getActionButton(); - - if (composeButton != null) { - if (hideFab) { - if (dy > 0 && composeButton.isShown()) { - composeButton.hide(); // hides the button if we're scrolling down - } else if (dy < 0 && !composeButton.isShown()) { - composeButton.show(); // shows it if we are scrolling up - } - } else if (!composeButton.isShown()) { - composeButton.show(); - } - } - } - - @Override - public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { - AccountListFragment.this.onLoadMore(view); - } - }; - } else { - // Just use the basic scroll listener to load more accounts. - scrollListener = new EndlessOnScrollListener(layoutManager) { - @Override - public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { - AccountListFragment.this.onLoadMore(view); - } - }; - } + // Just use the basic scroll listener to load more accounts. + scrollListener = new EndlessOnScrollListener(layoutManager) { + @Override + public void onLoadMore(int page, int totalItemsCount, RecyclerView view) { + AccountListFragment.this.onLoadMore(view); + } + }; recyclerView.addOnScrollListener(scrollListener); } - private boolean actionButtonPresent() { - return type == Type.FOLLOWS || type == Type.FOLLOWERS; - } - - @Override - public void onDestroyView() { - if (jumpToTopAllowed()) { - TabLayout tabLayout = getActivity().findViewById(R.id.tab_layout); - tabLayout.removeOnTabSelectedListener(onTabSelectedListener); - } - super.onDestroyView(); - } - @Override public void onViewAccount(String id) { Intent intent = new Intent(getContext(), AccountActivity.class); @@ -420,10 +356,6 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi Log.e(TAG, message); } - private boolean jumpToTopAllowed() { - return type == Type.FOLLOWS || type == Type.FOLLOWERS; - } - private void jumpToTop() { layoutManager.scrollToPositionWithOffset(0, 0); scrollListener.reset(); @@ -437,11 +369,16 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi private Call> getFetchCallByListType(Type type, String fromId, String uptoId) { switch (type) { default: - case FOLLOWS: return api.accountFollowing(accountId, fromId, uptoId, null); - case FOLLOWERS: return api.accountFollowers(accountId, fromId, uptoId, null); - case BLOCKS: return api.blocks(fromId, uptoId, null); - case MUTES: return api.mutes(fromId, uptoId, null); - case FOLLOW_REQUESTS: return api.followRequests(fromId, uptoId, null); + case FOLLOWS: + return api.accountFollowing(accountId, fromId, uptoId, null); + case FOLLOWERS: + return api.accountFollowers(accountId, fromId, uptoId, null); + case BLOCKS: + return api.blocks(fromId, uptoId, null); + case MUTES: + return api.mutes(fromId, uptoId, null); + case FOLLOW_REQUESTS: + return api.followRequests(fromId, uptoId, null); } } @@ -491,7 +428,7 @@ public class AccountListFragment extends BaseFragment implements AccountActionLi } private void onFetchAccountsSuccess(List accounts, String linkHeader, - FetchEnd fetchEnd) { + FetchEnd fetchEnd) { List links = HttpHeaderLink.parse(linkHeader); switch (fetchEnd) { case TOP: { diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt new file mode 100644 index 000000000..ebcdab985 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountMediaFragment.kt @@ -0,0 +1,288 @@ +/* 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.fragment + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.preference.PreferenceManager +import android.support.v4.app.ActivityOptionsCompat +import android.support.v4.content.ContextCompat +import android.support.v4.view.ViewCompat +import android.support.v4.widget.SwipeRefreshLayout +import android.support.v7.widget.GridLayoutManager +import android.support.v7.widget.RecyclerView +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.ViewVideoActivity +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.view.SquareImageView +import com.squareup.picasso.Picasso +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.util.* + +/** + * Created by charlag on 26/10/2017. + * + * Fragment with multiple columns of media previews for the specified account. + */ + +class AccountMediaFragment : BaseFragment() { + + companion object { + @JvmStatic + fun newInstance(accountId: String): AccountMediaFragment { + val fragment = AccountMediaFragment() + fragment.arguments = Bundle() + fragment.arguments.putString(ACCOUNT_ID_ARG, accountId) + return fragment + } + + private const val ACCOUNT_ID_ARG = "account_id" + private const val TAG = "AccountMediaFragment" + } + + private val adapter = MediaGridAdapter() + private var currentCall: Call>? = null + private lateinit var api: MastodonApi + private val statuses = mutableListOf() + private var fetchingStatus = FetchingStatus.NOT_FETCHING + lateinit private var swipeLayout: SwipeRefreshLayout + + private val callback = object : Callback> { + override fun onFailure(call: Call>?, t: Throwable?) { + fetchingStatus = FetchingStatus.NOT_FETCHING + swipeLayout.isRefreshing = false + Log.d(TAG, "Failed to fetch account media", t) + } + + override fun onResponse(call: Call>, response: Response>) { + fetchingStatus = FetchingStatus.NOT_FETCHING + swipeLayout.isRefreshing = false + val body = response.body() + body?.let { fetched -> + statuses.addAll(0, fetched) + // flatMap requires iterable but I don't want to box each array into list + val result = mutableListOf() + for (status in fetched) { + result.addAll(status.attachments) + } + adapter.addTop(result) + } + } + } + + private val bottomCallback = object : Callback> { + override fun onFailure(call: Call>?, t: Throwable?) { + fetchingStatus = FetchingStatus.NOT_FETCHING + Log.d(TAG, "Failed to fetch account media", t) + } + + override fun onResponse(call: Call>, response: Response>) { + fetchingStatus = FetchingStatus.NOT_FETCHING + val body = response.body() + body?.let { fetched -> + Log.d(TAG, "fetched ${fetched.size} statuses") + if (fetched.isNotEmpty()) Log.d(TAG, "first: ${fetched.first().id}, last: ${fetched.last().id}") + statuses.addAll(fetched) + Log.d(TAG, "now there are ${statuses.size} statuses") + // flatMap requires iterable but I don't want to box each array into list + val result = mutableListOf() + for (status in fetched) { + result.addAll(status.attachments) + } + adapter.addBottom(result) + } + } + + } + + override fun onAttach(context: Context) { + super.onAttach(context) + // we should get rid of this + api = (context as BaseActivity).mastodonApi + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + val view = inflater.inflate(R.layout.fragment_timeline, container, false) + val recyclerView = view.findViewById(R.id.recycler_view) + val columnCount = context.resources.getInteger(R.integer.profile_media_column_count) + val layoutManager = GridLayoutManager(context, columnCount) + + val lightThemeEnabled = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean("lightTheme", false) + val bgRes = if (lightThemeEnabled) R.color.window_background_light + else R.color.window_background_dark + adapter.baseItemColor = ContextCompat.getColor(recyclerView.context, bgRes) + + recyclerView.layoutManager = layoutManager + recyclerView.adapter = adapter + + val accountId = arguments.getString(ACCOUNT_ID_ARG) + + swipeLayout = view.findViewById(R.id.swipe_refresh_layout) + swipeLayout.setOnRefreshListener { + if (fetchingStatus != FetchingStatus.NOT_FETCHING) return@setOnRefreshListener + currentCall = if (statuses.isEmpty()) { + fetchingStatus = FetchingStatus.INITIAL_FETCHING + api.accountStatuses(accountId, null, null, null, true) + } else { + fetchingStatus = FetchingStatus.REFRESHING + api.accountStatuses(accountId, null, statuses[0].id, null, true) + } + currentCall?.enqueue(callback) + + } + + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + + override fun onScrolled(recyclerView: RecyclerView?, dx: Int, dy: Int) { + if (dy > 0) { + val itemCount = layoutManager.itemCount + val lastItem = layoutManager.findLastCompletelyVisibleItemPosition() + if (itemCount <= lastItem + 3 && fetchingStatus == FetchingStatus.NOT_FETCHING) { + 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?.enqueue(bottomCallback) + } + } + } + } + }) + + return view + } + + // That's sort of an optimization to only load media once user has opened the tab + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + if (!isVisibleToUser) return + 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?.enqueue(callback) + } + } + + private fun viewMedia(items: List, currentIndex: Int, view: View?) { + val urls = items.map { it.url }.toTypedArray() + val type = items[currentIndex].type + + when (type) { + Status.MediaAttachment.Type.IMAGE -> { + val intent = Intent(context, ViewMediaActivity::class.java) + intent.putExtra("urls", urls) + intent.putExtra("urlIndex", currentIndex) + if (view != null) { + val url = urls[currentIndex] + ViewCompat.setTransitionName(view, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, + view, url) + startActivity(intent, options.toBundle()) + } else { + startActivity(intent) + } + } + Status.MediaAttachment.Type.GIFV, Status.MediaAttachment.Type.VIDEO -> { + val intent = Intent(context, ViewVideoActivity::class.java) + intent.putExtra("url", urls[currentIndex]) + startActivity(intent) + } + Status.MediaAttachment.Type.UNKNOWN, null -> { + }/* Intentionally do nothing. This case is here is to handle when new attachment + * types are added to the API before code is added here to handle them. So, the + * best fallback is to just show the preview and ignore requests to view them. */ + } + } + + private enum class FetchingStatus { + NOT_FETCHING, INITIAL_FETCHING, FETCHING_BOTTOM, REFRESHING + } + + inner class MediaGridAdapter + : RecyclerView.Adapter() { + + var baseItemColor = Color.BLACK + + private val items = mutableListOf() + private val itemBgBaseHSV = FloatArray(3) + private val random = Random() + + fun addTop(newItems: List) { + items.addAll(0, newItems) + notifyItemRangeInserted(0, newItems.size) + } + + fun addBottom(newItems: List) { + if (newItems.isEmpty()) return + + val oldLen = items.size + items.addAll(newItems) + notifyItemRangeInserted(oldLen, newItems.size) + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + val hsv = FloatArray(3) + Color.colorToHSV(baseItemColor, hsv) + super.onAttachedToRecyclerView(recyclerView) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { + val view = SquareImageView(parent.context) + view.scaleType = ImageView.ScaleType.CENTER_CROP + return MediaViewHolder(view) + } + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { + itemBgBaseHSV[2] = random.nextFloat() * (1f - 0.3f) + 0.3f + holder.imageView.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV)) + val item = items[position] + Picasso.with(holder.imageView.context) + .load(item.previewUrl) + .into(holder.imageView) + } + + + inner class MediaViewHolder(val imageView: ImageView) + : RecyclerView.ViewHolder(imageView), + View.OnClickListener { + init { + itemView.setOnClickListener(this) + } + + // saving some allocations + override fun onClick(v: View?) { + viewMedia(items, adapterPosition, imageView) + } + } + } +} \ No newline at end of file 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 d5d6da60c..d3c9b3dfe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/TimelineFragment.java @@ -20,6 +20,7 @@ import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.design.widget.FloatingActionButton; import android.support.design.widget.TabLayout; @@ -263,7 +264,7 @@ public class TimelineFragment extends SFragment implements final Status status = statuses.get(position); super.reblogWithCallback(status, reblog, new Callback() { @Override - public void onResponse(Call call, retrofit2.Response response) { + public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { if (response.isSuccessful()) { status.reblogged = reblog; @@ -280,7 +281,7 @@ public class TimelineFragment extends SFragment implements } @Override - public void onFailure(Call call, Throwable t) { + public void onFailure(@NonNull Call call, @NonNull Throwable t) { Log.d(TAG, "Failed to reblog status " + status.id); t.printStackTrace(); } @@ -293,7 +294,7 @@ public class TimelineFragment extends SFragment implements super.favouriteWithCallback(status, favourite, new Callback() { @Override - public void onResponse(Call call, retrofit2.Response response) { + public void onResponse(@NonNull Call call, @NonNull retrofit2.Response response) { if (response.isSuccessful()) { status.favourited = favourite; @@ -310,7 +311,7 @@ public class TimelineFragment extends SFragment implements } @Override - public void onFailure(Call call, Throwable t) { + public void onFailure(@NonNull Call call, @NonNull Throwable t) { Log.d(TAG, "Failed to favourite status " + status.id); t.printStackTrace(); } @@ -462,7 +463,7 @@ public class TimelineFragment extends SFragment implements case TAG: return api.hashtagTimeline(tagOrId, null, fromId, uptoId, null); case USER: - return api.accountStatuses(tagOrId, fromId, uptoId, null); + return api.accountStatuses(tagOrId, fromId, uptoId, null, null); case FAVOURITES: return api.favourites(fromId, uptoId, null); } @@ -495,7 +496,7 @@ public class TimelineFragment extends SFragment implements Callback> callback = new Callback>() { @Override - public void onResponse(Call> call, Response> response) { + public void onResponse(@NonNull Call> call, @NonNull Response> response) { if (response.isSuccessful()) { String linkHeader = response.headers().get("Link"); onFetchTimelineSuccess(response.body(), linkHeader, fetchEnd); @@ -505,7 +506,7 @@ public class TimelineFragment extends SFragment implements } @Override - public void onFailure(Call> call, Throwable t) { + public void onFailure(@NonNull Call> call, @NonNull Throwable t) { onFetchTimelineFailure((Exception) t, fetchEnd); } }; 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 78003e1eb..502eeb39f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.java @@ -15,6 +15,8 @@ package com.keylesspalace.tusky.network; +import android.support.annotation.Nullable; + import com.keylesspalace.tusky.entity.AccessToken; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.AppCredentials; @@ -127,12 +129,25 @@ public interface MastodonApi { @Query("limit") Integer limit); @GET("api/v1/accounts/{id}") Call account(@Path("id") String accountId); + + /** + * Method to fetch statuses for the specified account. + * @param accountId ID for account for which statuses will be requested + * @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. + * @return + */ @GET("api/v1/accounts/{id}/statuses") Call> accountStatuses( @Path("id") String accountId, @Query("max_id") String maxId, @Query("since_id") String sinceId, - @Query("limit") Integer limit); + @Query("limit") Integer limit, + @Nullable @Query("only_media") Boolean onlyMedia); @GET("api/v1/accounts/{id}/followers") Call> accountFollowers( @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 0929c1675..f031186f5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/pager/AccountPagerAdapter.java @@ -15,27 +15,19 @@ package com.keylesspalace.tusky.pager; -import android.content.Context; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentPagerAdapter; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.fragment.AccountListFragment; +import com.keylesspalace.tusky.fragment.AccountMediaFragment; import com.keylesspalace.tusky.fragment.TimelineFragment; public class AccountPagerAdapter extends FragmentPagerAdapter { - private Context context; private String accountId; private String[] pageTitles; - public AccountPagerAdapter(FragmentManager manager, Context context, String accountId) { + public AccountPagerAdapter(FragmentManager manager, String accountId) { super(manager); - this.context = context; this.accountId = accountId; } @@ -50,31 +42,21 @@ public class AccountPagerAdapter extends FragmentPagerAdapter { return TimelineFragment.newInstance(TimelineFragment.Kind.USER, accountId); } case 1: { - return AccountListFragment.newInstance(AccountListFragment.Type.FOLLOWS, accountId); - } - case 2: { - return AccountListFragment.newInstance(AccountListFragment.Type.FOLLOWERS, accountId); + return AccountMediaFragment.newInstance(accountId); } default: { - return null; + throw new AssertionError("Page " + position + " is out of AccountPagerAdapter bounds"); } } } @Override public int getCount() { - return 3; + return 2; } @Override public CharSequence getPageTitle(int position) { return pageTitles[position]; } - - public View getTabView(int position, ViewGroup root) { - View view = LayoutInflater.from(context).inflate(R.layout.tab_account, root, false); - TextView title = view.findViewById(R.id.title); - title.setText(pageTitles[position]); - return view; - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt new file mode 100644 index 000000000..5e2330efb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/SquareImageView.kt @@ -0,0 +1,24 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.ImageView + +/** + * Created by charlag on 26/10/2017. + */ + +class SquareImageView : ImageView { + constructor(context: Context) : super(context) + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) + + constructor(context: Context, attributes: AttributeSet, defStyleAttr: Int) + : super(context, attributes, defStyleAttr) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val width = measuredWidth + setMeasuredDimension(width, width) + } +} \ No newline at end of file diff --git a/app/src/main/res/color/account_tab_font_color.xml b/app/src/main/res/color/account_tab_font_color.xml index accc21cbc..c81c01a26 100644 --- a/app/src/main/res/color/account_tab_font_color.xml +++ b/app/src/main/res/color/account_tab_font_color.xml @@ -1,5 +1,5 @@ - - + + \ 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 a1788cb45..16359a8dd 100644 --- a/app/src/main/res/layout/activity_account.xml +++ b/app/src/main/res/layout/activity_account.xml @@ -40,7 +40,7 @@ android:scaleType="centerCrop" app:layout_collapseMode="pin" /> -