diff --git a/collect_app/src/androidTest/assets/forms/likert_test.xml b/collect_app/src/androidTest/assets/forms/likert_test.xml new file mode 100755 index 00000000000..d53fdcfb161 --- /dev/null +++ b/collect_app/src/androidTest/assets/forms/likert_test.xml @@ -0,0 +1,419 @@ + + + + All widgets likert icon + + + + + Strongly Agree + jr://images/famous.jpg + + + Strongly Disagree + jr://images/famous.jpg + + + Agree + jr://images/famous.jpg + + + Agree + jr://images/famous.jpg + + + Strongly Disagree + jr://images/famous.jpg + + + Agree + jr://images/famous.jpg + + + Strongly Agree + jr://images/famous.jpg + + + Disagree + jr://images/famous.jpg + + + Strongly Agree + jr://images/famous.jpg + + + Neutral + jr://images/not_Found.jpg + + + Disagree + jr://images/famous.jpg + + + Strongly Disagree + jr://images/famous.jpg + + + Disagree + jr://images/famous.jpg + + + Neutral + jr://images/famous.jpg + + + A + + + B + + + C + + + A1 + + + A2 + + + A3 + + + B1 + + + B2 + + + B3 + + + C1 + + + C2 + + + C3 + + + C4 + + + A1A + + + A1B + + + B1A + + + B1B + + + + + + + + + + + + + + + + + + + + + + + + + static_instance-level1-0 + a + + + static_instance-level1-1 + b + + + static_instance-level1-2 + c + + + + + + + static_instance-level2-0 + a + a1 + + + static_instance-level2-1 + a + a2 + + + static_instance-level2-2 + a + a3 + + + static_instance-level2-3 + b + b1 + + + static_instance-level2-4 + b + b2 + + + static_instance-level2-5 + b + b3 + + + static_instance-level2-6 + c + c1 + + + static_instance-level2-7 + c + c2 + + + static_instance-level2-8 + c + c3 + + + static_instance-level2-9 + c + c4 + + + + + + + static_instance-level3-0 + a1a + a1 + + + static_instance-level3-1 + a1b + a1 + + + static_instance-level3-2 + b1a + b1 + + + static_instance-level3-3 + b1b + b1 + + + + + + + + + + + + + + + + + + + Likert type widget (happy case) + + + choice_0 + + + + choice_1 + + + + choice_2 + + + + choice_3 + + + + choice_4 + + + + + Likert type widget with images (happy case) + + + + + + + + + + + + + + Insufficient text provided + + + choice_0 + + + + choice_1 + + + + choice_2 + + + + choice_3 + + + + + Insufficient images provided + + + + + + + neutral + + + + + + + + + Image cannot be found + + + + + + + + + + + + + + When there is a missing Text + + + choice_0 + + + + choice_1 + + + + choice_2 + + + + choice_3 + + + + + + + + + a + + + + b + + + + c + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/collect_app/src/androidTest/assets/media/famous.jpg b/collect_app/src/androidTest/assets/media/famous.jpg new file mode 100644 index 00000000000..d8071cd75ce Binary files /dev/null and b/collect_app/src/androidTest/assets/media/famous.jpg differ diff --git a/collect_app/src/androidTest/java/org/odk/collect/android/formentry/LikertTest.java b/collect_app/src/androidTest/java/org/odk/collect/android/formentry/LikertTest.java new file mode 100644 index 00000000000..07c8fc4a8ca --- /dev/null +++ b/collect_app/src/androidTest/java/org/odk/collect/android/formentry/LikertTest.java @@ -0,0 +1,158 @@ +package org.odk.collect.android.formentry; + +import android.Manifest; + +import androidx.test.espresso.intent.rule.IntentsTestRule; +import androidx.test.rule.GrantPermissionRule; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.RuleChain; +import org.odk.collect.android.R; +import org.odk.collect.android.activities.FormEntryActivity; +import org.odk.collect.android.support.CopyFormRule; +import org.odk.collect.android.support.ResetStateRule; +import org.odk.collect.android.test.FormLoadingUtils; + +import java.util.Collections; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.isChecked; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isNotChecked; +import static androidx.test.espresso.matcher.ViewMatchers.withClassName; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.endsWith; +import static org.hamcrest.Matchers.startsWith; +import static org.odk.collect.android.test.CustomMatchers.withIndex; + +public class LikertTest { + private static final String LIKERT_TEST_FORM = "likert_test.xml"; + + @Rule + public IntentsTestRule activityTestRule = FormLoadingUtils.getFormActivityTestRuleFor(LIKERT_TEST_FORM); + + @Rule + public RuleChain copyFormChain = RuleChain + .outerRule(GrantPermissionRule.grant( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE, + Manifest.permission.CAMERA) + ) + .around(new ResetStateRule()) + .around(new CopyFormRule(LIKERT_TEST_FORM, Collections.singletonList("famous.jpg"))); + + @Test + public void allText_canClick() { + openWidgetList(); + onView(withText("Likert Widget")).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).check(matches(isChecked())); + } + + @Test + public void allImages_canClick() { + openWidgetList(); + onView(withText("Likert Image Widget")).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).check(matches(isChecked())); + } + + @Test + public void insufficientText_canClick() { + openWidgetList(); + onView(withText("Likert Widget Error")).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).check(matches(isChecked())); + } + + @Test + public void insufficientImages_canClick() { + openWidgetList(); + onView(withText("Likert Image Widget Error")).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).check(matches(isChecked())); + } + + @Test + public void missingImage_canClick() { + openWidgetList(); + onView(withText("Likert Image Widget Error2")).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).check(matches(isChecked())); + } + + @Test + public void missingText_canClick() { + openWidgetList(); + onView(withText("Likert Missing text Error")).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).check(matches(isChecked())); + } + + @Test + public void onlyOneRemainsClicked() { + openWidgetList(); + onView(withText("Likert Image Widget")).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).check(matches(isChecked())); + onView(withIndex(withClassName(endsWith("RadioButton")), 2)).perform(click()); + onView(withIndex(withClassName(endsWith("RadioButton")), 2)).check(matches(isChecked())); + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).check(matches(isNotChecked())); + } + + @Test + public void testImagesLoad() { + openWidgetList(); + onView(withText("Likert Image Widget")).perform(click()); + + for (int i = 0; i < 5; i++) { + onView(withIndex(withClassName(endsWith("RadioButton")), i)).check(matches(isDisplayed())); + } + } + + @Test + public void updateTest_SelectionChangeAtOneCascadeLevelWithLikert_ShouldUpdateNextLevels() { + openWidgetList(); + onView(withText("Cascading likert")).perform(click()); + + // No choices should be shown for levels 2 and 3 when no selection is made for level 1 + onView(withText(startsWith("Level1"))).perform(click()); + onView(withText("A1")).check(doesNotExist()); + onView(withText("B1")).check(doesNotExist()); + onView(withText("C1")).check(doesNotExist()); + onView(withText("A1A")).check(doesNotExist()); + + // Selecting C for level 1 should only reveal options for C at level 2 + // and selecting C3 for level 2 shouldn't reveal options in level 3 + onView(withIndex(withClassName(endsWith("RadioButton")), 2)).perform(click()); + onView(withText("C1")).check(matches(isDisplayed())); + onView(withText("C4")).check(matches(isDisplayed())); + onView(withText("A1")).check(doesNotExist()); + onView(withText("B1")).check(doesNotExist()); + onView(withIndex(withClassName(endsWith("RadioButton")), 5)).perform(click()); + onView(withText("A1A")).check(doesNotExist()); + + // Selecting A for level 1 should reveal options for A at level 2 + onView(withIndex(withClassName(endsWith("RadioButton")), 0)).perform(click()); + onView(withText("A1")).check(matches(isDisplayed())); + onView(withText("A1A")).check(doesNotExist()); + onView(withText("B1")).check(doesNotExist()); + onView(withText("C1")).check(doesNotExist()); + + // Selecting A1 for level 2 should reveal options for A1 at level 3 + onView(withIndex(withClassName(endsWith("RadioButton")), 3)).perform(click()); + onView(withText("A1A")).check(matches(isDisplayed())); + onView(withText("B1A")).check(doesNotExist()); + onView(withText("B1")).check(doesNotExist()); + onView(withText("C1")).check(doesNotExist()); + } + + private void openWidgetList() { + onView(withId(R.id.menu_goto)).perform(click()); + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/utilities/WidgetAppearanceUtils.java b/collect_app/src/main/java/org/odk/collect/android/utilities/WidgetAppearanceUtils.java index c5e555f860e..649e7a860f3 100644 --- a/collect_app/src/main/java/org/odk/collect/android/utilities/WidgetAppearanceUtils.java +++ b/collect_app/src/main/java/org/odk/collect/android/utilities/WidgetAppearanceUtils.java @@ -53,6 +53,7 @@ public class WidgetAppearanceUtils { public static final String AUTOCOMPLETE = "autocomplete"; public static final String LIST_NO_LABEL = "list-nolabel"; public static final String LIST = "list"; + public static final String LIKERT = "likert"; public static final String LABEL = "label"; public static final String IMAGE_MAP = "image-map"; public static final String NO_BUTTONS = "no-buttons"; diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/LikertWidget.java b/collect_app/src/main/java/org/odk/collect/android/widgets/LikertWidget.java new file mode 100644 index 00000000000..170962ebce7 --- /dev/null +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/LikertWidget.java @@ -0,0 +1,355 @@ +package org.odk.collect.android.widgets; + +import java.io.File; +import java.util.HashMap; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Bitmap; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.appcompat.widget.AppCompatRadioButton; + +import org.javarosa.core.model.SelectChoice; +import org.javarosa.core.model.data.IAnswerData; +import org.javarosa.core.model.data.SelectOneData; +import org.javarosa.core.model.data.helper.Selection; +import org.javarosa.core.reference.InvalidReferenceException; +import org.javarosa.core.reference.ReferenceManager; +import org.javarosa.form.api.FormEntryCaption; +import org.odk.collect.android.R; +import org.odk.collect.android.external.ExternalSelectChoice; +import org.odk.collect.android.formentry.questions.QuestionDetails; +import org.odk.collect.android.utilities.FileUtils; +import org.odk.collect.android.utilities.ViewIds; + +import timber.log.Timber; + +@SuppressLint("ViewConstructor") +public class LikertWidget extends ItemsWidget { + + private LinearLayout view; + private RadioButton checkedButton; + private final LinearLayout.LayoutParams linearLayoutParams = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, 1); + private final LayoutParams textViewParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + private final LayoutParams imageViewParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + private final LayoutParams radioButtonsParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); + private final LayoutParams buttonViewParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); + private final LayoutParams leftLineViewParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 2); + private final LayoutParams rightLineViewParams = new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 2); + + HashMap buttonsToName; + + public LikertWidget(Context context, QuestionDetails questionDetails) { + super(context, questionDetails); + + setMainViewLayoutParameters(); + setStructures(); + + setButtonListener(); + setSavedButton(); + addAnswerView(view); + } + + public void setMainViewLayoutParameters() { + view = new LinearLayout(getContext()); + view.setOrientation(LinearLayout.HORIZONTAL); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + view.setLayoutParams(params); + } + + // Inserts the selected button from a saved state + public void setSavedButton() { + if (getFormEntryPrompt().getAnswerValue() != null) { + String name = ((Selection) getFormEntryPrompt().getAnswerValue() + .getValue()).getValue(); + for (RadioButton bu: buttonsToName.keySet()) { + if (buttonsToName.get(bu).equals(name)) { + checkedButton = bu; + checkedButton.setChecked(true); + } + } + } + } + + @Override + public IAnswerData getAnswer() { + if (checkedButton == null) { + return null; + } else { + int selectedIndex = -1; + for (int i = 0; i < items.size(); i++) { + if (items.get(i).getValue().equals(buttonsToName.get(checkedButton))) { + selectedIndex = i; + } + } + if (selectedIndex == -1) { + return null; + } + SelectChoice sc = items.get(selectedIndex); + return new SelectOneData(new Selection(sc)); + } + } + + @Override + protected void addAnswerView(View v) { + if (v == null) { + Timber.e("cannot add a null view as an answerView"); + return; + } + // default place to add answer + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + params.addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE); + params.addRule(RelativeLayout.BELOW, getHelpTextLayout().getId()); + params.setMargins(10, 0, 10, 0); + addView(v, params); + } + + public void setStructures() { + buttonsToName = new HashMap<>(); + for (int i = 0; i < items.size(); i++) { + RelativeLayout buttonView = new RelativeLayout(this.getContext()); + buttonViewParams.addRule(CENTER_IN_PARENT, TRUE); + buttonView.setLayoutParams(buttonViewParams); + RadioButton button = getRadioButton(i); + + buttonsToName.put(button, items.get(i).getValue()); + buttonView.addView(button); + + if (i == 0) { + addLine(true, false, button, buttonView); + } else if (i == items.size() - 1) { + addLine(false, true, button, buttonView); + } else { + addLine(false, false, button, buttonView); + } + + LinearLayout optionView = getLinearLayout(); + optionView.addView(buttonView); + + ImageView imgView = getImageAsImageView(i); + // checks if image is set or valid + if (imgView != null) { + optionView.addView(imgView); + } + TextView choice = getTextView(); + choice.setText(getFormEntryPrompt().getSelectChoiceText(items.get(i))); + + optionView.addView(choice); + + optionView.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + RadioButton r = button; + if (checkedButton != null) { + checkedButton.setChecked(false); + } + checkedButton = r; + checkedButton.setChecked(true); + widgetValueChanged(); + } + }); + view.addView(optionView); + } + } + + // Adds lines to the button's side + public void addLine(boolean left, boolean right, RadioButton button, RelativeLayout buttonView) { + // left line + View leftLineView = new View(this.getContext()); + leftLineViewParams.addRule(RelativeLayout.LEFT_OF, button.getId()); + leftLineViewParams.addRule(CENTER_IN_PARENT, TRUE); + leftLineView.setLayoutParams(leftLineViewParams); + leftLineView.setBackgroundColor(getResources().getColor(R.color.gray600)); + + // right line + View rightLineView = new View(this.getContext()); + rightLineViewParams.addRule(RelativeLayout.RIGHT_OF, button.getId()); + rightLineViewParams.addRule(CENTER_IN_PARENT, TRUE); + rightLineView.setLayoutParams(rightLineViewParams); + rightLineView.setBackgroundColor(getResources().getColor(R.color.gray600)); + + if (left) { + if (isRTL()) { + rightLineView.setVisibility(View.INVISIBLE); + } else { + leftLineView.setVisibility(View.INVISIBLE); + } + } + buttonView.addView(leftLineView); + if (right) { + if (isRTL()) { + leftLineView.setVisibility(View.INVISIBLE); + } else { + rightLineView.setVisibility(View.INVISIBLE); + } + } + + buttonView.addView(rightLineView); + } + + // Creates image view for choice + public ImageView getImageView() { + ImageView view = new ImageView(getContext()); + view.setLayoutParams(imageViewParams); + return view; + } + + public RadioButton getRadioButton(int i) { + AppCompatRadioButton button = new AppCompatRadioButton(getContext()); + button.setId(ViewIds.generateViewId()); + button.setEnabled(!getFormEntryPrompt().isReadOnly()); + button.setFocusable(!getFormEntryPrompt().isReadOnly()); + radioButtonsParams.addRule(CENTER_HORIZONTAL, TRUE); + button.setLayoutParams(radioButtonsParams); + // This the adds the negated margins to reduce the extra padding of the button. + // It is done this way to get the width of the button which has to be done after rendering + ViewTreeObserver vto = button.getViewTreeObserver(); + // This variable is to prevent an infinite loop for rendering the button. + final Boolean[] paramsSet = {false}; + vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + if (!paramsSet[0]) { + int width = button.getWidth(); + radioButtonsParams.setMargins(-width / 5, 0, -width / 5, 0); + button.setLayoutParams(radioButtonsParams); + paramsSet[0] = true; + } + } + }); + button.setGravity(Gravity.CENTER); + return button; + } + + // Creates text view for choice + public TextView getTextView() { + TextView view = new TextView(getContext()); + view.setGravity(Gravity.CENTER); + view.setPadding(2, 2, 2, 2); + view.setLayoutParams(textViewParams); + return view; + } + + // Linear Layout for new choice + public LinearLayout getLinearLayout() { + LinearLayout optionView = new LinearLayout(getContext()); + optionView.setGravity(Gravity.CENTER); + optionView.setLayoutParams(linearLayoutParams); + linearLayoutParams.setMargins(-1, 0, -1, 0); + optionView.setOrientation(LinearLayout.VERTICAL); + return optionView; + } + + public void setButtonListener() { + for (RadioButton button: buttonsToName.keySet()) { + button.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + RadioButton r = (RadioButton) v; + if (checkedButton != null) { + checkedButton.setChecked(false); + } + checkedButton = r; + checkedButton.setChecked(true); + widgetValueChanged(); + } + }); + } + } + + public ImageView getImageAsImageView(int index) { + ImageView view = getImageView(); + String imageURI; + if (items.get(index) instanceof ExternalSelectChoice) { + imageURI = ((ExternalSelectChoice) items.get(index)).getImage(); + } else { + imageURI = getFormEntryPrompt().getSpecialFormSelectChoiceText(items.get(index), + FormEntryCaption.TEXT_FORM_IMAGE); + } + if (imageURI != null) { + String error = setImageFromOtherSource(imageURI, view); + if (error != null) { + return null; + } + return view; + } else { + return null; + } + } + + public String setImageFromOtherSource(String imageURI, ImageView imageView) { + String errorMsg = null; + try { + String imageFilename = + ReferenceManager.instance().DeriveReference(imageURI).getLocalURI(); + final File imageFile = new File(imageFilename); + if (imageFile.exists()) { + Bitmap b = null; + try { + DisplayMetrics metrics = getContext().getResources().getDisplayMetrics(); + int screenWidth = metrics.widthPixels; + int screenHeight = metrics.heightPixels; + b = FileUtils.getBitmapScaledToDisplay(imageFile, screenHeight, screenWidth); + } catch (OutOfMemoryError e) { + errorMsg = "ERROR: " + e.getMessage(); + } + + if (b != null) { + imageView.setAdjustViewBounds(true); + imageView.setImageBitmap(b); + } else if (errorMsg == null) { + // Loading the image failed. The image work when in .jpg format + errorMsg = getContext().getString(R.string.file_invalid, imageFile); + + } + } else { + errorMsg = getContext().getString(R.string.file_missing, imageFile); + } + if (errorMsg != null) { + Timber.e(errorMsg); + } + + } catch (InvalidReferenceException e) { + Timber.e(e, "Invalid image reference due to %s ", e.getMessage()); + } + return errorMsg; + } + + @Override + public void setOnLongClickListener(OnLongClickListener l) { + for (RadioButton r : buttonsToName.keySet()) { + r.setOnLongClickListener(l); + } + } + + @Override + public void cancelLongPress() { + super.cancelLongPress(); + for (RadioButton r : buttonsToName.keySet()) { + r.cancelLongPress(); + } + } + + @Override + public void clearAnswer() { + if (checkedButton != null) { + checkedButton.setChecked(false); + } + checkedButton = null; + widgetValueChanged(); + } +} diff --git a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java index cf1bee06699..edee7885a60 100644 --- a/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java +++ b/collect_app/src/main/java/org/odk/collect/android/widgets/WidgetFactory.java @@ -146,6 +146,8 @@ public static QuestionWidget createWidgetFromPrompt(FormEntryPrompt prompt, Cont questionWidget = new SpinnerWidget(context, questionDetails, appearance.contains(WidgetAppearanceUtils.QUICK)); } else if (appearance.contains(WidgetAppearanceUtils.SEARCH) || appearance.contains(WidgetAppearanceUtils.AUTOCOMPLETE)) { questionWidget = new SelectOneSearchWidget(context, questionDetails, appearance.contains(WidgetAppearanceUtils.QUICK)); + } else if (appearance.contains(WidgetAppearanceUtils.LIKERT)) { + questionWidget = new LikertWidget(context, questionDetails); } else if (appearance.contains(WidgetAppearanceUtils.LIST_NO_LABEL)) { questionWidget = new ListWidget(context, questionDetails, false, appearance.contains(WidgetAppearanceUtils.QUICK)); } else if (appearance.contains(WidgetAppearanceUtils.LIST)) {