diff --git a/.github/workflows/changelog-to-fastlane.yml b/.github/workflows/changelog-to-fastlane.yml index 03ea30ea84..f37abbc109 100644 --- a/.github/workflows/changelog-to-fastlane.yml +++ b/.github/workflows/changelog-to-fastlane.yml @@ -9,7 +9,7 @@ on: permissions: actions: none checks: none - contents: read + contents: write deployments: none discussions: none id-token: none diff --git a/CHANGELOG.md b/CHANGELOG.md index a21f249d96..a6722a3997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased - 132 + +- Refine "Add card" workflow +- Validation flow improvements + ## v2.26.0 - 131 (2023-09-14) - Move "Archive mode" into "Display options" (previously "Show details") menu diff --git a/app/src/main/java/com/ziadoua/zcard/BarcodeSelectorActivity.java b/app/src/main/java/com/ziadoua/zcard/BarcodeSelectorActivity.java index e47500adc3..92d7b63643 100644 --- a/app/src/main/java/com/ziadoua/zcard/BarcodeSelectorActivity.java +++ b/app/src/main/java/com/ziadoua/zcard/BarcodeSelectorActivity.java @@ -16,7 +16,6 @@ import java.util.ArrayList; -import androidx.appcompat.app.ActionBar; import androidx.appcompat.widget.Toolbar; import com.ziadoua.zcard.databinding.BarcodeSelectorActivityBinding; @@ -66,10 +65,6 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { runOnUiThread(() -> { generateBarcodes(s.toString()); - - View noBarcodeButtonView = binding.noBarcode; - setButtonListener(noBarcodeButtonView, s.toString()); - noBarcodeButtonView.setEnabled(s.length() > 0); }); }, INPUT_DELAY); } @@ -95,17 +90,6 @@ private void generateBarcodes(String value) { mAdapter.setBarcodes(barcodes); } - private void setButtonListener(final View button, final String cardId) { - button.setOnClickListener(view -> { - Log.d(TAG, "Selected no barcode"); - Intent result = new Intent(); - result.putExtra(BARCODE_FORMAT, ""); - result.putExtra(BARCODE_CONTENTS, cardId); - BarcodeSelectorActivity.this.setResult(RESULT_OK, result); - finish(); - }); - } - @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { diff --git a/app/src/main/java/com/ziadoua/zcard/LoyaltyCardEditActivity.java b/app/src/main/java/com/ziadoua/zcard/LoyaltyCardEditActivity.java index 3e1fe02535..68dfd88735 100644 --- a/app/src/main/java/com/ziadoua/zcard/LoyaltyCardEditActivity.java +++ b/app/src/main/java/com/ziadoua/zcard/LoyaltyCardEditActivity.java @@ -262,7 +262,6 @@ private void extractIntentFields(Intent intent) { + ", updateLoyaltyCard=" + updateLoyaltyCard); } - @Override public void onSaveInstanceState(@NonNull Bundle savedInstanceState) { super.onSaveInstanceState(savedInstanceState); @@ -378,8 +377,15 @@ protected void onCreate(Bundle savedInstanceState) { storeFieldEdit.addTextChangedListener(new SimpleTextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - updateTempState(LoyaltyCardField.store, s.toString()); - generateIcon(s.toString()); + String storeName = s.toString().trim(); + updateTempState(LoyaltyCardField.store, storeName); + generateIcon(storeName); + + if (storeName.length() == 0) { + storeFieldEdit.setError(getString(R.string.field_must_not_be_empty)); + } else { + storeFieldEdit.setError(null); + } } }); @@ -411,6 +417,10 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { balanceField.setOnFocusChangeListener((v, hasFocus) -> { if (!hasFocus && !onResuming && !onRestoring) { + if (balanceField.getText().toString().isEmpty()) { + updateTempState(LoyaltyCardField.balance, BigDecimal.valueOf(0)); + } + balanceField.setText(Utils.formatBalanceWithoutCurrencySymbol(tempLoyaltyCard.balance, tempLoyaltyCard.balanceType)); } }); @@ -422,10 +432,12 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { try { BigDecimal balance = Utils.parseBalance(s.toString(), tempLoyaltyCard.balanceType); updateTempState(LoyaltyCardField.balance, balance); + balanceField.setError(null); validBalance = true; } catch (ParseException e) { - validBalance = false; e.printStackTrace(); + balanceField.setError(getString(R.string.balanceParsingFailed)); + validBalance = false; } } }); @@ -501,6 +513,12 @@ public void beforeTextChanged(CharSequence s, int start, int count, int after) { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { updateTempState(LoyaltyCardField.cardId, s.toString()); + + if (s.length() == 0) { + cardIdFieldView.setError(getString(R.string.field_must_not_be_empty)); + } else { + cardIdFieldView.setError(null); + } } }); @@ -946,7 +964,7 @@ public void onResume() { saveButton.setOnClickListener(v -> doSave()); saveButton.bringToFront(); - generateIcon(storeFieldEdit.getText().toString()); + generateIcon(storeFieldEdit.getText().toString().trim()); // It can't be null because we set it in updateTempState but SpotBugs insists it can be // NP_NULL_ON_SOME_PATH: Possible null pointer dereference and @@ -1365,7 +1383,7 @@ public void onColorSelected(int dialogId, int color) { thumbnailEditIcon.setBackgroundColor(Utils.needsDarkForeground(color) ? Color.BLACK : Color.WHITE); thumbnailEditIcon.setColorFilter(Utils.needsDarkForeground(color) ? Color.WHITE : Color.BLACK); - generateIcon(storeFieldEdit.getText().toString()); + generateIcon(storeFieldEdit.getText().toString().trim()); } // ColorPickerDialogListener callback @@ -1484,18 +1502,41 @@ private void doSave() { return; } + boolean hasError = false; + if (tempLoyaltyCard.store.isEmpty()) { - Snackbar.make(storeFieldEdit, R.string.noStoreError, Snackbar.LENGTH_LONG).show(); - return; + storeFieldEdit.setError(getString(R.string.field_must_not_be_empty)); + + // Focus element + tabs.selectTab(tabs.getTabAt(0)); + storeFieldEdit.requestFocus(); + + hasError = true; } if (tempLoyaltyCard.cardId.isEmpty()) { - Snackbar.make(cardIdFieldView, R.string.noCardIdError, Snackbar.LENGTH_LONG).show(); - return; + cardIdFieldView.setError(getString(R.string.field_must_not_be_empty)); + + // Focus element if first error element + if (!hasError) { + tabs.selectTab(tabs.getTabAt(0)); + cardIdFieldView.requestFocus(); + hasError = true; + } } if (!validBalance) { - Snackbar.make(balanceField, getString(R.string.parsingBalanceFailed, balanceField.getText().toString()), Snackbar.LENGTH_LONG).show(); + balanceField.setError(getString(R.string.balanceParsingFailed)); + + // Focus element if first error element + if (!hasError) { + tabs.selectTab(tabs.getTabAt(1)); + balanceField.requestFocus(); + hasError = true; + } + } + + if (hasError) { return; } @@ -1631,7 +1672,7 @@ private void generateBarcode() { String cardIdString = tempLoyaltyCard.barcodeId != null ? tempLoyaltyCard.barcodeId : tempLoyaltyCard.cardId; CatimaBarcode barcodeFormat = tempLoyaltyCard.barcodeType; - if (cardIdString == null || barcodeFormat == null) { + if (cardIdString == null || cardIdString.isEmpty() || barcodeFormat == null) { barcodeImageLayout.setVisibility(View.GONE); return; } diff --git a/app/src/main/java/com/ziadoua/zcard/ScanActivity.java b/app/src/main/java/com/ziadoua/zcard/ScanActivity.java index 605c3ace2f..ac945d6b1f 100644 --- a/app/src/main/java/com/ziadoua/zcard/ScanActivity.java +++ b/app/src/main/java/com/ziadoua/zcard/ScanActivity.java @@ -1,5 +1,8 @@ package com.ziadoua.zcard; +import static protect.card_locker.BarcodeSelectorActivity.BARCODE_CONTENTS; +import static protect.card_locker.BarcodeSelectorActivity.BARCODE_FORMAT; + import android.Manifest; import android.app.Activity; import android.content.ActivityNotFoundException; @@ -9,6 +12,7 @@ import android.net.Uri; import android.os.Bundle; import android.provider.Settings; +import android.text.InputType; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; @@ -16,15 +20,22 @@ import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.zxing.ResultPoint; import com.google.zxing.client.android.Intents; import com.journeyapps.barcodescanner.BarcodeCallback; @@ -44,7 +55,6 @@ * originally licensed under Apache 2.0 */ public class ScanActivity extends CatimaAppCompatActivity { - private ScanActivityBinding binding; private CustomBarcodeScannerBinding customBarcodeScannerBinding; private static final String TAG = "zCard"; @@ -65,6 +75,9 @@ public class ScanActivity extends CatimaAppCompatActivity { // can't use the pre-made contract because that launches the file manager for image type instead of gallery private ActivityResultLauncher photoPickerLauncher; + static final String STATE_SCANNER_ACTIVE = "scannerActive"; + private boolean mScannerActive = true; + private void extractIntentFields(Intent intent) { final Bundle b = intent.getExtras(); cardId = b != null ? b.getString(LoyaltyCardEditActivity.BUNDLE_CARDID) : null; @@ -87,8 +100,36 @@ protected void onCreate(Bundle savedInstanceState) { manualAddLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> handleActivityResult(Utils.SELECT_BARCODE_REQUEST, result.getResultCode(), result.getData())); photoPickerLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> handleActivityResult(Utils.BARCODE_IMPORT_FROM_IMAGE_FILE, result.getResultCode(), result.getData())); - customBarcodeScannerBinding.addFromImage.setOnClickListener(this::addFromImage); - customBarcodeScannerBinding.addManually.setOnClickListener(this::addManually); + customBarcodeScannerBinding.fabOtherOptions.setOnClickListener(view -> { + setScannerActive(false); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(ScanActivity.this); + builder.setTitle(getString(R.string.add_a_card_in_a_different_way)); + builder.setItems( + new CharSequence[]{ + getString(R.string.addWithoutBarcode), + getString(R.string.addManually), + getString(R.string.addFromImage) + }, + (dialogInterface, i) -> { + switch (i) { + case 0: + addWithoutBarcode(); + break; + case 1: + addManually(); + break; + case 2: + addFromImage(); + break; + default: + throw new IllegalArgumentException("Unknown 'Add a card in a different way' dialog option"); + } + } + ); + builder.setOnCancelListener(dialogInterface -> setScannerActive(true)); + builder.show(); + }); barcodeScannerView = binding.zxingBarcodeScanner; @@ -106,8 +147,8 @@ protected void onCreate(Bundle savedInstanceState) { public void barcodeResult(BarcodeResult result) { Intent scanResult = new Intent(); Bundle scanResultBundle = new Bundle(); - scanResultBundle.putString(BarcodeSelectorActivity.BARCODE_CONTENTS, result.getText()); - scanResultBundle.putString(BarcodeSelectorActivity.BARCODE_FORMAT, result.getBarcodeFormat().name()); + scanResultBundle.putString(BARCODE_CONTENTS, result.getText()); + scanResultBundle.putString(BARCODE_FORMAT, result.getBarcodeFormat().name()); if (addGroup != null) { scanResultBundle.putString(LoyaltyCardEditActivity.BUNDLE_ADDGROUP, addGroup); } @@ -126,7 +167,11 @@ public void possibleResultPoints(List resultPoints) { @Override protected void onResume() { super.onResume(); - capture.onResume(); + + if (mScannerActive) { + capture.onResume(); + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { showCameraPermissionMissingText(false); } @@ -146,9 +191,18 @@ protected void onDestroy() { } @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - capture.onSaveInstanceState(outState); + protected void onSaveInstanceState(Bundle savedInstanceState) { + super.onSaveInstanceState(savedInstanceState); + capture.onSaveInstanceState(savedInstanceState); + + savedInstanceState.putBoolean(STATE_SCANNER_ACTIVE, mScannerActive); + } + + @Override + public void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + + mScannerActive = savedInstanceState.getBoolean(STATE_SCANNER_ACTIVE); } @Override @@ -190,19 +244,20 @@ public boolean onOptionsItemSelected(MenuItem item) { return super.onOptionsItemSelected(item); } - private void handleActivityResult(int requestCode, int resultCode, Intent intent) { - super.onActivityResult(requestCode, resultCode, intent); - - BarcodeValues barcodeValues = Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this); - - if (barcodeValues.isEmpty()) { - return; + private void setScannerActive(boolean isActive) { + if (isActive) { + barcodeScannerView.resume(); + } else { + barcodeScannerView.pause(); } + mScannerActive = isActive; + } + private void returnResult(String barcodeContents, String barcodeFormat) { Intent manualResult = new Intent(); Bundle manualResultBundle = new Bundle(); - manualResultBundle.putString(BarcodeSelectorActivity.BARCODE_CONTENTS, barcodeValues.content()); - manualResultBundle.putString(BarcodeSelectorActivity.BARCODE_FORMAT, barcodeValues.format()); + manualResultBundle.putString(BARCODE_CONTENTS, barcodeContents); + manualResultBundle.putString(BARCODE_FORMAT, barcodeFormat); if (addGroup != null) { manualResultBundle.putString(LoyaltyCardEditActivity.BUNDLE_ADDGROUP, addGroup); } @@ -211,7 +266,84 @@ private void handleActivityResult(int requestCode, int resultCode, Intent intent finish(); } - public void addManually(View view) { + private void handleActivityResult(int requestCode, int resultCode, Intent intent) { + super.onActivityResult(requestCode, resultCode, intent); + + BarcodeValues barcodeValues = Utils.parseSetBarcodeActivityResult(requestCode, resultCode, intent, this); + + if (barcodeValues.isEmpty()) { + setScannerActive(true); + return; + } + + returnResult(barcodeValues.content(), barcodeValues.format()); + } + + private void addWithoutBarcode() { + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); + + builder.setOnCancelListener(dialogInterface -> setScannerActive(true)); + + // Header + builder.setTitle(R.string.addWithoutBarcode); + + // Layout + LinearLayout layout = new LinearLayout(this); + layout.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + int contentPadding = getResources().getDimensionPixelSize(R.dimen.alert_dialog_content_padding); + params.leftMargin = contentPadding; + params.topMargin = contentPadding / 2; + params.rightMargin = contentPadding; + + // Description + TextView currentTextview = new TextView(this); + currentTextview.setText(getString(R.string.enter_card_id)); + currentTextview.setLayoutParams(params); + layout.addView(currentTextview); + + // EditText with spacing + final EditText input = new EditText(this); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setLayoutParams(params); + layout.addView(input); + + // Set layout + builder.setView(layout); + + // Buttons + builder.setPositiveButton(getString(R.string.ok), (dialog, which) -> { + returnResult(input.getText().toString(), ""); + }); + builder.setNegativeButton(getString(R.string.cancel), (dialog, which) -> dialog.cancel()); + AlertDialog dialog = builder.create(); + + // Now that the dialog exists, we can bind something that affects the OK button + input.addTextChangedListener(new SimpleTextWatcher() { + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (s.length() == 0) { + input.setError(getString(R.string.card_id_must_not_be_empty)); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); + } else { + input.setError(null); + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true); + } + } + }); + + dialog.show(); + + // Disable button (must be done **after** dialog is shown to prevent crash + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false); + // Set focus on input field + dialog.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + input.requestFocus(); + } + + public void addManually() { Intent i = new Intent(getApplicationContext(), BarcodeSelectorActivity.class); if (cardId != null) { final Bundle b = new Bundle(); @@ -221,7 +353,7 @@ public void addManually(View view) { manualAddLauncher.launch(i); } - public void addFromImage(View view) { + public void addFromImage() { PermissionUtils.requestStorageReadPermission(this, PERMISSION_SCAN_ADD_FROM_IMAGE); } @@ -236,6 +368,7 @@ private void addFromImageAfterPermission() { try { photoPickerLauncher.launch(chooserIntent); } catch (ActivityNotFoundException e) { + setScannerActive(true); Toast.makeText(getApplicationContext(), R.string.failedLaunchingPhotoPicker, Toast.LENGTH_LONG).show(); Log.e(TAG, "No activity found to handle intent", e); } @@ -288,6 +421,7 @@ public void onMockedRequestPermissionsResult(int requestCode, @NonNull String[] if (granted) { addFromImageAfterPermission(); } else { + setScannerActive(true); Toast.makeText(this, R.string.storageReadPermissionRequired, Toast.LENGTH_LONG).show(); } } diff --git a/app/src/main/res/layout/barcode_selector_activity.xml b/app/src/main/res/layout/barcode_selector_activity.xml index e2dc32bb30..d7c5ae6c42 100644 --- a/app/src/main/res/layout/barcode_selector_activity.xml +++ b/app/src/main/res/layout/barcode_selector_activity.xml @@ -30,13 +30,13 @@ app:layout_constraintBottom_toTopOf="@+id/barcodeIdLayout" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - android:text="@string/enterBarcodeInstructions" /> + android:text="@string/manually_enter_barcode_instructions" /> @@ -57,24 +57,11 @@ android:singleLine="true" /> -