diff --git a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java index 62faec3c..077d54f5 100644 --- a/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java +++ b/5calls/app/src/androidTest/java/org/a5calls/android/a5calls/controller/MainActivityHappyPathTest.java @@ -43,6 +43,7 @@ import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertTrue; /** @@ -51,6 +52,31 @@ @RunWith(AndroidJUnit4.class) public class MainActivityHappyPathTest extends MainActivityBaseTest { + // Custom matcher that matches only the first view matching the given matcher. + public static Matcher first(final Matcher matcher) { + return new TypeSafeMatcher() { + boolean matched = false; + + @Override + public boolean matchesSafely(View view) { + if (matched) { + return false; + } + if (matcher.matches(view)) { + matched = true; + return true; + } + return false; + } + + @Override + public void describeTo(Description description) { + description.appendText("first view matching: "); + matcher.describeTo(description); + } + }; + } + // Custom matcher to check if a CollapsingToolbarLayout's title contains specific text public static Matcher withCollapsingToolbarTitle(final Matcher textMatcher) { return new TypeSafeMatcher() { @@ -368,4 +394,36 @@ public void MainActivity_ShowsTutorialOnce() throws InterruptedException { // Put the address back. AccountManager.Instance.setAddress(context, address); } + + @Test + public void testBookmarkToggle() { + setupMockResponses(/*isSplit=*/false, /*hasLocation=*/true); + setupMockRequestQueue(); + launchMainActivity(1000); + + // Tap the first bookmark icon to bookmark the issue. + onView(first(allOf(withId(R.id.bookmark_icon), + withContentDescription(R.string.bookmark_issue), + isDisplayed()))) + .perform(click()); + + // Verify it changed to the "bookmarked" state. + onView(first(allOf(withId(R.id.bookmark_icon), + withContentDescription(R.string.remove_bookmark), + isDisplayed()))) + .check(matches(isDisplayed())); + + // Tap again to un-bookmark. + onView(first(allOf(withId(R.id.bookmark_icon), + withContentDescription(R.string.remove_bookmark), + isDisplayed()))) + .perform(click()); + + // Verify it returned to the "not bookmarked" state — all icons should + // be back to "Bookmark issue" since only the first was toggled. + onView(first(allOf(withId(R.id.bookmark_icon), + withContentDescription(R.string.bookmark_issue), + isDisplayed()))) + .check(matches(isDisplayed())); + } } \ No newline at end of file diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/FilterAdapter.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/FilterAdapter.java new file mode 100644 index 00000000..7d98ca80 --- /dev/null +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/FilterAdapter.java @@ -0,0 +1,116 @@ +package org.a5calls.android.a5calls.adapter; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import org.a5calls.android.a5calls.R; + +import java.util.ArrayList; +import java.util.List; + +/** + * Adapter for the filter dropdown that shows hard-coded filters (All issues, Top issues, Saved) + * followed by a divider, then dynamic category/state filters. + */ +public class FilterAdapter extends BaseAdapter { + private static final int VIEW_TYPE_NORMAL = 0; + private static final int VIEW_TYPE_DIVIDER = 1; + + /** Number of hard-coded filter items before the divider. */ + public static final int HARD_CODED_COUNT = 3; + + private final Context mContext; + private final List mItems; + + public FilterAdapter(Context context, List items) { + mContext = context; + mItems = items; + } + + @Override + public int getCount() { + if (mItems.size() > HARD_CODED_COUNT) { + return mItems.size() + 1; // +1 for the divider + } + return mItems.size(); + } + + @Override + public Object getItem(int position) { + if (position < HARD_CODED_COUNT) { + return mItems.get(position); + } + if (position == HARD_CODED_COUNT && mItems.size() > HARD_CODED_COUNT) { + return null; // divider + } + return mItems.get(position - 1); // offset by divider + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getViewTypeCount() { + return 2; + } + + @Override + public int getItemViewType(int position) { + if (position == HARD_CODED_COUNT && mItems.size() > HARD_CODED_COUNT) { + return VIEW_TYPE_DIVIDER; + } + return VIEW_TYPE_NORMAL; + } + + @Override + public boolean isEnabled(int position) { + return getItemViewType(position) != VIEW_TYPE_DIVIDER; + } + + @Override + public boolean areAllItemsEnabled() { + return false; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + if (getItemViewType(position) == VIEW_TYPE_DIVIDER) { + if (convertView == null) { + convertView = LayoutInflater.from(mContext) + .inflate(R.layout.filter_divider_item, parent, false); + } + return convertView; + } + + if (convertView == null || convertView.getId() == R.id.filter_divider) { + convertView = LayoutInflater.from(mContext) + .inflate(R.layout.filter_list_item, parent, false); + } + String text = getFilterText(position); + ((TextView) convertView).setText(text); + return convertView; + } + + /** + * Returns the actual filter text for a given adapter position, accounting for the divider. + */ + public String getFilterText(int position) { + if (position < HARD_CODED_COUNT) { + return mItems.get(position); + } + if (position == HARD_CODED_COUNT && mItems.size() > HARD_CODED_COUNT) { + return null; // divider + } + return mItems.get(position - 1); + } + + public void notifyItemsChanged() { + notifyDataSetChanged(); + } +} diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/IssuesAdapter.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/IssuesAdapter.java index 5059d1e2..029d142b 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/IssuesAdapter.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/adapter/IssuesAdapter.java @@ -8,6 +8,7 @@ import android.view.View; import android.view.ViewGroup; import android.widget.Button; +import android.widget.ImageView; import android.widget.TextView; import org.a5calls.android.a5calls.AppSingleton; @@ -20,7 +21,9 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.regex.Pattern; import androidx.annotation.NonNull; @@ -36,17 +39,23 @@ public class IssuesAdapter extends RecyclerView.Adapter public static final int ERROR_ADDRESS = 12; public static final int NO_ISSUES_YET = 13; public static final int ERROR_SEARCH_NO_MATCH = 14; + public static final int ERROR_BOOKMARKS_EMPTY = 15; private static final int VIEW_TYPE_EMPTY_REQUEST = 0; private static final int VIEW_TYPE_ISSUE = 1; private static final int VIEW_TYPE_EMPTY_ADDRESS = 2; private static final int VIEW_TYPE_NO_SEARCH_MATCH = 3; + private static final int VIEW_TYPE_EMPTY_BOOKMARKS = 4; private List mIssues = new ArrayList<>(); private List mAllIssues = new ArrayList<>(); private boolean mIsSplitDistrict = false; private int mErrorType = NO_ISSUES_YET; private int mAddressErrorType = NO_ISSUES_YET; + private String mLastFilterText = ""; + private String mLastSearchText = ""; + + private Set mBookmarkedIds = new HashSet<>(); private List mContacts = new ArrayList<>(); private final Activity mActivity; @@ -61,6 +70,8 @@ public interface Callback { void launchSearchDialog(); void startIssueActivity(Context context, Issue issue); + + void onBookmarkToggled(String issueId, boolean isNowBookmarked); } public IssuesAdapter(Activity activity, Callback callback) { @@ -108,32 +119,50 @@ public void setContacts(List contacts, boolean isSplitDistrict, int err } } + public void setBookmarkedIds(Set ids) { + mBookmarkedIds = ids; + notifyDataSetChanged(); + } + + public void onBookmarksChanged() { + if (TextUtils.equals(mLastFilterText, + mActivity.getResources().getString(R.string.bookmarked_issues_filter))) { + setFilterAndSearch(mLastFilterText, mLastSearchText); + } + } + public void setFilterAndSearch(String filterText, String searchText) { - if (mErrorType == ERROR_SEARCH_NO_MATCH) { - // If we previously had a search error, reset it: this is a new + mLastFilterText = filterText; + mLastSearchText = searchText; + if (mErrorType == ERROR_SEARCH_NO_MATCH || mErrorType == ERROR_BOOKMARKS_EMPTY) { + // If we previously had a search or bookmarks error, reset it: this is a new // filter or search. mErrorType = NO_ERROR; } + + List filtered; if (!TextUtils.isEmpty(searchText)) { - mIssues = sortIssuesWithMetaPriority(filterIssuesBySearchText(searchText, mAllIssues)); - // If there's no other error, show a search error. - if (mIssues.isEmpty() && mErrorType == NO_ERROR) { + filtered = filterIssuesBySearchText(searchText, mAllIssues); + if (filtered.isEmpty() && mErrorType == NO_ERROR) { mErrorType = ERROR_SEARCH_NO_MATCH; } - } else { - // Search text is empty. - if (TextUtils.equals(filterText, - mActivity.getResources().getString(R.string.all_issues_filter))) { - // Include everything - mIssues = sortIssuesWithMetaPriority(mAllIssues); - } else if (TextUtils.equals(filterText, - mActivity.getResources().getString(R.string.top_issues_filter))) { - mIssues = sortIssuesWithMetaPriority(filterActiveIssues()); - } else { - // Filter by the category string. - mIssues = sortIssuesWithMetaPriority(filterIssuesByCategory(mAllIssues, filterText)); + } else if (TextUtils.equals(filterText, + mActivity.getResources().getString(R.string.all_issues_filter))) { + filtered = mAllIssues; + } else if (TextUtils.equals(filterText, + mActivity.getResources().getString(R.string.top_issues_filter))) { + filtered = filterActiveIssues(); + } else if (TextUtils.equals(filterText, + mActivity.getResources().getString(R.string.bookmarked_issues_filter))) { + filtered = filterBookmarkedIssues(mAllIssues, mBookmarkedIds); + if (filtered.isEmpty() && mErrorType == NO_ERROR) { + mErrorType = ERROR_BOOKMARKS_EMPTY; } + } else { + filtered = filterIssuesByCategory(mAllIssues, filterText); } + + mIssues = sortIssuesWithMetaPriority(filtered); notifyDataSetChanged(); } @@ -213,6 +242,17 @@ private ArrayList filterActiveIssues() { return tempIssues; } + @VisibleForTesting + public static ArrayList filterBookmarkedIssues(List issues, Set bookmarkedIds) { + ArrayList result = new ArrayList<>(); + for (Issue issue : issues) { + if (bookmarkedIds.contains(issue.id)) { + result.add(issue); + } + } + return result; + } + @VisibleForTesting public static ArrayList filterIssuesByCategory(List issues, String activeCategory) { ArrayList tempIssues = new ArrayList<>(); @@ -222,6 +262,9 @@ public static ArrayList filterIssuesByCategory(List issues, String tempIssues.add(issue); continue; } + if (issue.categories == null) { + continue; + } for (Category category : issue.categories) { if (TextUtils.equals(activeCategory, category.name)) { tempIssues.add(issue); @@ -306,6 +349,10 @@ public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int View empty = LayoutInflater.from(parent.getContext()).inflate( R.layout.empty_issues_search_view, parent, false); return new EmptySearchViewHolder(empty); + } else if (viewType == VIEW_TYPE_EMPTY_BOOKMARKS) { + View empty = LayoutInflater.from(parent.getContext()).inflate( + R.layout.empty_bookmarks_view, parent, false); + return new EmptyBookmarksViewHolder(empty); } else { ConstraintLayout v = (ConstraintLayout) LayoutInflater.from(parent.getContext()) .inflate(R.layout.issue_view, parent, false); @@ -330,6 +377,38 @@ public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int vh.stateIndicator.setVisibility(View.GONE); } + // Keep bookmark icon INVISIBLE (not GONE) on placeholder/demo issues + // so it still reserves space for consistent row height. + if (issue.isPlaceholder) { + vh.bookmarkIcon.setVisibility(View.INVISIBLE); + vh.bookmarkIcon.setClickable(false); + } else { + vh.bookmarkIcon.setVisibility(View.VISIBLE); + // Set bookmark icon state. + boolean isBookmarked = mBookmarkedIds.contains(issue.id); + vh.bookmarkIcon.setImageResource(isBookmarked ? + R.drawable.bookmark_filled_24 : R.drawable.bookmark_outline_24); + vh.bookmarkIcon.setContentDescription(mActivity.getResources().getString( + isBookmarked ? R.string.remove_bookmark : R.string.bookmark_issue)); + vh.bookmarkIcon.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + boolean wasBookmarked = mBookmarkedIds.contains(issue.id); + if (wasBookmarked) { + mBookmarkedIds.remove(issue.id); + } else { + mBookmarkedIds.add(issue.id); + } + boolean nowBookmarked = !wasBookmarked; + vh.bookmarkIcon.setImageResource(nowBookmarked ? + R.drawable.bookmark_filled_24 : R.drawable.bookmark_outline_24); + vh.bookmarkIcon.setContentDescription(mActivity.getResources().getString( + nowBookmarked ? R.string.remove_bookmark : R.string.bookmark_issue)); + mCallback.onBookmarkToggled(issue.id, nowBookmarked); + } + }); + } + vh.itemView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { @@ -353,7 +432,7 @@ public void onClick(View v) { } if (mAddressErrorType != NO_ERROR) { - // If there was an address error, clear the number of calls to make. + // If there was an address error, clear the number of calls to make vh.numCalls.setText(""); vh.numCalls.setVisibility(View.GONE); vh.previousCallStats.setVisibility(View.GONE); @@ -404,6 +483,7 @@ public void onClick(View v) { public void onViewRecycled(RecyclerView.ViewHolder holder) { if (holder instanceof IssueViewHolder) { holder.itemView.setOnClickListener(null); + ((IssueViewHolder) holder).bookmarkIcon.setOnClickListener(null); } else if (holder instanceof EmptyRequestViewHolder) { ((EmptyRequestViewHolder) holder).refreshButton.setOnClickListener(null); } else if (holder instanceof EmptyAddressViewHolder) { @@ -416,7 +496,8 @@ public void onViewRecycled(RecyclerView.ViewHolder holder) { @Override public int getItemCount() { - if (mErrorType == ERROR_REQUEST || mErrorType == ERROR_SEARCH_NO_MATCH) { + if (mErrorType == ERROR_REQUEST || mErrorType == ERROR_SEARCH_NO_MATCH || + mErrorType == ERROR_BOOKMARKS_EMPTY) { // For these special types of errors, we will hide the issues. return 1; } @@ -432,6 +513,9 @@ public int getItemViewType(int position) { if (mErrorType == ERROR_SEARCH_NO_MATCH) { return VIEW_TYPE_NO_SEARCH_MATCH; } + if (mErrorType == ERROR_BOOKMARKS_EMPTY) { + return VIEW_TYPE_EMPTY_BOOKMARKS; + } } return VIEW_TYPE_ISSUE; } @@ -492,6 +576,7 @@ private static class IssueViewHolder extends RecyclerView.ViewHolder { public TextView numCalls; public TextView previousCallStats; public TextView stateIndicator; + public ImageView bookmarkIcon; public IssueViewHolder(View itemView) { super(itemView); @@ -499,6 +584,7 @@ public IssueViewHolder(View itemView) { numCalls = (TextView) itemView.findViewById(R.id.issue_call_count); previousCallStats = (TextView) itemView.findViewById(R.id.previous_call_stats); stateIndicator = (TextView) itemView.findViewById(R.id.state_indicator); + bookmarkIcon = (ImageView) itemView.findViewById(R.id.bookmark_icon); } } @@ -542,6 +628,12 @@ public EmptySearchViewHolder(View itemView) { } } +private static class EmptyBookmarksViewHolder extends RecyclerView.ViewHolder { + public EmptyBookmarksViewHolder(View itemView) { + super(itemView); + } +} + /** * Sorts a list of issues to prioritize those with meta values (state abbreviations) at the top, * then sorts the remaining issues. Both groups maintain their internal sort order. Placeholder diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/IssueActivity.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/IssueActivity.java index 82031e90..6278593e 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/IssueActivity.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/IssueActivity.java @@ -76,6 +76,7 @@ public class IssueActivity extends AppCompatActivity implements FiveCallsApi.Scr public static final String KEY_IS_DISTRICT_SPLIT = "key_is_district_split"; public static final String KEY_IS_LOW_ACCURACY = "key_is_low_accuracy"; public static final String KEY_DONATE_IS_ON = "key_donate_is_on"; + public static final String KEY_IS_BOOKMARKED = "key_is_bookmarked"; public static final int RESULT_OK = 1; public static final int RESULT_SERVER_ERROR = 2; @@ -96,6 +97,7 @@ public class IssueActivity extends AppCompatActivity implements FiveCallsApi.Scr // low accuracy locations are zip codes or city names, we warn on state reps if you are using one private boolean mIsLowAccuracy = false; private boolean mDonateIsOn = false; + private boolean mIsBookmarked = false; private boolean mIsAnimating = false; private ActivityIssueBinding binding; @@ -184,6 +186,7 @@ public void onContactsReceived(String locationName, String districtId, boolean i mIsDistrictSplit = getIntent().getBooleanExtra(KEY_IS_DISTRICT_SPLIT, false); mIsLowAccuracy = getIntent().getBooleanExtra(KEY_IS_LOW_ACCURACY, false); mDonateIsOn = getIntent().getBooleanExtra(KEY_DONATE_IS_ON, false); + mIsBookmarked = getIntent().getBooleanExtra(KEY_IS_BOOKMARKED, false); mLocationName = getIntent().getStringExtra(RepCallActivity.KEY_LOCATION_NAME); setContentView(binding.getRoot()); @@ -236,6 +239,27 @@ public void onContactsReceived(String locationName, String districtId, boolean i } binding.issueName.setText(mIssue.name); + if (mIssue.isPlaceholder) { + binding.bookmarkIcon.setVisibility(View.GONE); + } else { + updateBookmarkIcon(); + binding.bookmarkIcon.setOnClickListener(v -> { + mIsBookmarked = !mIsBookmarked; + DatabaseHelper dbHelper = AppSingleton.getInstance(getApplicationContext()) + .getDatabaseHelper(); + if (mIsBookmarked) { + dbHelper.addBookmark(mIssue.id); + } else { + dbHelper.removeBookmark(mIssue.id); + } + updateBookmarkIcon(); + if (mIssue.permalink != null) { + FiveCallsApplication.analyticsManager().trackBookmark( + mIssue.permalink, mIsBookmarked, this); + } + }); + } + MarkdownUtil.setUpScript(binding.issueDescription, mIssue.reason, getApplicationContext()); if (!TextUtils.isEmpty(mIssue.link)) { binding.link.setVisibility(View.VISIBLE); @@ -339,6 +363,7 @@ protected void onSaveInstanceState(Bundle outState) { outState.putParcelable(KEY_ISSUE, mIssue); outState.putBoolean(KEY_IS_DISTRICT_SPLIT, mIsDistrictSplit); outState.putBoolean(KEY_IS_LOW_ACCURACY, mIsLowAccuracy); + outState.putBoolean(KEY_IS_BOOKMARKED, mIsBookmarked); } @Override @@ -671,6 +696,13 @@ private void maybeShowIssueDone() { } } + private void updateBookmarkIcon() { + binding.bookmarkIcon.setImageResource(mIsBookmarked ? + R.drawable.bookmark_filled_24 : R.drawable.bookmark_outline_24); + binding.bookmarkIcon.setContentDescription(getResources().getString( + mIsBookmarked ? R.string.remove_bookmark : R.string.bookmark_issue)); + } + private void showIssueDetails() { new AlertDialog.Builder(IssueActivity.this) .setTitle(R.string.details_btn) diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java index 3b807f39..c8281ba2 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/MainActivity.java @@ -33,8 +33,7 @@ import android.view.MenuItem; import android.view.View; import android.view.ViewTreeObserver; -import android.widget.AdapterView; -import android.widget.ArrayAdapter; +import android.widget.ListPopupWindow; import android.widget.TextView; import com.google.firebase.auth.FirebaseAuth; @@ -43,20 +42,24 @@ import org.a5calls.android.a5calls.BuildConfig; import org.a5calls.android.a5calls.FiveCallsApplication; import org.a5calls.android.a5calls.R; +import org.a5calls.android.a5calls.adapter.FilterAdapter; import org.a5calls.android.a5calls.adapter.IssuesAdapter; import org.a5calls.android.a5calls.databinding.ActivityMainBinding; import org.a5calls.android.a5calls.model.AccountManager; import org.a5calls.android.a5calls.model.Category; import org.a5calls.android.a5calls.model.Contact; +import org.a5calls.android.a5calls.model.DatabaseHelper; import org.a5calls.android.a5calls.net.FiveCallsApi; import org.a5calls.android.a5calls.model.Issue; import org.a5calls.android.a5calls.util.CustomTabsUtil; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import static android.view.View.VISIBLE; @@ -79,7 +82,8 @@ public class MainActivity extends AppCompatActivity implements IssuesAdapter.Cal private String mPendingDeepLinkPath = null; - private ArrayAdapter mFilterAdapter; + private FilterAdapter mFilterAdapter; + private final List mFilterItems = new ArrayList<>(); private String mFilterText = ""; private String mSearchText = ""; private IssuesAdapter mIssuesAdapter; @@ -148,9 +152,9 @@ protected void onCreate(Bundle savedInstanceState) { WindowInsetsCompat.Type.displayCutout()); binding.appbar.setPadding(insets.left, insets.top, insets.right, 0); binding.issuesRecyclerView.setPadding(insets.left, 0, insets.right, insets.bottom); - final int paddingSpinner = getResources().getDimensionPixelSize(R.dimen.padding_spinner); - binding.filter.setPadding(paddingSpinner + insets.left, 0, - paddingSpinner + insets.right, 0); + binding.filterBar.setPadding( + binding.filterBar.getPaddingStart() + insets.left, binding.filterBar.getPaddingTop(), + binding.filterBar.getPaddingEnd() + insets.right, binding.filterBar.getPaddingBottom()); binding.searchBar.setPadding(insets.left, 0, insets.right, 0); final int sidePadding = getResources().getDimensionPixelSize( R.dimen.activity_horizontal_margin); @@ -219,26 +223,34 @@ public void onError() { mIssuesAdapter = new IssuesAdapter(this, this); binding.issuesRecyclerView.setAdapter(mIssuesAdapter); - mFilterAdapter = new ArrayAdapter<>(this, R.layout.filter_item); - mFilterAdapter.setDropDownViewResource(R.layout.filter_list_item); - mFilterAdapter.addAll(getResources().getStringArray(R.array.default_filters)); - binding.filter.setAdapter(mFilterAdapter); + // Load bookmarked issues from database. + loadBookmarks(); + + // Initialize filter items with defaults. + String[] defaultFilters = getResources().getStringArray(R.array.default_filters); + Collections.addAll(mFilterItems, defaultFilters); + mFilterAdapter = new FilterAdapter(this, mFilterItems); + if (savedInstanceState != null) { mFilterText = savedInstanceState.getString(KEY_FILTER_ITEM_SELECTED); mSearchText = savedInstanceState.getString(KEY_SEARCH_TEXT); mShowLowAccuracyWarning = savedInstanceState.getBoolean(KEY_SHOW_LOW_ACCURACY_WARNING); if (TextUtils.isEmpty(mSearchText)) { binding.searchBar.setVisibility(View.GONE); - binding.filter.setVisibility(VISIBLE); + binding.filterBar.setVisibility(VISIBLE); } else { binding.searchBar.setVisibility(VISIBLE); - binding.filter.setVisibility(View.GONE); + binding.filterBar.setVisibility(View.GONE); binding.searchText.setText(mSearchText); } } else { - // Safe to use index as the top two filters are hard-coded strings. - mFilterText = mFilterAdapter.getItem(0); + mFilterText = mFilterItems.get(0); } + updateFilterButtonText(); + + // Show filter popup when filter button is tapped. + binding.filter.setOnClickListener(v -> showFilterPopup()); + binding.searchText.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { @@ -306,11 +318,11 @@ public void onGlobalLayout() { int supportActionBarHeight = getSupportActionBar() != null ? getSupportActionBar().getHeight() : 0; int searchHeight = binding.searchBar.getHeight(); - int filterHeight = binding.filter.getHeight(); + int filterHeight = binding.filterBar.getHeight(); binding.swipeContainer.getLayoutParams().height = (int) (getResources().getConfiguration().screenHeightDp * displayMetrics.density - searchHeight - filterHeight - supportActionBarHeight); - binding.filter.getViewTreeObserver().removeOnGlobalLayoutListener(this); + binding.drawerLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this); } }); @@ -389,6 +401,9 @@ public void startIssueActivity(Context context, Issue issue) { issueIntent.putExtra(IssueActivity.KEY_IS_DISTRICT_SPLIT, mIsDistrictSplit); issueIntent.putExtra(IssueActivity.KEY_IS_LOW_ACCURACY, mIsLowAccuracy); issueIntent.putExtra(IssueActivity.KEY_DONATE_IS_ON, mDonateIsOn); + DatabaseHelper dbHelper = AppSingleton.getInstance(getApplicationContext()) + .getDatabaseHelper(); + issueIntent.putExtra(IssueActivity.KEY_IS_BOOKMARKED, dbHelper.isBookmarked(issue.id)); startActivityForResult(issueIntent, ISSUE_DETAIL_REQUEST); } @@ -464,6 +479,7 @@ public void onJsonError() { @Override public void onIssuesReceived(List issues) { populateFilterAdapterIfNeeded(issues); + loadBookmarks(); maybeAddPlaceholderIssue(issues); mIssuesAdapter.setAllIssues(issues, IssuesAdapter.NO_ERROR); @@ -589,7 +605,9 @@ private void registerOnBackPressedCallback() { @Override public void handleOnBackPressed() { // Clear the filter, if there was one. - binding.filter.setSelection(0); + mFilterText = mFilterItems.get(0); + updateFilterButtonText(); + mIssuesAdapter.setFilterAndSearch(mFilterText, mSearchText); // Clear the search, if there was one. onIssueSearchSet(""); // The calls above will disable this callback, so no need @@ -602,13 +620,13 @@ public void handleOnBackPressed() { // Should be called whenever filter or search state changes. private void updateOnBackPressedCallbackEnabled() { - boolean isFiltering = !Objects.equals(mFilterText, mFilterAdapter.getItem(0)); + boolean isFiltering = !mFilterItems.isEmpty() && !Objects.equals(mFilterText, mFilterItems.get(0)); boolean isSearching = !TextUtils.isEmpty(mSearchText); mOnBackPressedCallback.setEnabled(isFiltering || isSearching); } private void populateFilterAdapterIfNeeded(List issues) { - if (mFilterAdapter.getCount() > 2) { + if (mFilterItems.size() > FilterAdapter.HARD_CODED_COUNT) { // Already populated. Don't try again. // This assumes that the categories won't change much during the course of a session. return; @@ -630,31 +648,73 @@ private void populateFilterAdapterIfNeeded(List issues) { } } Collections.sort(topics); - mFilterAdapter.addAll(topics); - binding.filter.setSelection(mFilterAdapter.getPosition(mFilterText)); - // Set this listener after manually setting the selection so it isn't fired right away. - binding.filter.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView adapterView, View view, int i, long l) { - String newFilter = mFilterAdapter.getItem(i); - if (TextUtils.equals(newFilter, mFilterText)) { - // Already set! - return; - } + mFilterItems.addAll(topics); + mFilterAdapter.notifyItemsChanged(); + updateFilterButtonText(); + } + + private void showFilterPopup() { + ListPopupWindow popup = new ListPopupWindow(this); + popup.setAnchorView(binding.filter); + popup.setAdapter(mFilterAdapter); + popup.setContentWidth(measurePopupWidthPx()); + popup.setModal(true); + popup.setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED); + popup.setVerticalOffset(0); + popup.setBackgroundDrawable(new android.graphics.drawable.ColorDrawable( + android.graphics.Color.WHITE)); + // Constrain popup height to available space below the anchor. + // Without this, WRAP_CONTENT can exceed available space, causing + // Android to reposition the popup above the anchor. + { + android.graphics.Rect vf = new android.graphics.Rect(); + binding.filter.getWindowVisibleDisplayFrame(vf); + int[] loc = new int[2]; + binding.filter.getLocationOnScreen(loc); + popup.setHeight(vf.bottom - loc[1] - binding.filter.getHeight()); + } + popup.setOnItemClickListener((parent, view, position, id) -> { + String newFilter = mFilterAdapter.getFilterText(position); + if (newFilter == null) { + return; // divider + } + if (!TextUtils.equals(newFilter, mFilterText)) { mFilterText = newFilter; + updateFilterButtonText(); updateOnBackPressedCallbackEnabled(); - if (binding.swipeContainer.isRefreshing()) { - // Already loading issues! - return; + if (!binding.swipeContainer.isRefreshing()) { + mIssuesAdapter.setFilterAndSearch(mFilterText, mSearchText); } - mIssuesAdapter.setFilterAndSearch(mFilterText, mSearchText); } + popup.dismiss(); + }); + popup.show(); + } - @Override - public void onNothingSelected(AdapterView adapterView) { - + private int measurePopupWidthPx() { + int maxWidthPx = 0; + View measureView = null; + int widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + for (int i = 0; i < mFilterAdapter.getCount(); i++) { + if (!mFilterAdapter.isEnabled(i)) { + continue; // skip divider } - }); + measureView = mFilterAdapter.getView(i, measureView, binding.filterBar); + measureView.measure(widthSpec, heightSpec); + maxWidthPx = Math.max(maxWidthPx, measureView.getMeasuredWidth()); + } + return maxWidthPx; + } + + + private void updateFilterButtonText() { + if (TextUtils.isEmpty(mFilterText) || + TextUtils.equals(mFilterText, mFilterItems.get(0))) { + binding.filter.setText(getString(R.string.menu_filter)); + } else { + binding.filter.setText(mFilterText); + } } private void loadStats() { @@ -673,6 +733,38 @@ private void showStats() { startActivity(intent); } + private void loadBookmarks() { + DatabaseHelper dbHelper = AppSingleton.getInstance(getApplicationContext()) + .getDatabaseHelper(); + List bookmarkList = dbHelper.getBookmarkedIssueIds(); + Set bookmarkSet = new HashSet<>(bookmarkList); + mIssuesAdapter.setBookmarkedIds(bookmarkSet); + } + + @Override + public void onBookmarkToggled(String issueId, boolean isNowBookmarked) { + DatabaseHelper dbHelper = AppSingleton.getInstance(getApplicationContext()) + .getDatabaseHelper(); + if (isNowBookmarked) { + dbHelper.addBookmark(issueId); + } else { + dbHelper.removeBookmark(issueId); + } + // Track analytics. + Issue issue = null; + for (Issue i : mIssuesAdapter.getAllIssues()) { + if (TextUtils.equals(i.id, issueId)) { + issue = i; + break; + } + } + if (issue != null && issue.permalink != null) { + FiveCallsApplication.analyticsManager().trackBookmark( + issue.permalink, isNowBookmarked, this); + } + mIssuesAdapter.onBookmarksChanged(); + } + @Override public void refreshIssues() { FiveCallsApi api = AppSingleton.getInstance(getApplicationContext()).getJsonController(); @@ -706,6 +798,9 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == ISSUE_DETAIL_REQUEST && resultCode == RESULT_OK) { Issue issue = data.getExtras().getParcelable(IssueActivity.KEY_ISSUE); mIssuesAdapter.updateIssue(issue); + // Reload bookmarks in case bookmark state changed in IssueActivity. + loadBookmarks(); + mIssuesAdapter.onBookmarksChanged(); } super.onActivityResult(requestCode, resultCode, data); } @@ -722,14 +817,14 @@ public void onIssueSearchSet(String searchText) { api.reportSearch(searchText.trim()); } - binding.filter.setVisibility(View.GONE); + binding.filterBar.setVisibility(View.GONE); binding.searchBar.setVisibility(VISIBLE); setSearchText(searchText); updateOnBackPressedCallbackEnabled(); } public void onIssueSearchCleared() { - binding.filter.setVisibility(VISIBLE); + binding.filterBar.setVisibility(VISIBLE); binding.searchBar.setVisibility(View.GONE); setSearchText(""); updateOnBackPressedCallbackEnabled(); diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/model/DatabaseHelper.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/model/DatabaseHelper.java index a8b658ed..1f987be2 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/model/DatabaseHelper.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/model/DatabaseHelper.java @@ -22,13 +22,15 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String TAG = "DatabaseHelper"; - private static final int DATABASE_VERSION = 3; + private static final int DATABASE_VERSION = 4; @VisibleForTesting protected static final String CALLS_TABLE_NAME = "UserCallsDatabase"; @VisibleForTesting protected static final String ISSUES_TABLE_NAME = "UserIssuesTable"; @VisibleForTesting protected static final String CONTACTS_TABLE_NAME = "UserContactsTable"; + @VisibleForTesting + protected static final String BOOKMARKS_TABLE_NAME = "BookmarkedIssues"; // Can be used to control time in tests. private TimeProvider mTimeProvider; @@ -82,6 +84,16 @@ public static class ContactColumns { "CREATE TABLE " + CONTACTS_TABLE_NAME + " (" + ContactColumns.CONTACT_ID + " STRING, " + ContactColumns.CONTACT_NAME + " STRING);"; + private static class BookmarksColumns { + public static String ISSUE_ID = "issueid"; + public static String TIMESTAMP = "timestamp"; + } + + private static final String BOOKMARKS_TABLE_CREATE = + "CREATE TABLE " + BOOKMARKS_TABLE_NAME + " (" + + BookmarksColumns.ISSUE_ID + " STRING PRIMARY KEY, " + + BookmarksColumns.TIMESTAMP + " INTEGER);"; + public DatabaseHelper(Context context) { this(context, new DefaultTimeProvider()); } @@ -96,6 +108,7 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(CALLS_TABLE_CREATE); db.execSQL(ISSUES_TABLE_CREATE); db.execSQL(CONTACTS_TABLE_CREATE); + db.execSQL(BOOKMARKS_TABLE_CREATE); } @Override @@ -124,6 +137,11 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { new String[]{Outcome.Status.VM.toString()}); currentDbVersion = 3; } + + if (oldVersion < 4 && currentDbVersion < newVersion) { + db.execSQL(BOOKMARKS_TABLE_CREATE); + currentDbVersion = 4; + } } /** @@ -333,6 +351,54 @@ public List> getCallCountsByIssue() { return result; } + /** + * Adds a bookmark for the given issue ID. + */ + public void addBookmark(String issueId) { + ContentValues values = new ContentValues(); + values.put(BookmarksColumns.ISSUE_ID, issueId); + values.put(BookmarksColumns.TIMESTAMP, mTimeProvider.currentTimeMillis()); + getWritableDatabase().insertWithOnConflict(BOOKMARKS_TABLE_NAME, null, values, + SQLiteDatabase.CONFLICT_REPLACE); + } + + /** + * Removes the bookmark for the given issue ID. + */ + public void removeBookmark(String issueId) { + getWritableDatabase().delete(BOOKMARKS_TABLE_NAME, + BookmarksColumns.ISSUE_ID + " = ?", new String[]{issueId}); + } + + /** + * Returns whether the given issue ID is bookmarked. + */ + public boolean isBookmarked(String issueId) { + Cursor c = getReadableDatabase().rawQuery( + "SELECT " + BookmarksColumns.ISSUE_ID + " FROM " + BOOKMARKS_TABLE_NAME + + " WHERE " + BookmarksColumns.ISSUE_ID + " = ?", + new String[]{issueId}); + boolean result = c.getCount() > 0; + c.close(); + return result; + } + + /** + * Returns a list of all bookmarked issue IDs. + */ + public List getBookmarkedIssueIds() { + Cursor c = getReadableDatabase().rawQuery( + "SELECT " + BookmarksColumns.ISSUE_ID + " FROM " + BOOKMARKS_TABLE_NAME + + " ORDER BY " + BookmarksColumns.TIMESTAMP + " DESC", + null); + List result = new ArrayList<>(); + while (c.moveToNext()) { + result.add(c.getString(0)); + } + c.close(); + return result; + } + @VisibleForTesting public static String sanitizeContactId(String contactId) { // TODO this only works on single quotes and not double quotes. Triple quotes are still diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/util/AnalyticsManager.kt b/5calls/app/src/main/java/org/a5calls/android/a5calls/util/AnalyticsManager.kt index a54b6d09..3b942b5a 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/util/AnalyticsManager.kt +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/util/AnalyticsManager.kt @@ -43,4 +43,12 @@ class AnalyticsManager { url = issuePath, props = staticProps) } } + + fun trackBookmark(issuePath: String, added: Boolean, context: Context) { + if (!BuildConfig.DEBUG && AccountManager.Instance.allowAnalytics(context)) { + val action = if (added) "add" else "remove" + getPlausible(context).event(name = "Bookmark-$action", + url = issuePath, props = staticProps) + } + } } diff --git a/5calls/app/src/main/res/drawable/bookmark_filled_24.xml b/5calls/app/src/main/res/drawable/bookmark_filled_24.xml new file mode 100644 index 00000000..a756a16d --- /dev/null +++ b/5calls/app/src/main/res/drawable/bookmark_filled_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/5calls/app/src/main/res/drawable/bookmark_outline_24.xml b/5calls/app/src/main/res/drawable/bookmark_outline_24.xml new file mode 100644 index 00000000..2f82b61c --- /dev/null +++ b/5calls/app/src/main/res/drawable/bookmark_outline_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/5calls/app/src/main/res/drawable/ic_filter_list_24.xml b/5calls/app/src/main/res/drawable/ic_filter_list_24.xml new file mode 100644 index 00000000..31fb3faf --- /dev/null +++ b/5calls/app/src/main/res/drawable/ic_filter_list_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/5calls/app/src/main/res/layout/activity_issue.xml b/5calls/app/src/main/res/layout/activity_issue.xml index b465d807..8e015517 100644 --- a/5calls/app/src/main/res/layout/activity_issue.xml +++ b/5calls/app/src/main/res/layout/activity_issue.xml @@ -44,10 +44,33 @@ android:layout_gravity="end" /> - + + + + + + + - + android:background="@color/colorAccent" + > + + + + diff --git a/5calls/app/src/main/res/layout/empty_bookmarks_view.xml b/5calls/app/src/main/res/layout/empty_bookmarks_view.xml new file mode 100644 index 00000000..3014f001 --- /dev/null +++ b/5calls/app/src/main/res/layout/empty_bookmarks_view.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/5calls/app/src/main/res/layout/filter_divider_item.xml b/5calls/app/src/main/res/layout/filter_divider_item.xml new file mode 100644 index 00000000..7a21fe92 --- /dev/null +++ b/5calls/app/src/main/res/layout/filter_divider_item.xml @@ -0,0 +1,6 @@ + + diff --git a/5calls/app/src/main/res/layout/issue_view.xml b/5calls/app/src/main/res/layout/issue_view.xml index 649651f9..66c13259 100644 --- a/5calls/app/src/main/res/layout/issue_view.xml +++ b/5calls/app/src/main/res/layout/issue_view.xml @@ -20,12 +20,24 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent"/> + + + app:layout_constraintEnd_toStartOf="@id/bookmark_icon"/> - \ No newline at end of file + app:layout_constraintEnd_toStartOf="@id/bookmark_icon"/> + diff --git a/5calls/app/src/main/res/menu/menu_main.xml b/5calls/app/src/main/res/menu/menu_main.xml index ccb2df84..8382b9e9 100644 --- a/5calls/app/src/main/res/menu/menu_main.xml +++ b/5calls/app/src/main/res/menu/menu_main.xml @@ -16,4 +16,4 @@ android:icon="@drawable/ic_refresh_white_24dp" /> - \ No newline at end of file + diff --git a/5calls/app/src/main/res/values-es/strings.xml b/5calls/app/src/main/res/values-es/strings.xml index 2998ebbb..0703c18a 100644 --- a/5calls/app/src/main/res/values-es/strings.xml +++ b/5calls/app/src/main/res/values-es/strings.xml @@ -442,11 +442,26 @@ Filtrar - Temas principales + Arriba Todos los temas + + Filtrar + + + Guardados + + + Problema de marcador + + + Quitar marcador + + + No se encontraron problemas marcados como activos + Notificaciones diff --git a/5calls/app/src/main/res/values/strings.xml b/5calls/app/src/main/res/values/strings.xml index 53afbf38..800d0547 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -531,14 +531,30 @@ Filter - Top issues + Top All issues + + Filter + + + Saved + + + Bookmark issue + + + Remove bookmark + + + No active bookmarked issues found + @string/top_issues_filter @string/all_issues_filter + @string/bookmarked_issues_filter diff --git a/5calls/app/src/main/res/values/styles.xml b/5calls/app/src/main/res/values/styles.xml index 83953fb8..73c4cbaf 100644 --- a/5calls/app/src/main/res/values/styles.xml +++ b/5calls/app/src/main/res/values/styles.xml @@ -273,7 +273,10 @@