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 11e36835..fd181af8 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,7 +8,6 @@ import android.view.View; import android.view.ViewGroup; import android.widget.Button; -import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; @@ -49,6 +48,8 @@ public class IssuesAdapter extends RecyclerView.Adapter private final Activity mActivity; private final Callback mCallback; + private List starredIssueIds = new ArrayList<>(); + public interface Callback { void refreshIssues(); @@ -81,6 +82,11 @@ public void setAllIssues(List issues, int errorType) { } } + public void setStarredIssues(List issueIds) { + starredIssueIds = issueIds; + notifyDataSetChanged(); + } + public void setAddressError(int error) { mAddressErrorType = error; mContacts.clear(); @@ -124,7 +130,10 @@ public void setFilterAndSearch(String filterText, String searchText) { } else if (TextUtils.equals(filterText, mActivity.getResources().getString(R.string.top_issues_filter))) { mIssues = filterActiveIssues(); - } else { + } else if (TextUtils.equals(filterText, mActivity.getResources().getString(R.string.starred_issues_filter))) { + mIssues = filterStarredIssues(); + } + else { // Filter by the category string. mIssues = filterIssuesByCategory(filterText); } @@ -177,6 +186,16 @@ private ArrayList filterActiveIssues() { return tempIssues; } + private ArrayList filterStarredIssues() { + ArrayList tempIssues = new ArrayList<>(); + for (Issue issue : mAllIssues) { + if (starredIssueIds.contains(issue.id)) { + tempIssues.add(issue); + } + } + return tempIssues; + } + private ArrayList filterIssuesByCategory(String activeCategory) { ArrayList tempIssues = new ArrayList<>(); for (Issue issue : mAllIssues) { @@ -232,6 +251,14 @@ public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int IssueViewHolder vh = (IssueViewHolder) holder; final Issue issue = mIssues.get(position); vh.name.setText(issue.name); + + if (starredIssueIds.contains(issue.id)) { + vh.name.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_star_yellow_24dp, 0); + } else { + // To undo previously set drawable in the event of an issue being unstarred + vh.name.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0 ,0); + } + vh.itemView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { 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 040fe1b0..9e38b093 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 @@ -64,6 +64,7 @@ public class IssueActivity extends AppCompatActivity { public static final String KEY_ISSUE = "key_issue"; 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_STARRED = "key_is_starred"; public static final int RESULT_OK = 1; public static final int RESULT_SERVER_ERROR = 2; @@ -80,6 +81,7 @@ public class IssueActivity extends AppCompatActivity { private boolean mIsLowAccuracy = false; private boolean mDonateIsOn = false; private boolean mIsAnimating = false; + private boolean mIsStarred = false; private ActivityIssueBinding binding; @@ -96,12 +98,14 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { } mIsLowAccuracy = getIntent().getBooleanExtra(KEY_IS_LOW_ACCURACY, false); mDonateIsOn = getIntent().getBooleanExtra(KEY_DONATE_IS_ON, false); + mIsStarred = getIntent().getBooleanExtra(KEY_IS_STARRED, false); setContentView(binding.getRoot()); if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); - getSupportActionBar().setTitle(mIssue.name); + // getSupportActionBar().setTitle(mIssue.name); + getSupportActionBar().setDisplayShowTitleEnabled(false); } binding.issueName.setText(mIssue.name); @@ -202,6 +206,7 @@ protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putParcelable(KEY_ISSUE, mIssue); outState.putBoolean(KEY_IS_LOW_ACCURACY, mIsLowAccuracy); + outState.putBoolean(KEY_IS_STARRED, mIsStarred); } @Override @@ -272,6 +277,16 @@ public void onClick(View view) { public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.menu_issue, menu); + + MenuItem starItem = menu.findItem(R.id.menu_star); + if (mIsStarred) { + starItem.setIcon(R.drawable.ic_star_white_24dp); + starItem.setTitle(R.string.action_unstar_issue); + } else { + starItem.setIcon(R.drawable.ic_star_outline_white_24dp); + starItem.setTitle(R.string.action_star_issue); + } + return true; } @@ -285,6 +300,17 @@ public boolean onOptionsItemSelected(MenuItem item) { sendShare(); return true; } + if (item.getItemId() == R.id.menu_star) { + DatabaseHelper db = AppSingleton.getInstance(getApplicationContext()).getDatabaseHelper(); + if (mIsStarred) { + db.removeStarredIssue(mIssue.id); + mIsStarred = false; + } else { + db.addStarredIssue(mIssue.id); + mIsStarred = true; + } + invalidateOptionsMenu(); + } if (item.getItemId() == R.id.menu_details) { showIssueDetails(); return true; 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 ce21d21e..071071f6 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 @@ -54,6 +54,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; import static android.view.View.VISIBLE; @@ -86,6 +87,7 @@ public class MainActivity extends AppCompatActivity implements IssuesAdapter.Cal private boolean mShowLowAccuracyWarning = true; private boolean mDonateIsOn = false; private FirebaseAuth mAuth = null; + private List starredIssues; private ActivityMainBinding binding; @@ -207,7 +209,7 @@ public void onError() { binding.searchText.setText(mSearchText); } } else { - // Safe to use index as the top two filters are hard-coded strings. + // Safe to use index as the top three filters are hard-coded strings. mFilterText = mFilterAdapter.getItem(0); } binding.searchText.setOnClickListener(new View.OnClickListener() { @@ -279,6 +281,7 @@ public void onGlobalLayout() { }); loadStats(); + loadStarredIssues(); mAddress = accountManager.getAddress(this); mLatitude = accountManager.getLat(this); @@ -352,6 +355,7 @@ public void startIssueActivity(Context context, Issue issue) { issueIntent.putExtra(RepCallActivity.KEY_LOCATION_NAME, mLocationName); issueIntent.putExtra(IssueActivity.KEY_IS_LOW_ACCURACY, mIsLowAccuracy); issueIntent.putExtra(IssueActivity.KEY_DONATE_IS_ON, mDonateIsOn); + issueIntent.putExtra(IssueActivity.KEY_IS_STARRED, starredIssues.contains(issue.id)); startActivityForResult(issueIntent, ISSUE_DETAIL_REQUEST); } @@ -428,6 +432,12 @@ public void onIssuesReceived(List issues) { mIssuesAdapter.setAllIssues(issues, IssuesAdapter.NO_ERROR); mIssuesAdapter.setFilterAndSearch(mFilterText, mSearchText); binding.swipeContainer.setRefreshing(false); + + List ids = new ArrayList<>(); + for (Issue i : issues) { + ids.add(i.id); + } + AppSingleton.getInstance(getApplicationContext()).getDatabaseHelper().trimStarredIssues(ids); } }; @@ -555,7 +565,7 @@ private void updateOnBackPressedCallbackEnabled() { } private void populateFilterAdapterIfNeeded(List issues) { - if (mFilterAdapter.getCount() > 2) { + if (mFilterAdapter.getCount() > 3) { // Already populated. Don't try again. // This assumes that the categories won't change much during the course of a session. return; @@ -609,6 +619,13 @@ private void loadStats() { } } + private void loadStarredIssues() { + starredIssues = AppSingleton.getInstance(getApplicationContext()) + .getDatabaseHelper().getStarredIssues(); + Log.d(TAG, starredIssues.toString()); + mIssuesAdapter.setStarredIssues(starredIssues); + } + private void showStats() { Intent intent = new Intent(this, StatsActivity.class); startActivity(intent); 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..9da471f1 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 STARRED_ISSUES_TABLE_NAME = "StarredIssuesTable"; // Can be used to control time in tests. private TimeProvider mTimeProvider; @@ -82,6 +84,15 @@ public static class ContactColumns { "CREATE TABLE " + CONTACTS_TABLE_NAME + " (" + ContactColumns.CONTACT_ID + " STRING, " + ContactColumns.CONTACT_NAME + " STRING);"; + private static class StarredIssuesColumns { + public static String ID = "issueid"; + public static String TIMESTAMP = "timestamp"; + } + + private static final String STARRED_ISSUES_TABLE_CREATE = + "CREATE TABLE " + STARRED_ISSUES_TABLE_NAME + " (" + StarredIssuesColumns.ID + " STRING, " + + StarredIssuesColumns.TIMESTAMP + " INTEGER);"; + public DatabaseHelper(Context context) { this(context, new DefaultTimeProvider()); } @@ -96,6 +107,7 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(CALLS_TABLE_CREATE); db.execSQL(ISSUES_TABLE_CREATE); db.execSQL(CONTACTS_TABLE_CREATE); + db.execSQL(STARRED_ISSUES_TABLE_CREATE); } @Override @@ -124,6 +136,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { new String[]{Outcome.Status.VM.toString()}); currentDbVersion = 3; } + + if (oldVersion < 4 && currentDbVersion < newVersion) { + db.execSQL(STARRED_ISSUES_TABLE_CREATE); + } } /** @@ -164,6 +180,21 @@ private void addContact(String contactId, String contactName) { SQLiteDatabase.CONFLICT_IGNORE); } + public void addStarredIssue(String issueId) { + ContentValues values = new ContentValues(); + values.put(StarredIssuesColumns.ID, issueId); + values.put(StarredIssuesColumns.TIMESTAMP, mTimeProvider.currentTimeMillis()); + getWritableDatabase().insert(STARRED_ISSUES_TABLE_NAME, null, values); + } + + public void removeStarredIssue(String issueId) { + getWritableDatabase().delete( + STARRED_ISSUES_TABLE_NAME, + StarredIssuesColumns.ID + " = ?", + List.of(issueId).toArray(new String[0]) + ); + } + public String getIssueName(String issueId) { Cursor c = getReadableDatabase().rawQuery("SELECT " + IssuesColumns.ISSUE_NAME + " FROM " + ISSUES_TABLE_NAME + " WHERE " + IssuesColumns.ISSUE_ID + " = '" + issueId + "'", @@ -333,6 +364,37 @@ public List> getCallCountsByIssue() { return result; } + public List getStarredIssues() { + Cursor c = getReadableDatabase().rawQuery( + "SELECT " + StarredIssuesColumns.ID + " FROM " + STARRED_ISSUES_TABLE_NAME + + " ORDER BY " + StarredIssuesColumns.TIMESTAMP, null); + List result = new ArrayList<>(); + while (c.moveToNext()) { + result.add(c.getString(0)); + } + c.close(); + return result; + } + + // Delete any starred issues not present in the provided list of issue ids to prevent the + // table from filling up with issues that don't exist any more so the app doesn't have to look + // through as many items when seeing if the issue needs to be marked as starred + public int trimStarredIssues(List ids) { + StringBuilder placeholders = new StringBuilder(); + for (int i = 0; i < ids.size(); i++) { + placeholders.append("?"); + if (i < ids.size() - 1) { + placeholders.append(", "); + } + } + + String whereClause = StarredIssuesColumns.ID + " NOT IN (" + + placeholders + ")"; + String[] whereArgs = ids.toArray(new String[0]); + + return getWritableDatabase().delete(STARRED_ISSUES_TABLE_NAME, whereClause, whereArgs); + } + @VisibleForTesting public static String sanitizeContactId(String contactId) { // TODO this only works on single quotes and not double quotes. Triple quotes are still @@ -342,4 +404,6 @@ public static String sanitizeContactId(String contactId) { } return contactId; } + + } diff --git a/5calls/app/src/main/res/drawable-hdpi/ic_star_outline_white_24dp.png b/5calls/app/src/main/res/drawable-hdpi/ic_star_outline_white_24dp.png new file mode 100644 index 00000000..c67e2a7c Binary files /dev/null and b/5calls/app/src/main/res/drawable-hdpi/ic_star_outline_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-hdpi/ic_star_white_24dp.png b/5calls/app/src/main/res/drawable-hdpi/ic_star_white_24dp.png new file mode 100644 index 00000000..5b21bb34 Binary files /dev/null and b/5calls/app/src/main/res/drawable-hdpi/ic_star_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-hdpi/ic_star_yellow_24dp.png b/5calls/app/src/main/res/drawable-hdpi/ic_star_yellow_24dp.png new file mode 100644 index 00000000..f4bde0a9 Binary files /dev/null and b/5calls/app/src/main/res/drawable-hdpi/ic_star_yellow_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-mdpi/ic_star_outline_white_24dp.png b/5calls/app/src/main/res/drawable-mdpi/ic_star_outline_white_24dp.png new file mode 100644 index 00000000..13c20a92 Binary files /dev/null and b/5calls/app/src/main/res/drawable-mdpi/ic_star_outline_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-mdpi/ic_star_white_24dp.png b/5calls/app/src/main/res/drawable-mdpi/ic_star_white_24dp.png new file mode 100644 index 00000000..f44f4073 Binary files /dev/null and b/5calls/app/src/main/res/drawable-mdpi/ic_star_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-mdpi/ic_star_yellow_24dp.png b/5calls/app/src/main/res/drawable-mdpi/ic_star_yellow_24dp.png new file mode 100644 index 00000000..1edab639 Binary files /dev/null and b/5calls/app/src/main/res/drawable-mdpi/ic_star_yellow_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xhdpi/ic_star_outline_white_24dp.png b/5calls/app/src/main/res/drawable-xhdpi/ic_star_outline_white_24dp.png new file mode 100644 index 00000000..01d61019 Binary files /dev/null and b/5calls/app/src/main/res/drawable-xhdpi/ic_star_outline_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xhdpi/ic_star_white_24dp.png b/5calls/app/src/main/res/drawable-xhdpi/ic_star_white_24dp.png new file mode 100644 index 00000000..08dd7832 Binary files /dev/null and b/5calls/app/src/main/res/drawable-xhdpi/ic_star_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xhdpi/ic_star_yellow_24dp.png b/5calls/app/src/main/res/drawable-xhdpi/ic_star_yellow_24dp.png new file mode 100644 index 00000000..6cf07b86 Binary files /dev/null and b/5calls/app/src/main/res/drawable-xhdpi/ic_star_yellow_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xxhdpi/ic_star_outline_white_24dp.png b/5calls/app/src/main/res/drawable-xxhdpi/ic_star_outline_white_24dp.png new file mode 100644 index 00000000..7c837c22 Binary files /dev/null and b/5calls/app/src/main/res/drawable-xxhdpi/ic_star_outline_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xxhdpi/ic_star_white_24dp.png b/5calls/app/src/main/res/drawable-xxhdpi/ic_star_white_24dp.png new file mode 100644 index 00000000..c829536e Binary files /dev/null and b/5calls/app/src/main/res/drawable-xxhdpi/ic_star_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xxhdpi/ic_star_yellow_24dp.png b/5calls/app/src/main/res/drawable-xxhdpi/ic_star_yellow_24dp.png new file mode 100644 index 00000000..2a2e353d Binary files /dev/null and b/5calls/app/src/main/res/drawable-xxhdpi/ic_star_yellow_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_outline_white_24dp.png b/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_outline_white_24dp.png new file mode 100644 index 00000000..765813c0 Binary files /dev/null and b/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_outline_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_white_24dp.png b/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_white_24dp.png new file mode 100644 index 00000000..da350713 Binary files /dev/null and b/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_white_24dp.png differ diff --git a/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_yellow_24dp.png b/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_yellow_24dp.png new file mode 100644 index 00000000..c5355e03 Binary files /dev/null and b/5calls/app/src/main/res/drawable-xxxhdpi/ic_star_yellow_24dp.png differ diff --git a/5calls/app/src/main/res/menu/menu_issue.xml b/5calls/app/src/main/res/menu/menu_issue.xml index da855dfe..eeca417c 100644 --- a/5calls/app/src/main/res/menu/menu_issue.xml +++ b/5calls/app/src/main/res/menu/menu_issue.xml @@ -4,13 +4,19 @@ + \ No newline at end of file diff --git a/5calls/app/src/main/res/values/strings.xml b/5calls/app/src/main/res/values/strings.xml index b4ae5126..71677caf 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -477,9 +477,13 @@ All issues + + Starred issues + @string/top_issues_filter @string/all_issues_filter + @string/starred_issues_filter @@ -635,4 +639,9 @@ Categories: + + Star Issue + + + Unstar Issue diff --git a/5calls/app/src/test/java/org/a5calls/android/a5calls/model/DatabaseHelperTest.java b/5calls/app/src/test/java/org/a5calls/android/a5calls/model/DatabaseHelperTest.java index 2d4e62c6..4d180d22 100644 --- a/5calls/app/src/test/java/org/a5calls/android/a5calls/model/DatabaseHelperTest.java +++ b/5calls/app/src/test/java/org/a5calls/android/a5calls/model/DatabaseHelperTest.java @@ -1,7 +1,5 @@ package org.a5calls.android.a5calls.model; -import android.os.Parcel; - import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -30,6 +28,7 @@ public void setUp() { getApplicationContext().deleteDatabase(DatabaseHelper.CALLS_TABLE_NAME); getApplicationContext().deleteDatabase(DatabaseHelper.ISSUES_TABLE_NAME); getApplicationContext().deleteDatabase(DatabaseHelper.CONTACTS_TABLE_NAME); + getApplicationContext().deleteDatabase(DatabaseHelper.STARRED_ISSUES_TABLE_NAME); // Create a fake TimeProvider so we can control timestamps. mCalendar = new Calendar.Builder() @@ -343,6 +342,37 @@ public void testHasCalledToday() { } } + @Test + public void starredIssues_AddIssue() { + mDatabase.addStarredIssue("test-issue"); + assertEquals(1, mDatabase.getStarredIssues().size()); + assertEquals("test-issue", mDatabase.getStarredIssues().getFirst()); + } + + @Test + public void starredIssues_DeletesIssue() { + String issue = "to-be-deleted"; + mDatabase.addStarredIssue(issue); + assertTrue(mDatabase.getStarredIssues().contains(issue)); + mDatabase.removeStarredIssue(issue); + assertFalse(mDatabase.getStarredIssues().contains(issue)); + } + + @Test + public void starredIssues_TrimsIssues() { + mDatabase.addStarredIssue("test-issue2"); + mDatabase.addStarredIssue("test-issue3"); + mDatabase.addStarredIssue("keep"); + mDatabase.trimStarredIssues(List.of("keep")); + assertEquals(1, mDatabase.getStarredIssues().size()); + assertEquals("keep", mDatabase.getStarredIssues().getFirst()); + } + + @Test + public void starredIssue_TrimIssues() { + + } + // Wrapper class for holding a pair of issues and contacts. private static class IssuesAndContacts { List contacts;