package org.commcare.views; import android.content.Context; import android.content.SharedPreferences; import android.graphics.Typeface; import android.os.Build; import android.os.Handler; import android.preference.PreferenceManager; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.util.TypedValue; import android.view.View; import android.view.View.OnLongClickListener; import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; import org.commcare.utils.BlockingActionsManager; import org.commcare.dalvik.R; import org.commcare.interfaces.WidgetChangedListener; import org.commcare.logging.AndroidLogger; import org.commcare.logic.PendingCalloutInterface; import org.commcare.models.ODKStorage; import org.commcare.preferences.FormEntryPreferences; import org.commcare.utils.CompoundIntentList; import org.commcare.utils.MarkupUtil; import org.commcare.views.widgets.DateTimeWidget; import org.commcare.views.widgets.IntentWidget; import org.commcare.views.widgets.QuestionWidget; import org.commcare.views.widgets.StringWidget; import org.commcare.views.widgets.TimeWidget; import org.commcare.views.widgets.WidgetFactory; import org.javarosa.core.model.FormIndex; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.services.Logger; import org.javarosa.form.api.FormEntryCaption; import org.javarosa.form.api.FormEntryPrompt; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; /** * @author carlhartung */ public class QuestionsView extends ScrollView implements OnLongClickListener, WidgetChangedListener { // starter random number for view IDs private final static int VIEW_ID = 12345; private final LinearLayout mView; private final LinearLayout.LayoutParams mLayout; private final ArrayList<QuestionWidget> widgets; private final ArrayList<View> dividers; private final int mQuestionFontsize; private WidgetChangedListener wcListener; private boolean hasListener = false; private int widgetIdCount = 0; private int mViewBannerCount = 0; private SpannableStringBuilder mGroupLabel; private final BlockingActionsManager blockingActionsManager; /** * If enabled, we use dividers between question prompts */ private static final boolean SEPERATORS_ENABLED = false; public QuestionsView(Context context, BlockingActionsManager blockingActionsManager) { super(context); SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); String question_font = settings.getString(FormEntryPreferences.KEY_FONT_SIZE, ODKStorage.DEFAULT_FONTSIZE); mQuestionFontsize = Integer.valueOf(question_font); widgets = new ArrayList<>(); dividers = new ArrayList<>(); mView = (LinearLayout) inflate(getContext(), R.layout.odkview_layout, null); mLayout = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.FILL_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); mGroupLabel = null; this.blockingActionsManager = blockingActionsManager; } public QuestionsView(Context context, FormEntryPrompt[] questionPrompts, FormEntryCaption[] groups, WidgetFactory factory, WidgetChangedListener wcl, BlockingActionsManager blockingActionsManager) { this(context, blockingActionsManager); if(wcl !=null){ hasListener = true; wcListener = wcl; } // display which group you are in as well as the question mGroupLabel = deriveGroupText(groups); String hintText = getHintText(questionPrompts); addHintText(hintText); boolean first = true; for (FormEntryPrompt p: questionPrompts) { if (!first) { View divider = new View(getContext()); if(SEPERATORS_ENABLED) { divider.setBackgroundResource(android.R.drawable.divider_horizontal_bright); divider.setMinimumHeight(3); } else { divider.setMinimumHeight(0); } dividers.add(divider); mView.addView(divider); } else { first = false; } QuestionWidget qw; // if question or answer type is not supported, use text widget qw = factory.createWidgetFromPrompt(p, getContext()); qw.setLongClickable(true); qw.setOnLongClickListener(this); qw.setId(VIEW_ID + widgetIdCount++); //Suppress the hint text if we bubbled it if(hintText != null) { qw.hideHintText(); } widgets.add(qw); mView.addView(qw, mLayout); qw.setChangedListeners(this, blockingActionsManager); } markLastStringWidget(); addView(mView); } private void removeQuestionFromIndex(int i) { int dividerIndex = Math.max(i - 1, 0); if (dividerIndex < dividers.size()) { mView.removeView(dividers.get(dividerIndex)); dividers.remove(dividerIndex); } if (i < widgets.size()) { mView.removeView(widgets.get(i)); widgets.get(i).unsetListeners(); widgets.remove(i); } } public void removeQuestionsFromIndex(ArrayList<Integer> indexes){ //Always gotta move backwards when removing, ensure that this list //goes backwards Collections.sort(indexes); Collections.reverse(indexes); for(int i=0; i< indexes.size(); i++){ removeQuestionFromIndex(indexes.get(i)); } } public void addQuestionToIndex(FormEntryPrompt fep, WidgetFactory factory, int i){ View divider = new View(getContext()); if(SEPERATORS_ENABLED) { divider.setBackgroundResource(android.R.drawable.divider_horizontal_bright); divider.setMinimumHeight(3); } else { divider.setMinimumHeight(0); } int dividerIndex = mViewBannerCount; if(i > 0) { dividerIndex += 2 * i - 1; } mView.addView(divider, getViewIndex(dividerIndex)); dividers.add(Math.max(0, i - 1), divider); QuestionWidget qw = factory.createWidgetFromPrompt(fep, getContext()); qw.setLongClickable(true); qw.setOnLongClickListener(this); qw.setId(VIEW_ID + widgetIdCount++); //Suppress the hint text if we bubbled it // if(hintText != null) { //TODO figure this out // qw.hideHintText(); // } widgets.add(i, qw); mView.addView(qw, getViewIndex(2 * i + mViewBannerCount), mLayout); qw.setChangedListeners(this, blockingActionsManager); } /** * @return a HashMap of answers entered by the user for this set of widgets */ public HashMap<FormIndex, IAnswerData> getAnswers() { HashMap<FormIndex, IAnswerData> answers = new HashMap<>(); for (QuestionWidget q : widgets) { // The FormEntryPrompt has the FormIndex, which is where the answer gets stored. The // QuestionWidget has the answer the user has entered. FormEntryPrompt p = q.getPrompt(); answers.put(p.getIndex(), q.getAnswer()); } return answers; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int newHeight = MeasureSpec.getSize(heightMeasureSpec); int oldHeight = this.getMeasuredHeight(); if (oldHeight == 0 || Math.abs(((newHeight * 1.0 - oldHeight) / oldHeight)) > .2) { // Update the frame size and hint height based on the new height for (QuestionWidget qw : this.widgets) { qw.updateFrameSize(newHeight); qw.updateHintHeight(newHeight/4); } } else { // Check to see if any of our QuestionWidgets have a hint text that was initially // displayed without proper height spec information for (QuestionWidget qw : this.widgets) { if (qw.hintTextNeedsHeightSpec) { qw.updateHintHeight(newHeight/4); } } } super.onMeasure(widthMeasureSpec, heightMeasureSpec); } private void updateConstraintRelevancies(QuestionWidget changedWidget) { if (hasListener) { wcListener.widgetEntryChanged(changedWidget); } } /** * Returns the hierarchy of groups to which the question belongs. */ private SpannableStringBuilder deriveGroupText(FormEntryCaption[] groups) { SpannableStringBuilder s = new SpannableStringBuilder(""); String t; String m; int i; // list all groups in one string for (FormEntryCaption g : groups) { i = g.getMultiplicity() + 1; t = g.getLongText(); m = g.getMarkdownText(); if(m != null){ Spannable markdownSpannable = MarkupUtil.returnMarkdown(getContext(), m); s.append(markdownSpannable); } else if (t != null && !t.trim().equals("")) { s.append(t); } else { continue; } if (g.repeats() && i > 0) { s.append(" (").append(String.valueOf(i)).append(")"); } s.append(" > "); } //remove the trailing " > " if(s.length() > 0) { s.delete(s.length() - 2, s.length()); } return s; } /** * Ugh, the coupling here sucks, but this returns the group label * to be used for this odk view. */ public SpannableStringBuilder getGroupLabel() { return mGroupLabel; } private String getHintText(FormEntryPrompt[] questionPrompts) { //Figure out if we share hint text between questions String hintText = null; if (questionPrompts.length > 1) { hintText = questionPrompts[0].getHintText(); for (FormEntryPrompt p : questionPrompts) { //If something doesn't have hint text at all, //bail String curHintText = p.getHintText(); //Otherwise see if it matches if (curHintText == null || !curHintText.equals(hintText)) { //If not, we can't do this trick hintText = null; break; } } } return hintText; } private void addHintText(String hintText) { if (hintText != null && !hintText.equals("")) { TextView mHelpText = new TextView(getContext()); mHelpText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, mQuestionFontsize - 3); mHelpText.setPadding(0, -5, 0, 7); // wrap to the widget of view mHelpText.setHorizontallyScrolling(false); mHelpText.setText(hintText); mHelpText.setTypeface(null, Typeface.ITALIC); mViewBannerCount++; mView.addView(mHelpText, mLayout); } } public void setFocus(Context context, int indexOfLastChangedWidget) { QuestionWidget widgetToFocus = null; if (indexOfLastChangedWidget != -1 && indexOfLastChangedWidget < widgets.size()) { widgetToFocus = widgets.get(indexOfLastChangedWidget); } else if (widgets.size() > 0) { widgetToFocus = widgets.get(0); } if (widgetToFocus != null) { scrollToWidget(widgetToFocus); widgetToFocus.setFocus(context); } } private void scrollToWidget(final QuestionWidget widget) { new Handler().post(new Runnable() { @Override public void run() { QuestionsView.this.scrollTo(0, widget.getTop()); } }); } /** * @param pendingIntentWidget - the widget for which a callout from form entry just occurred, * if there is one * @return the index of the widget that focus was restored to, or -1 if there was no * widget that just called out */ public int restoreFocusToQuestionThatCalledOut(Context context, QuestionWidget pendingIntentWidget) { if (pendingIntentWidget != null) { int index = widgets.indexOf(pendingIntentWidget); setFocus(context, index); return index; } return -1; } /** * Called when another activity returns information to answer this question. */ public void setBinaryData(Object answer, PendingCalloutInterface pendingCalloutInterface) { FormIndex questionFormIndex = pendingCalloutInterface.getPendingCalloutFormIndex(); if (questionFormIndex == null) { Logger.log(AndroidLogger.SOFT_ASSERT, "Unable to find question widget to attach pending data to."); return; } for (QuestionWidget q : widgets) { if (questionFormIndex.equals(q.getFormId())) { q.setBinaryData(answer); return; } } Logger.log(AndroidLogger.SOFT_ASSERT, "Unable to find question widget to attach pending data to."); } /** * @return true if the answer was cleared, false otherwise. */ public boolean clearAnswer() { // If there's only one widget, clear the answer. // If there are more, then force a long-press to clear the answer. if (widgets.size() == 1 && !widgets.get(0).getPrompt().isReadOnly()) { widgets.get(0).clearAnswer(); return true; } else { return false; } } public ArrayList<QuestionWidget> getWidgets() { return widgets; } public boolean isQuestionList() { return widgets.size() > 1; } @Override public void setOnFocusChangeListener(OnFocusChangeListener l) { for (QuestionWidget qw : widgets) { qw.setOnFocusChangeListener(l); } } public void teardownView() { for (QuestionWidget widget : widgets) { widget.unsetListeners(); widget.setOnCreateContextMenuListener(null); } wcListener = null; } @Override public boolean onLongClick(View v) { return false; } @Override public void cancelLongPress() { super.cancelLongPress(); for (QuestionWidget qw : widgets) { qw.cancelLongPress(); } } @Override public void widgetEntryChanged(QuestionWidget changedWidget) { updateConstraintRelevancies(changedWidget); markLastStringWidget(); } private void markLastStringWidget() { StringWidget last = null; for (QuestionWidget q: widgets) { if (q instanceof StringWidget) { if (last != null) { last.setLastQuestion(false); } last = (StringWidget)q; last.setLastQuestion(true); } } } /** * Translate question index to view index. * @param questionIndex Index in the list of questions. * @return Index of question's view in mView. */ private int getViewIndex(int questionIndex) { return questionIndex; } /** * Takes in a form entry prompt that is obtained generically and if there * is already one on screen (which, for instance, may have cached some of its data) * returns the object in use currently. */ public FormEntryPrompt getOnScreenPrompt(FormEntryPrompt prompt) { FormIndex index = prompt.getIndex(); for (QuestionWidget widget : this.getWidgets()) { if (widget.getFormId().equals(index)) { return widget.getPrompt(); } } return prompt; } public void restoreTimePickerData() { // On honeycomb and above this is handled by calling: // TimePicker.setSaveFromParentEnabled(false); // TimePicker.setSaveEnabled(true); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { //csims@dimagi.com - 22/08/2012 - For release only, fix immediately. //There is a _horribly obnoxious_ bug in TimePickers that messes up how they work //on screen rotation. We need to re-do any setAnswers that we perform on them after //onResume. try { if (getWidgets() != null) { for (QuestionWidget qw : getWidgets()) { if (qw instanceof DateTimeWidget) { ((DateTimeWidget)qw).setAnswer(); } else if (qw instanceof TimeWidget) { ((TimeWidget)qw).setAnswer(); } } } } catch (Exception e) { //if this fails, we _really_ don't want to mess anything up. this is a last minute //fix } } } public CompoundIntentList getAggregateIntentCallout() { CompoundIntentList compoundedCallout = null; for (QuestionWidget widget : this.getWidgets()) { if(widget instanceof IntentWidget) { boolean expectResult = compoundedCallout != null; compoundedCallout = ((IntentWidget)widget).addToCompoundIntent(compoundedCallout); if(compoundedCallout == null && expectResult) { return null; } } } if(compoundedCallout == null || compoundedCallout.getNumberOfCallouts() <= 1) { return null; } return compoundedCallout; } }