diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..dbc78091 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew :app:compileDebugJavaWithJavac)", + "Bash(./gradlew :app:testDebugUnitTest --tests \"org.a5calls.android.a5calls.controller.IssueActivityUnitTest\")", + "Bash(java -version)", + "Bash(./gradlew --version)", + "Bash(/usr/libexec/java_home -V)", + "Bash(JAVA_HOME=/Users/scottpeterson/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home ./gradlew :app:testDebugUnitTest --tests \"org.a5calls.android.a5calls.controller.IssueActivityUnitTest\")", + "Read(//Users/scottpeterson/.sdkman/candidates/**)", + "Bash(brew list *)", + "Bash(brew *)", + "Read(//opt/homebrew/Cellar/openjdk@21/21.0.11/libexec/openjdk.jdk/Contents/Home/bin/**)", + "Bash(/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin/java -version)", + "Bash(JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :app:testDebugUnitTest --tests \"org.a5calls.android.a5calls.controller.IssueActivityUnitTest\")", + "Bash(JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :app:testDebugUnitTest)" + ] + } +} 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 119871ec..e9d3e917 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 @@ -1,19 +1,24 @@ package org.a5calls.android.a5calls.controller; +import android.animation.ValueAnimator; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.text.Html; import android.text.TextUtils; import android.text.method.LinkMovementMethod; import android.util.Log; +import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; +import android.view.animation.LinearInterpolator; import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; @@ -29,6 +34,7 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.content.ContextCompat; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowCompat; @@ -40,6 +46,7 @@ import com.bumptech.glide.load.resource.bitmap.CircleCrop; import com.google.android.gms.tasks.Task; import com.google.android.material.bottomsheet.BottomSheetBehavior; +import com.google.android.material.progressindicator.CircularProgressIndicator; import com.google.android.material.snackbar.Snackbar; import com.google.android.play.core.review.ReviewInfo; import com.google.android.play.core.review.ReviewManager; @@ -57,6 +64,7 @@ import org.a5calls.android.a5calls.model.CustomizedContactScript; import org.a5calls.android.a5calls.model.DatabaseHelper; import org.a5calls.android.a5calls.model.Issue; +import org.a5calls.android.a5calls.model.Outcome; import org.a5calls.android.a5calls.net.FiveCallsApi; import org.a5calls.android.a5calls.util.MarkdownUtil; import org.a5calls.android.a5calls.util.StateMapping; @@ -87,10 +95,16 @@ public class IssueActivity extends AppCompatActivity implements FiveCallsApi.Scr private static final String DONATE_URL = "https://secure.actblue.com/donate/5calls-donate?refcode=android&refcode2="; private static final int MIN_CALLS_TO_SHOW_CALL_STATS = 10; + private static final long PENDING_CALL_DELAY_MS = 5000L; private boolean mShowServerError = false; private boolean mShowPlaceholderCalled = false; + private Integer mPendingContactIndex = null; + private Outcome mPendingOutcome = null; + private final Handler mPendingCallHandler = new Handler(Looper.getMainLooper()); + private FiveCallsApi.CallRequestListener mCommitStatusListener; + private Issue mIssue; private String mAddress; private String mLocationName; @@ -117,6 +131,16 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { mRepCallLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == RESULT_OK) { + Intent data = result.getData(); + if (data != null + && data.hasExtra(RepCallActivity.EXTRA_PENDING_OUTCOME)) { + mPendingContactIndex = data.getIntExtra( + RepCallActivity.EXTRA_PENDING_CONTACT_INDEX, -1); + mPendingOutcome = data.getParcelableExtra( + RepCallActivity.EXTRA_PENDING_OUTCOME); + } + } if (result.getResultCode() == RESULT_SERVER_ERROR) { mShowServerError = true; } @@ -126,6 +150,28 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { }); FiveCallsApi api = AppSingleton.getInstance(this).getJsonController(); + mCommitStatusListener = new FiveCallsApi.CallRequestListener() { + @Override + public void onRequestError() { + showServerErrorSnackbar(); + } + + @Override + public void onJsonError() { + showServerErrorSnackbar(); + } + + @Override + public void onReportReceived(int count, boolean donateOn) { + // unused + } + + @Override + public void onCallReported() { + // unused — commit succeeded + } + }; + api.registerCallRequestListener(mCommitStatusListener); mLocationLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { @@ -370,9 +416,7 @@ protected void onSaveInstanceState(Bundle outState) { protected void onResume() { super.onResume(); if (mShowServerError) { - Snackbar.make(getWindow().getDecorView(), - getResources().getString(R.string.call_error_db_recorded_anyway), - Snackbar.LENGTH_LONG).show(); + showServerErrorSnackbar(); mShowServerError = false; } if (mIssue.contactAreas.isEmpty()) { @@ -385,6 +429,15 @@ protected void onResume() { AccountManager.Instance.setShowPlaceholderIssue(getApplicationContext(), false); } showContactsUi(); + if (mPendingContactIndex != null && mPendingOutcome != null) { + showUndoSnackbar(); + } + } + + @Override + protected void onPause() { + super.onPause(); + commitPendingCall(); } private void showContactsUi() { @@ -582,7 +635,8 @@ private boolean loadRepList() { for (int i = 0; i < mIssue.contacts.size(); i++) { Contact contact = mIssue.contacts.get(i); View repView = LayoutInflater.from(this).inflate(R.layout.rep_list_view, null); - boolean hasCalledToday = dbHelper.hasCalledToday(mIssue.id, contact.id); + boolean hasCalledToday = dbHelper.hasCalledToday(mIssue.id, contact.id) + || isPendingForContact(i); populateRepView(repView, contact, i, hasCalledToday); binding.repList.addView(repView); if (!hasCalledToday && !contact.isPlaceholder) { @@ -592,6 +646,10 @@ private boolean loadRepList() { return allCalled && !mIssue.contacts.isEmpty(); } + private boolean isPendingForContact(int index) { + return mPendingContactIndex != null && mPendingContactIndex == index; + } + private void populateRepView(View repView, Contact contact, final int index, boolean hasCalledToday) { final TextView contactName = repView.findViewById(R.id.contact_name); @@ -646,7 +704,6 @@ private void populateRepView(View repView, Contact contact, final int index, public void onClick(View view) { Intent intent = new Intent(getApplicationContext(), RepCallActivity.class); intent.putExtra(KEY_ISSUE, mIssue); - intent.putExtra(RepCallActivity.KEY_ADDRESS, mAddress); intent.putExtra(RepCallActivity.KEY_LOCATION_NAME, mLocationName); intent.putExtra(RepCallActivity.KEY_ACTIVE_CONTACT_INDEX, index); mRepCallLauncher.launch(intent); @@ -660,6 +717,131 @@ public void onClick(View view) { } } + private void showUndoSnackbar() { + if (mPendingContactIndex == null || mPendingOutcome == null) { + return; + } + if (mPendingContactIndex < 0 || mPendingContactIndex >= mIssue.contacts.size()) { + mPendingContactIndex = null; + mPendingOutcome = null; + return; + } + String message = buildUndoSnackbarMessage(this, mPendingOutcome.status); + final Snackbar snackbar = Snackbar.make(getWindow().getDecorView(), message, + Snackbar.LENGTH_INDEFINITE); + snackbar.setAction(R.string.undo_action, v -> { + snackbar.dismiss(); + cancelPendingCall(); + }); + snackbar.setActionTextColor(ContextCompat.getColor(this, R.color.colorAccentLight)); + addUndoCountdownIndicator(snackbar); + snackbar.show(); + mPendingCallHandler.removeCallbacksAndMessages(null); + mPendingCallHandler.postDelayed(() -> { + snackbar.dismiss(); + commitPendingCall(); + }, PENDING_CALL_DELAY_MS); + } + + private void addUndoCountdownIndicator(Snackbar snackbar) { + View actionView = snackbar.getView().findViewById( + com.google.android.material.R.id.snackbar_action); + if (actionView == null || !(actionView.getParent() instanceof LinearLayout)) { + return; + } + LinearLayout contentRow = (LinearLayout) actionView.getParent(); + + float density = getResources().getDisplayMetrics().density; + int sizePx = Math.round(20 * density); + int marginPx = Math.round(8 * density); + int trackThicknessPx = Math.round(2 * density); + + CircularProgressIndicator indicator = new CircularProgressIndicator(this); + indicator.setIndicatorSize(sizePx); + indicator.setTrackThickness(trackThicknessPx); + indicator.setIndeterminate(false); + indicator.setMax(100); + indicator.setProgressCompat(100, false); + indicator.setIndicatorColor( + ContextCompat.getColor(this, R.color.colorAccentLight)); + + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(sizePx, sizePx); + lp.gravity = Gravity.CENTER_VERTICAL; + lp.setMarginEnd(marginPx); + contentRow.addView(indicator, contentRow.indexOfChild(actionView), lp); + + ValueAnimator animator = ValueAnimator.ofInt(100, 0); + animator.setDuration(PENDING_CALL_DELAY_MS); + animator.setInterpolator(new LinearInterpolator()); + animator.addUpdateListener(a -> + indicator.setProgressCompat((int) a.getAnimatedValue(), false)); + snackbar.addCallback(new Snackbar.Callback() { + @Override + public void onDismissed(Snackbar bar, int event) { + animator.cancel(); + } + }); + animator.start(); + } + + private void commitPendingCall() { + mPendingCallHandler.removeCallbacksAndMessages(null); + if (mPendingContactIndex == null || mPendingOutcome == null) { + return; + } + int index = mPendingContactIndex; + Outcome outcome = mPendingOutcome; + mPendingContactIndex = null; + mPendingOutcome = null; + if (index < 0 || index >= mIssue.contacts.size()) { + return; + } + Contact contact = mIssue.contacts.get(index); + AppSingleton.getInstance(getApplicationContext()).getDatabaseHelper().addCall( + mIssue.id, mIssue.name, contact.id, contact.name, + outcome.status.toString(), mAddress); + AppSingleton.getInstance(getApplicationContext()).getJsonController().reportCall( + mIssue.id, contact.id, outcome.status); + } + + private void cancelPendingCall() { + mPendingCallHandler.removeCallbacksAndMessages(null); + if (mPendingContactIndex == null || mPendingOutcome == null) { + return; + } + int index = mPendingContactIndex; + mPendingContactIndex = null; + mPendingOutcome = null; + showContactsUi(); + Intent intent = new Intent(getApplicationContext(), RepCallActivity.class); + intent.putExtra(KEY_ISSUE, mIssue); + intent.putExtra(RepCallActivity.KEY_LOCATION_NAME, mLocationName); + intent.putExtra(RepCallActivity.KEY_ACTIVE_CONTACT_INDEX, index); + intent.putExtra(RepCallActivity.EXTRA_SHOW_UNDONE_MESSAGE, true); + mRepCallLauncher.launch(intent); + } + + private void showServerErrorSnackbar() { + Snackbar.make(getWindow().getDecorView(), + getResources().getString(R.string.call_error_db_recorded_anyway), + Snackbar.LENGTH_LONG).show(); + } + + @VisibleForTesting + static String capitalizeFirst(String s) { + if (s == null || s.isEmpty()) { + return s; + } + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + @VisibleForTesting + static String buildUndoSnackbarMessage(Context context, Outcome.Status status) { + String outcomeLabel = capitalizeFirst(Outcome.getDisplayString(context, status)); + return context.getResources().getString( + R.string.call_reported_undo_format, outcomeLabel); + } + private void maybeShowIssueDone() { if (mIssue.contacts.isEmpty()) { // Couldn't find any contacts. @@ -668,12 +850,13 @@ private void maybeShowIssueDone() { } final DatabaseHelper dbHelper = AppSingleton.getInstance(this).getDatabaseHelper(); int numPlaceholder = 0; - for (Contact contact : mIssue.contacts) { + for (int i = 0; i < mIssue.contacts.size(); i++) { + Contact contact = mIssue.contacts.get(i); if (contact.isPlaceholder) { numPlaceholder++; continue; } - if (!dbHelper.hasCalledToday(mIssue.id, contact.id)) { + if (!dbHelper.hasCalledToday(mIssue.id, contact.id) && !isPendingForContact(i)) { binding.issueDone.getRoot().setVisibility(View.GONE); return; } @@ -842,8 +1025,10 @@ public void onScriptsReceived(String issueId, List scri @Override protected void onDestroy() { super.onDestroy(); + mPendingCallHandler.removeCallbacksAndMessages(null); FiveCallsApi api = AppSingleton.getInstance(this).getJsonController(); api.unregisterScriptsRequestListener(this); api.unregisterContactsRequestListener(mContactsRequestListener); + api.unregisterCallRequestListener(mCommitStatusListener); } } diff --git a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/RepCallActivity.java b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/RepCallActivity.java index 4069ff52..85e64272 100644 --- a/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/RepCallActivity.java +++ b/5calls/app/src/main/java/org/a5calls/android/a5calls/controller/RepCallActivity.java @@ -64,7 +64,10 @@ public class RepCallActivity extends AppCompatActivity implements FiveCallsApi.S public static final String KEY_ACTIVE_CONTACT_INDEX = "active_contact_index"; private static final String KEY_LOCAL_OFFICES_EXPANDED = "local_offices_expanded"; - private FiveCallsApi.CallRequestListener mStatusListener; + public static final String EXTRA_PENDING_CONTACT_INDEX = "extra_pending_contact_index"; + public static final String EXTRA_PENDING_OUTCOME = "extra_pending_outcome"; + public static final String EXTRA_SHOW_UNDONE_MESSAGE = "extra_show_undone_message"; + private Issue mIssue; private int mActiveContactIndex; private OutcomeAdapter outcomeAdapter; @@ -77,7 +80,6 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { WindowCompat.setDecorFitsSystemWindows(getWindow(), false); binding = ActivityRepCallBinding.inflate(getLayoutInflater()); - final String address = getIntent().getStringExtra(KEY_ADDRESS); mActiveContactIndex = getIntent().getIntExtra(KEY_ACTIVE_CONTACT_INDEX, 0); mIssue = getIntent().getParcelableExtra(KEY_ISSUE); if (mIssue == null) { @@ -102,33 +104,15 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { return WindowInsetsCompat.CONSUMED; }); - mStatusListener = new FiveCallsApi.CallRequestListener() { - @Override - public void onRequestError() { - returnToIssueWithServerError(); - } - - @Override - public void onJsonError() { - returnToIssueWithServerError(); - } - - @Override - public void onReportReceived(int count, boolean donateOn) { - // unused - } - - @Override - public void onCallReported() { - // Note: Skips are not reported. - returnToIssue(); - } - }; FiveCallsApi api = AppSingleton.getInstance(getApplicationContext()) .getJsonController(); - api.registerCallRequestListener(mStatusListener); api.registerScriptsRequestListener(this); + if (getIntent().getBooleanExtra(EXTRA_SHOW_UNDONE_MESSAGE, false)) { + Snackbar.make(binding.scrollView, R.string.call_response_undone, + Snackbar.LENGTH_LONG).show(); + } + // The markdown view gets focus unless we let the scrollview take it back. binding.scrollView.setFocusableInTouchMode(true); binding.scrollView.setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS); @@ -157,7 +141,7 @@ public void onCallReported() { public void onOutcomeClicked(Outcome outcome) { if (!mIssue.isPlaceholder) { reportEvent(outcome); - reportCall(outcome, address); + returnToIssueWithPendingCall(outcome); } else { AccountManager.Instance.setPlaceholderIssueCalled(getApplicationContext(), true); returnToIssueWithDemoCalled(); @@ -180,7 +164,6 @@ public void onOutcomeClicked(Outcome outcome) { @Override protected void onDestroy() { FiveCallsApi api = AppSingleton.getInstance(getApplicationContext()).getJsonController(); - api.unregisterCallRequestListener(mStatusListener); api.unregisterScriptsRequestListener(this); super.onDestroy(); } @@ -203,13 +186,20 @@ public boolean onOptionsItemSelected(MenuItem item) { return super.onOptionsItemSelected(item); } - private void reportCall(Outcome outcome, String address) { + private void returnToIssueWithPendingCall(Outcome outcome) { + if (isFinishing()) { + return; + } outcomeAdapter.setEnabled(false); - AppSingleton.getInstance(getApplicationContext()).getDatabaseHelper().addCall(mIssue.id, - mIssue.name, mIssue.contacts.get(mActiveContactIndex).id, - mIssue.contacts.get(mActiveContactIndex).name, outcome.status.toString(), address); - AppSingleton.getInstance(getApplicationContext()).getJsonController().reportCall( - mIssue.id, mIssue.contacts.get(mActiveContactIndex).id, outcome.status); + Intent upIntent = NavUtils.getParentActivityIntent(this); + if (upIntent == null) { + return; + } + upIntent.putExtra(IssueActivity.KEY_ISSUE, mIssue); + upIntent.putExtra(EXTRA_PENDING_CONTACT_INDEX, mActiveContactIndex); + upIntent.putExtra(EXTRA_PENDING_OUTCOME, outcome); + setResult(IssueActivity.RESULT_OK, upIntent); + finish(); } private void setupContactUi(int index, boolean expandLocalSection) { @@ -374,19 +364,6 @@ private void returnToIssue() { finish(); } - private void returnToIssueWithServerError() { - if (isFinishing()) { - return; - } - Intent upIntent = NavUtils.getParentActivityIntent(this); - if (upIntent == null) { - return; - } - upIntent.putExtra(IssueActivity.KEY_ISSUE, mIssue); - setResult(IssueActivity.RESULT_SERVER_ERROR, upIntent); - finish(); - } - private void returnToIssueWithDemoCalled() { if (isFinishing()) { return; diff --git a/5calls/app/src/main/res/values-es/strings.xml b/5calls/app/src/main/res/values-es/strings.xml index 9c3b5068..48b32b3d 100644 --- a/5calls/app/src/main/res/values-es/strings.xml +++ b/5calls/app/src/main/res/values-es/strings.xml @@ -185,6 +185,15 @@ Llamada grabada, pero tuvimos algunos problemas al contactar al servidor. + + %1$s registrado + + + DESHACER + + + No registrada + Configuración diff --git a/5calls/app/src/main/res/values/strings.xml b/5calls/app/src/main/res/values/strings.xml index 6ea7647a..e6045cd7 100644 --- a/5calls/app/src/main/res/values/strings.xml +++ b/5calls/app/src/main/res/values/strings.xml @@ -213,6 +213,15 @@ Call recorded for you, but we had some issues contacting the server. + + %1$s reported + + + UNDO + + + Not reported + Settings diff --git a/5calls/app/src/test/java/org/a5calls/android/a5calls/controller/IssueActivityUnitTest.java b/5calls/app/src/test/java/org/a5calls/android/a5calls/controller/IssueActivityUnitTest.java index ac9868bf..d07f429e 100644 --- a/5calls/app/src/test/java/org/a5calls/android/a5calls/controller/IssueActivityUnitTest.java +++ b/5calls/app/src/test/java/org/a5calls/android/a5calls/controller/IssueActivityUnitTest.java @@ -5,14 +5,17 @@ import org.a5calls.android.a5calls.model.Category; import org.a5calls.android.a5calls.model.Issue; import org.a5calls.android.a5calls.model.IssueStats; +import org.a5calls.android.a5calls.model.Outcome; import org.a5calls.android.a5calls.model.TestModelUtils; import org.junit.Test; import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; import androidx.test.ext.junit.runners.AndroidJUnit4; import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; /** * Unit tests for IssueActivity. @@ -92,4 +95,80 @@ public void testIssueDetailMessage_noCategories() { assertEquals("Total calls on this topic: 42\n\n" + "First posted: March 22, 2025", result); } + + @Test + public void testCapitalizeFirst_null() { + assertNull(IssueActivity.capitalizeFirst(null)); + } + + @Test + public void testCapitalizeFirst_empty() { + assertEquals("", IssueActivity.capitalizeFirst("")); + } + + @Test + public void testCapitalizeFirst_singleChar() { + // Guards the substring(1) boundary: s.length() == 1 must not throw. + assertEquals("A", IssueActivity.capitalizeFirst("a")); + } + + @Test + public void testCapitalizeFirst_singleCharAlreadyUpper() { + assertEquals("A", IssueActivity.capitalizeFirst("A")); + } + + @Test + public void testCapitalizeFirst_word() { + assertEquals("Hello", IssueActivity.capitalizeFirst("hello")); + } + + @Test + public void testCapitalizeFirst_wordAlreadyCapitalized() { + assertEquals("Hello", IssueActivity.capitalizeFirst("Hello")); + } + + @Test + public void testBuildUndoSnackbarMessage_voicemail() { + assertEquals("Left voicemail reported", + IssueActivity.buildUndoSnackbarMessage( + getApplicationContext(), Outcome.Status.VOICEMAIL)); + } + + @Test + public void testBuildUndoSnackbarMessage_contact() { + assertEquals("Made contact reported", + IssueActivity.buildUndoSnackbarMessage( + getApplicationContext(), Outcome.Status.CONTACT)); + } + + @Test + public void testBuildUndoSnackbarMessage_unavailable() { + assertEquals("Unavailable reported", + IssueActivity.buildUndoSnackbarMessage( + getApplicationContext(), Outcome.Status.UNAVAILABLE)); + } + + @Test + @Config(qualifiers = "es") + public void testBuildUndoSnackbarMessage_spanish_voicemail() { + assertEquals("Dejé mensaje de voz registrado", + IssueActivity.buildUndoSnackbarMessage( + getApplicationContext(), Outcome.Status.VOICEMAIL)); + } + + @Test + @Config(qualifiers = "es") + public void testBuildUndoSnackbarMessage_spanish_contact() { + assertEquals("Hice contacto registrado", + IssueActivity.buildUndoSnackbarMessage( + getApplicationContext(), Outcome.Status.CONTACT)); + } + + @Test + @Config(qualifiers = "es") + public void testBuildUndoSnackbarMessage_spanish_unavailable() { + assertEquals("No disponible registrado", + IssueActivity.buildUndoSnackbarMessage( + getApplicationContext(), Outcome.Status.UNAVAILABLE)); + } }