move bottom sheet from fragments to activities (#628)

* move bottom sheet from fragments to activities

* move BottomSheetLogic to dedicated abstract Activity

* change tests

* improve code
This commit is contained in:
Konrad Pozniak 2018-05-06 22:05:54 +02:00 committed by GitHub
parent 1c711eca22
commit a2bfef3101
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 421 additions and 296 deletions

View File

@ -75,7 +75,7 @@ import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public final class AccountActivity extends BaseActivity implements ActionButtonActivity,
public final class AccountActivity extends BottomSheetActivity implements ActionButtonActivity,
HasSupportFragmentInjector {
private static final String TAG = "AccountActivity"; // logging tag
@ -329,8 +329,8 @@ public final class AccountActivity extends BaseActivity implements ActionButtonA
}
@Override
public void onViewURL(String url) {
LinkHelper.openLink(url, note.getContext());
public void onViewUrl(String url) {
viewUrl(url);
}
});
@ -711,4 +711,10 @@ public final class AccountActivity extends BaseActivity implements ActionButtonA
public AndroidInjector<Fragment> supportFragmentInjector() {
return dispatchingAndroidInjector;
}
@NonNull
@Override
public MastodonApi getMastodonApi() {
return mastodonApi;
}
}

View File

@ -34,10 +34,17 @@ import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import retrofit2.Call;
public abstract class BaseActivity extends AppCompatActivity {
protected List<Call> callList;
@Inject
public AccountManager accountManager;
@ -75,6 +82,9 @@ public abstract class BaseActivity extends AppCompatActivity {
getTheme().applyStyle(style, false);
redirectIfNotLoggedIn();
callList = new ArrayList<>();
}
@Override
@ -162,4 +172,12 @@ public abstract class BaseActivity extends AppCompatActivity {
.build()
.scheduleAsync();
}
@Override
protected void onDestroy() {
for (Call call : callList) {
call.cancel();
}
super.onDestroy();
}
}

View File

@ -0,0 +1,196 @@
/* Copyright 2018 Conny Duck
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.content.Intent
import android.os.Bundle
import android.support.annotation.VisibleForTesting
import android.support.design.widget.BottomSheetBehavior
import android.view.View
import android.widget.LinearLayout
import com.keylesspalace.tusky.entity.SearchResults
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.LinkHelper
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.net.URI
import java.net.URISyntaxException
/** this is the base class for all activities that open links
* links are checked against the api if they are mastodon links so they can be openend in Tusky
* Subclasses must have a bottom sheet with Id item_status_bottom_sheet in their layout hierachy
*/
abstract class BottomSheetActivity : BaseActivity() {
lateinit var bottomSheet: BottomSheetBehavior<LinearLayout>
var searchUrl: String? = null
abstract fun getMastodonApi(): MastodonApi
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
val bottomSheetLayout: LinearLayout = findViewById(R.id.item_status_bottom_sheet)
bottomSheet = BottomSheetBehavior.from(bottomSheetLayout)
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
bottomSheet.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
cancelActiveSearch()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
})
}
open fun viewUrl(url: String) {
if (!looksLikeMastodonUrl(url)) {
openLink(url)
return
}
val call = getMastodonApi().search(url, true)
call.enqueue(object : Callback<SearchResults> {
override fun onResponse(call: Call<SearchResults>, response: Response<SearchResults>) {
if (getCancelSearchRequested(url)) {
return
}
onEndSearch(url)
if (response.isSuccessful) {
// According to the mastodon API doc, if the search query is a url,
// only exact matches for statuses or accounts are returned
// which is good, because pleroma returns a different url
// than the public post link
val searchResult = response.body()
if(searchResult != null) {
if (searchResult.statuses.isNotEmpty()) {
viewThread(searchResult.statuses[0])
return
} else if (searchResult.accounts.isNotEmpty()) {
viewAccount(searchResult.accounts[0].id)
return
}
}
}
openLink(url)
}
override fun onFailure(call: Call<SearchResults>, t: Throwable) {
if (!getCancelSearchRequested(url)) {
onEndSearch(url)
openLink(url)
}
}
})
callList.add(call)
onBeginSearch(url)
}
open fun viewThread(status: Status) {
if (!isSearching()) {
val intent = Intent(this, ViewThreadActivity::class.java)
intent.putExtra("id", status.actionableId)
intent.putExtra("url", status.actionableStatus.url)
startActivity(intent)
}
}
open fun viewAccount(id: String) {
val intent = Intent(this, AccountActivity::class.java)
intent.putExtra("id", id)
startActivity(intent)
}
@VisibleForTesting
fun onBeginSearch(url: String) {
searchUrl = url
showQuerySheet()
}
@VisibleForTesting
fun getCancelSearchRequested(url: String): Boolean {
return url != searchUrl
}
@VisibleForTesting
fun isSearching(): Boolean {
return searchUrl != null
}
@VisibleForTesting
fun onEndSearch(url: String?) {
if (url == searchUrl) {
// Don't clear query if there's no match,
// since we might just now be getting the response for a canceled search
searchUrl = null
hideQuerySheet()
}
}
@VisibleForTesting
fun cancelActiveSearch() {
if (isSearching()) {
onEndSearch(searchUrl)
}
}
@VisibleForTesting
open fun openLink(url: String) {
LinkHelper.openLink(url, this)
}
private fun showQuerySheet() {
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED
}
private fun hideQuerySheet() {
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN
}
}
// https://mastodon.foo.bar/@User
// https://mastodon.foo.bar/@User/43456787654678
// https://pleroma.foo.bar/users/User
// https://pleroma.foo.bar/users/43456787654678
// https://pleroma.foo.bar/notice/43456787654678
// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207
fun looksLikeMastodonUrl(urlString: String): Boolean {
val uri: URI
try {
uri = URI(urlString)
} catch (e: URISyntaxException) {
return false
}
if (uri.query != null ||
uri.fragment != null ||
uri.path == null) {
return false
}
val path = uri.path
return path.matches("^/@[^/]+$".toRegex()) ||
path.matches("^/users/[^/]+$".toRegex()) ||
path.matches("^/@[^/]+/\\d+$".toRegex()) ||
path.matches("^/notice/\\d+$".toRegex()) ||
path.matches("^/objects/[-a-f0-9]+$".toRegex())
}

View File

@ -24,6 +24,9 @@ import android.support.v7.widget.Toolbar;
import android.view.MenuItem;
import com.keylesspalace.tusky.fragment.TimelineFragment;
import com.keylesspalace.tusky.network.MastodonApi;
import org.jetbrains.annotations.NotNull;
import javax.inject.Inject;
@ -31,8 +34,10 @@ import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.support.HasSupportFragmentInjector;
public class FavouritesActivity extends BaseActivity implements HasSupportFragmentInjector {
public class FavouritesActivity extends BottomSheetActivity implements HasSupportFragmentInjector {
@Inject
public MastodonApi mastodonApi;
@Inject
public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector;
@ -71,4 +76,10 @@ public class FavouritesActivity extends BaseActivity implements HasSupportFragme
public AndroidInjector<Fragment> supportFragmentInjector() {
return dispatchingAndroidInjector;
}
@NotNull
@Override
public MastodonApi getMastodonApi() {
return mastodonApi;
}
}

View File

@ -60,6 +60,8 @@ import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader;
import com.mikepenz.materialdrawer.util.DrawerImageLoader;
import com.squareup.picasso.Picasso;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
@ -72,7 +74,7 @@ import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class MainActivity extends BaseActivity implements ActionButtonActivity,
public class MainActivity extends BottomSheetActivity implements ActionButtonActivity,
HasSupportFragmentInjector {
private static final String TAG = "MainActivity"; // logging tag
private static final long DRAWER_ITEM_ADD_ACCOUNT = -13;
@ -550,4 +552,10 @@ public class MainActivity extends BaseActivity implements ActionButtonActivity,
public AndroidInjector<Fragment> supportFragmentInjector() {
return fragmentInjector;
}
@NotNull
@Override
public MastodonApi getMastodonApi() {
return mastodonApi;
}
}

View File

@ -10,13 +10,16 @@ import android.view.MenuItem
import android.widget.FrameLayout
import com.keylesspalace.tusky.fragment.TimelineFragment
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.network.MastodonApi
import dagger.android.AndroidInjector
import dagger.android.DispatchingAndroidInjector
import dagger.android.support.HasSupportFragmentInjector
import javax.inject.Inject
class ModalTimelineActivity : BaseActivity(), ActionButtonActivity, HasSupportFragmentInjector {
class ModalTimelineActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportFragmentInjector {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Fragment>
@ -75,4 +78,9 @@ class ModalTimelineActivity : BaseActivity(), ActionButtonActivity, HasSupportFr
override fun supportFragmentInjector(): AndroidInjector<Fragment> {
return dispatchingAndroidInjector
}
override fun getMastodonApi(): MastodonApi {
return api
}
}

View File

@ -30,6 +30,9 @@ import android.view.Menu;
import android.view.MenuItem;
import com.keylesspalace.tusky.fragment.SearchFragment;
import com.keylesspalace.tusky.network.MastodonApi;
import org.jetbrains.annotations.NotNull;
import javax.inject.Inject;
@ -37,9 +40,10 @@ import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.support.HasSupportFragmentInjector;
public class SearchActivity extends BaseActivity implements SearchView.OnQueryTextListener,
public class SearchActivity extends BottomSheetActivity implements SearchView.OnQueryTextListener,
HasSupportFragmentInjector {
@Inject
public MastodonApi mastodonApi;
@Inject
public DispatchingAndroidInjector<Fragment> fragmentInjector;
@ -139,4 +143,10 @@ public class SearchActivity extends BaseActivity implements SearchView.OnQueryTe
public AndroidInjector<Fragment> supportFragmentInjector() {
return fragmentInjector;
}
@NotNull
@Override
public MastodonApi getMastodonApi() {
return mastodonApi;
}
}

View File

@ -24,6 +24,9 @@ import android.support.v7.widget.Toolbar;
import android.view.MenuItem;
import com.keylesspalace.tusky.fragment.TimelineFragment;
import com.keylesspalace.tusky.network.MastodonApi;
import org.jetbrains.annotations.NotNull;
import javax.inject.Inject;
@ -31,8 +34,10 @@ import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.support.HasSupportFragmentInjector;
public class ViewTagActivity extends BaseActivity implements HasSupportFragmentInjector {
public class ViewTagActivity extends BottomSheetActivity implements HasSupportFragmentInjector {
@Inject
public MastodonApi mastodonApi;
@Inject
public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector;
@ -74,4 +79,10 @@ public class ViewTagActivity extends BaseActivity implements HasSupportFragmentI
public AndroidInjector<Fragment> supportFragmentInjector() {
return dispatchingAndroidInjector;
}
@NotNull
@Override
public MastodonApi getMastodonApi() {
return mastodonApi;
}
}

View File

@ -25,15 +25,18 @@ import android.view.Menu;
import android.view.MenuItem;
import com.keylesspalace.tusky.fragment.ViewThreadFragment;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.util.LinkHelper;
import org.jetbrains.annotations.NotNull;
import javax.inject.Inject;
import dagger.android.AndroidInjector;
import dagger.android.DispatchingAndroidInjector;
import dagger.android.support.HasSupportFragmentInjector;
public class ViewThreadActivity extends BaseActivity implements HasSupportFragmentInjector {
public class ViewThreadActivity extends BottomSheetActivity implements HasSupportFragmentInjector {
public static final int REVEAL_BUTTON_HIDDEN = 1;
public static final int REVEAL_BUTTON_REVEAL = 2;
@ -41,6 +44,8 @@ public class ViewThreadActivity extends BaseActivity implements HasSupportFragme
private int revealButtonState = REVEAL_BUTTON_HIDDEN;
@Inject
public MastodonApi mastodonApi;
@Inject
public DispatchingAndroidInjector<Fragment> dispatchingAndroidInjector;
@ -113,4 +118,10 @@ public class ViewThreadActivity extends BaseActivity implements HasSupportFragme
public AndroidInjector<Fragment> supportFragmentInjector() {
return dispatchingAndroidInjector;
}
@NotNull
@Override
public MastodonApi getMastodonApi() {
return mastodonApi;
}
}

View File

@ -20,49 +20,35 @@ import android.content.ClipboardManager;
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.design.widget.BottomSheetBehavior;
import android.support.v4.app.ActivityOptionsCompat;
import android.support.v4.view.ViewCompat;
import android.support.v7.widget.PopupMenu;
import android.text.Spanned;
import android.view.View;
import android.widget.LinearLayout;
import com.keylesspalace.tusky.AccountActivity;
import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.ComposeActivity;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.ReportActivity;
import com.keylesspalace.tusky.TuskyApplication;
import com.keylesspalace.tusky.ViewMediaActivity;
import com.keylesspalace.tusky.ViewTagActivity;
import com.keylesspalace.tusky.ViewThreadActivity;
import com.keylesspalace.tusky.ViewVideoActivity;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.SearchResults;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.AdapterItemRemover;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.TimelineCases;
import com.keylesspalace.tusky.util.HtmlUtils;
import com.keylesspalace.tusky.util.LinkHelper;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.inject.Inject;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an
* awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature
* of that is complicated by how they're coupled with Status and Notification and the corresponding
@ -74,10 +60,10 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
protected String loggedInAccountId;
protected String loggedInUsername;
protected String searchUrl;
protected abstract TimelineCases timelineCases();
protected BottomSheetBehavior bottomSheet;
private BottomSheetActivity bottomSheetActivity;
@Inject
protected MastodonApi mastodonApi;
@ -91,7 +77,6 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
loggedInAccountId = activeAccount.getAccountId();
loggedInUsername = activeAccount.getUsername();
}
setupBottomSheet(getView());
}
@Override
@ -100,9 +85,31 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
getActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left);
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
if(context instanceof BottomSheetActivity) {
bottomSheetActivity = (BottomSheetActivity)context;
} else {
throw new IllegalStateException("Fragment must be attached to a BottomSheetActivity!");
}
}
protected void openReblog(@Nullable final Status status) {
if (status == null) return;
viewAccount(status.getAccount().getId());
bottomSheetActivity.viewAccount(status.getAccount().getId());
}
protected void viewThread(Status status) {
bottomSheetActivity.viewThread(status);
}
protected void viewAccount(String accountId) {
bottomSheetActivity.viewAccount(accountId);
}
public void onViewUrl(String url) {
bottomSheetActivity.viewUrl(url);
}
protected void reply(Status status) {
@ -229,27 +236,12 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
}
}
protected void viewThread(Status status) {
if (!isSearching()) {
Intent intent = new Intent(getContext(), ViewThreadActivity.class);
intent.putExtra("id", status.getActionableId());
intent.putExtra("url", status.getActionableStatus().getUrl());
startActivity(intent);
}
}
protected void viewTag(String tag) {
Intent intent = new Intent(getContext(), ViewTagActivity.class);
intent.putExtra("hashtag", tag);
startActivity(intent);
}
protected void viewAccount(String id) {
Intent intent = new Intent(getContext(), AccountActivity.class);
intent.putExtra("id", id);
startActivity(intent);
}
protected void openReportPage(String accountId, String accountUsername, String statusId,
Spanned statusContent) {
Intent intent = new Intent(getContext(), ReportActivity.class);
@ -260,144 +252,5 @@ public abstract class SFragment extends BaseFragment implements AdapterItemRemov
startActivity(intent);
}
// https://mastodon.foo.bar/@User
// https://mastodon.foo.bar/@User/43456787654678
// https://pleroma.foo.bar/users/User
// https://pleroma.foo.bar/users/43456787654678
// https://pleroma.foo.bar/notice/43456787654678
// https://pleroma.foo.bar/objects/d4643c42-3ae0-4b73-b8b0-c725f5819207
static boolean looksLikeMastodonUrl(String urlString) {
URI uri;
try {
uri = new URI(urlString);
} catch (URISyntaxException e) {
return false;
}
if (uri.getQuery() != null ||
uri.getFragment() != null ||
uri.getPath() == null) {
return false;
}
String path = uri.getPath();
return path.matches("^/@[^/]+$") ||
path.matches("^/users/[^/]+$") ||
path.matches("^/@[^/]+/\\d+$") ||
path.matches("^/notice/\\d+$") ||
path.matches("^/objects/[-a-f0-9]+$");
}
void onBeginSearch(@NonNull String url) {
searchUrl = url;
showQuerySheet();
}
boolean getCancelSearchRequested(@NonNull String url) {
return !url.equals(searchUrl);
}
boolean isSearching() {
return searchUrl != null;
}
void onEndSearch(@NonNull String url) {
if (url.equals(searchUrl)) {
// Don't clear query if there's no match,
// since we might just now be getting the response for a canceled search
searchUrl = null;
hideQuerySheet();
}
}
void cancelActiveSearch()
{
if (isSearching()) {
onEndSearch(searchUrl);
}
}
void openLink(@NonNull String url) {
LinkHelper.openLink(url, getContext());
}
public void onViewURL(String url) {
if (!looksLikeMastodonUrl(url)) {
openLink(url);
return;
}
Call<SearchResults> call = mastodonApi.search(url, true);
call.enqueue(new Callback<SearchResults>() {
@Override
public void onResponse(@NonNull Call<SearchResults> call, @NonNull Response<SearchResults> response) {
if (getCancelSearchRequested(url)) {
return;
}
onEndSearch(url);
if (response.isSuccessful()) {
// According to the mastodon API doc, if the search query is a url,
// only exact matches for statuses or accounts are returned
// which is good, because pleroma returns a different url
// than the public post link
List<Status> statuses = response.body().getStatuses();
List<Account> accounts = response.body().getAccounts();
if (statuses != null && !statuses.isEmpty()) {
viewThread(statuses.get(0));
return;
} else if (accounts != null && !accounts.isEmpty()) {
viewAccount(accounts.get(0).getId());
return;
}
}
openLink(url);
}
@Override
public void onFailure(@NonNull Call<SearchResults> call, @NonNull Throwable t) {
if (!getCancelSearchRequested(url)) {
onEndSearch(url);
openLink(url);
}
}
});
callList.add(call);
onBeginSearch(url);
}
protected void setupBottomSheet(View view)
{
LinearLayout bottomSheetLayout = view.findViewById(R.id.item_status_bottom_sheet);
if (bottomSheetLayout != null) {
bottomSheet = BottomSheetBehavior.from(bottomSheetLayout);
bottomSheet.setState(BottomSheetBehavior.STATE_HIDDEN);
bottomSheet.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull View bottomSheet, int newState) {
switch(newState) {
case BottomSheetBehavior.STATE_HIDDEN:
cancelActiveSearch();
break;
default:
break;
}
}
@Override
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
}
});
}
}
private void showQuerySheet() {
if (bottomSheet != null)
bottomSheet.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
private void hideQuerySheet() {
if (bottomSheet != null)
bottomSheet.setState(BottomSheetBehavior.STATE_HIDDEN);
}
}

View File

@ -18,5 +18,5 @@ package com.keylesspalace.tusky.interfaces;
public interface LinkListener {
void onViewTag(String tag);
void onViewAccount(String id);
void onViewURL(String url);
void onViewUrl(String url);
}

View File

@ -20,12 +20,10 @@ import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.preference.PreferenceManager;
import android.provider.Browser;
import android.support.annotation.Nullable;
import android.support.customtabs.CustomTabsIntent;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
@ -109,7 +107,7 @@ public class LinkHelper {
customSpan = new CustomURLSpan(span.getURL()) {
@Override
public void onClick(View widget) {
listener.onViewURL(getURL());
listener.onViewUrl(getURL());
}
};
}

View File

@ -232,4 +232,6 @@
android:contentDescription="@string/action_mention"
app:srcCompat="@drawable/ic_create_24dp" />
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -17,4 +17,6 @@
<include layout="@layout/toolbar_shadow_shim" />
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -90,4 +90,6 @@
app:layout_anchorGravity="bottom|end"
app:srcCompat="@drawable/ic_create_24dp" />
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -1,27 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_view_thread"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.ModalTimelineActivity">
<include layout="@layout/toolbar_basic" />
<include
layout="@layout/toolbar_shadow_shim"
android:layout_width="0dp"
android:layout_height="4dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar" />
<FrameLayout
android:id="@+id/content_frame"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/toolbar_shadow_shim"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</android.support.constraint.ConstraintLayout>
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<include layout="@layout/toolbar_shadow_shim" />
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -31,4 +31,6 @@
<include layout="@layout/toolbar_shadow_shim" />
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -17,4 +17,6 @@
<include layout="@layout/toolbar_shadow_shim" />
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -17,4 +17,6 @@
<include layout="@layout/toolbar_shadow_shim" />
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>

View File

@ -26,6 +26,4 @@
android:text="@string/search_no_results"
android:visibility="gone" />
<include layout="@layout/item_status_bottom_sheet" />
</android.support.design.widget.CoordinatorLayout>

View File

@ -1,19 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<android.support.v4.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_width="match_parent">
<android.support.v4.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="top">
android:layout_gravity="top">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>
<include layout="@layout/item_status_bottom_sheet"/>
</android.support.design.widget.CoordinatorLayout>
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>

View File

@ -1,19 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<android.support.v4.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_width="match_parent">
<android.support.v4.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
android:layout_gravity="top">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="top">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</android.support.v4.widget.SwipeRefreshLayout>
<include layout="@layout/item_status_bottom_sheet" />
</android.support.design.widget.CoordinatorLayout>
android:scrollbars="vertical" />
</android.support.v4.widget.SwipeRefreshLayout>

View File

@ -13,14 +13,15 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.fragment
package com.keylesspalace.tusky
import android.support.design.widget.BottomSheetBehavior
import android.text.SpannedString
import android.widget.LinearLayout
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.SearchResults
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases
import okhttp3.Request
import org.junit.Assert
import org.junit.Before
@ -35,8 +36,8 @@ import retrofit2.Callback
import retrofit2.Response
import java.util.*
class SFragmentTest {
private lateinit var fragment : FakeSFragment
class BottomSheetActivityTest {
private lateinit var activity : FakeBottomSheetActivity
private lateinit var apiMock: MastodonApi
private val accountQuery = "http://mastodon.foo.bar/@User"
private val statusQuery = "http://mastodon.foo.bar/@User/345678"
@ -85,13 +86,12 @@ class SFragmentTest {
@Before
fun setup() {
fragment = FakeSFragment()
apiMock = Mockito.mock(MastodonApi::class.java)
`when`(apiMock.search(eq(accountQuery), ArgumentMatchers.anyBoolean())).thenReturn(accountCallback)
`when`(apiMock.search(eq(statusQuery), ArgumentMatchers.anyBoolean())).thenReturn(statusCallback)
`when`(apiMock.search(eq(nonMastodonQuery), ArgumentMatchers.anyBoolean())).thenReturn(emptyCallback)
fragment.mastodonApi = apiMock
activity = FakeBottomSheetActivity(apiMock)
}
@RunWith(Parameterized::class)
@ -131,22 +131,22 @@ class SFragmentTest {
@Test
fun test() {
Assert.assertEquals(expectedResult, SFragment.looksLikeMastodonUrl(url))
Assert.assertEquals(expectedResult, looksLikeMastodonUrl(url))
}
}
@Test
fun beginEndSearch_setIsSearching_isSearchingAfterBegin() {
fragment.onBeginSearch("https://mastodon.foo.bar/@User")
Assert.assertTrue(fragment.isSearching)
activity.onBeginSearch("https://mastodon.foo.bar/@User")
Assert.assertTrue(activity.isSearching())
}
@Test
fun beginEndSearch_setIsSearching_isNotSearchingAfterEnd() {
val validUrl = "https://mastodon.foo.bar/@User"
fragment.onBeginSearch(validUrl)
fragment.onEndSearch(validUrl)
Assert.assertFalse(fragment.isSearching)
activity.onBeginSearch(validUrl)
activity.onEndSearch(validUrl)
Assert.assertFalse(activity.isSearching())
}
@Test
@ -154,18 +154,18 @@ class SFragmentTest {
val validUrl = "https://mastodon.foo.bar/@User"
val invalidUrl = ""
fragment.onBeginSearch(validUrl)
fragment.onEndSearch(invalidUrl)
Assert.assertTrue(fragment.isSearching)
activity.onBeginSearch(validUrl)
activity.onEndSearch(invalidUrl)
Assert.assertTrue(activity.isSearching())
}
@Test
fun cancelActiveSearch() {
val url = "https://mastodon.foo.bar/@User"
fragment.onBeginSearch(url)
fragment.cancelActiveSearch()
Assert.assertFalse(fragment.isSearching)
activity.onBeginSearch(url)
activity.cancelActiveSearch()
Assert.assertFalse(activity.isSearching())
}
@Test
@ -173,85 +173,84 @@ class SFragmentTest {
val firstUrl = "https://mastodon.foo.bar/@User"
val secondUrl = "https://mastodon.foo.bar/@meh"
fragment.onBeginSearch(firstUrl)
fragment.cancelActiveSearch()
activity.onBeginSearch(firstUrl)
activity.cancelActiveSearch()
fragment.onBeginSearch(secondUrl)
Assert.assertTrue(fragment.getCancelSearchRequested(firstUrl))
Assert.assertFalse(fragment.getCancelSearchRequested(secondUrl))
activity.onBeginSearch(secondUrl)
Assert.assertTrue(activity.getCancelSearchRequested(firstUrl))
Assert.assertFalse(activity.getCancelSearchRequested(secondUrl))
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forAccount() {
fragment.onViewURL(accountQuery)
activity.viewUrl(accountQuery)
accountCallback.invokeCallback()
Assert.assertEquals(account.id, fragment.accountId)
Assert.assertEquals(account.id, activity.accountId)
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forStatus() {
fragment.onViewURL(statusQuery)
activity.viewUrl(statusQuery)
statusCallback.invokeCallback()
Assert.assertEquals(status, fragment.status)
Assert.assertEquals(status, activity.status)
}
@Test
fun search_inIdealConditions_returnsRequestedResults_forNonMastodonURL() {
fragment.onViewURL(nonMastodonQuery)
activity.viewUrl(nonMastodonQuery)
emptyCallback.invokeCallback()
Assert.assertEquals(nonMastodonQuery, fragment.url)
Assert.assertEquals(nonMastodonQuery, activity.link)
}
@Test
fun search_withCancellation_doesNotLoadUrl_forAccount() {
fragment.onViewURL(accountQuery)
Assert.assertTrue(fragment.isSearching)
fragment.cancelActiveSearch()
Assert.assertFalse(fragment.isSearching)
activity.viewUrl(accountQuery)
Assert.assertTrue(activity.isSearching())
activity.cancelActiveSearch()
Assert.assertFalse(activity.isSearching())
accountCallback.invokeCallback()
Assert.assertEquals(null, fragment.accountId)
Assert.assertEquals(null, activity.accountId)
}
@Test
fun search_withCancellation_doesNotLoadUrl_forStatus() {
fragment.onViewURL(accountQuery)
fragment.cancelActiveSearch()
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
accountCallback.invokeCallback()
Assert.assertEquals(null, fragment.accountId)
Assert.assertEquals(null, activity.accountId)
}
@Test
fun search_withCancellation_doesNotLoadUrl_forNonMastodonURL() {
fragment.onViewURL(nonMastodonQuery)
fragment.cancelActiveSearch()
activity.viewUrl(nonMastodonQuery)
activity.cancelActiveSearch()
emptyCallback.invokeCallback()
Assert.assertEquals(null, fragment.url)
Assert.assertEquals(null, activity.searchUrl)
}
@Test
fun search_withPreviousCancellation_completes() {
// begin/cancel account search
fragment.onViewURL(accountQuery)
fragment.cancelActiveSearch()
activity.viewUrl(accountQuery)
activity.cancelActiveSearch()
// begin status search
fragment.onViewURL(statusQuery)
activity.viewUrl(statusQuery)
// return response from account search
accountCallback.invokeCallback()
// ensure that status search is still ongoing
Assert.assertTrue(fragment.isSearching)
Assert.assertTrue(activity.isSearching())
statusCallback.invokeCallback()
// ensure that the result of the status search was recorded
// and the account search wasn't
Assert.assertEquals(status, fragment.status)
Assert.assertEquals(null, fragment.accountId)
Assert.assertEquals(status, activity.status)
Assert.assertEquals(null, activity.accountId)
}
class FakeSearchResults : Call<SearchResults>
{
class FakeSearchResults : Call<SearchResults> {
private var searchResults: SearchResults
private var callback: Callback<SearchResults>? = null
@ -283,29 +282,33 @@ class SFragmentTest {
override fun request(): Request { throw NotImplementedError() }
}
class FakeSFragment : SFragment() {
class FakeBottomSheetActivity(val api: MastodonApi) : BottomSheetActivity() {
var status: Status? = null
var accountId: String? = null
var url: String? = null
var link: String? = null
init {
callList = mutableListOf()
@Suppress("UNCHECKED_CAST")
bottomSheet = Mockito.mock(BottomSheetBehavior::class.java) as BottomSheetBehavior<LinearLayout>
callList = arrayListOf()
}
override fun getMastodonApi(): MastodonApi {
return api
}
override fun openLink(url: String) {
this.url = url
this.link = url
}
override fun viewAccount(id: String?) {
accountId = id
override fun viewAccount(id: String) {
this.accountId = id
}
override fun viewThread(status: Status?) {
override fun viewThread(status: Status) {
this.status = status
}
override fun removeItem(position: Int) { throw NotImplementedError() }
override fun removeAllByAccountId(accountId: String?) { throw NotImplementedError() }
override fun timelineCases(): TimelineCases { throw NotImplementedError() }
}
}