package org.commcare.views.widgets; import android.app.AlertDialog; import android.content.Context; import android.content.DialogInterface; import android.content.SharedPreferences; import android.graphics.Rect; import android.graphics.Typeface; import android.preference.PreferenceManager; import android.text.Spannable; import android.text.TextPaint; import android.text.method.LinkMovementMethod; import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.Log; import android.util.TypedValue; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.animation.Animation; import android.view.animation.Transformation; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView.ScaleType; import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; import org.commcare.dalvik.R; import org.commcare.interfaces.WidgetChangedListener; import org.commcare.logging.AndroidLogger; import org.commcare.models.ODKStorage; import org.commcare.preferences.FormEntryPreferences; import org.commcare.utils.BlockingActionsManager; import org.commcare.utils.DelayedBlockingAction; import org.commcare.utils.FileUtil; import org.commcare.utils.FormUploadUtil; import org.commcare.utils.MarkupUtil; import org.commcare.utils.StringUtils; import org.commcare.views.ShrinkingTextView; import org.commcare.views.ViewUtil; import org.commcare.views.media.MediaLayout; import org.javarosa.core.model.FormIndex; import org.javarosa.core.model.QuestionDataExtension; import org.javarosa.core.model.QuestionExtensionReceiver; import org.javarosa.core.model.SelectChoice; import org.javarosa.core.model.data.AnswerDataFactory; import org.javarosa.core.model.data.IAnswerData; import org.javarosa.core.model.data.InvalidData; import org.javarosa.core.services.Logger; import org.javarosa.form.api.FormEntryCaption; import org.javarosa.form.api.FormEntryPrompt; import java.io.File; public abstract class QuestionWidget extends LinearLayout implements QuestionExtensionReceiver { private final static String TAG = QuestionWidget.class.getSimpleName(); private final LinearLayout.LayoutParams mLayout; protected final FormEntryPrompt mPrompt; protected final int mQuestionFontSize; protected final int mAnswerFontSize; protected final static String ACQUIREFIELD = "acquire"; //the height of the "Frame" available to this widget. The frame //is the size of the parent that is available (it is roughly //the window without the keyboard/top bars/etc.) //Note that this value is only populated after the widget is //drawn for now. private int mFrameHeight = -1; protected TextView mQuestionText; private FrameLayout helpPlaceholder; private ShrinkingTextView mHintText; private View warningView; //Whether this question widget needs to request focus on //its next draw, due to a new element having been added (which couldn't have //requested focus yet due to having not been layed out) private boolean focusPending = false; protected WidgetChangedListener widgetChangedListener; protected BlockingActionsManager blockingActionsManager; public boolean hintTextNeedsHeightSpec = false; public QuestionWidget(Context context, FormEntryPrompt p) { super(context); mPrompt = p; //this is pretty sketch but is the only way to make the required background to work trivially for now this.setClipToPadding(false); this.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { QuestionWidget.this.acceptFocus(); } }); SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); String question_font = settings.getString(FormEntryPreferences.KEY_FONT_SIZE, ODKStorage.DEFAULT_FONTSIZE); mQuestionFontSize = Integer.valueOf(question_font); mAnswerFontSize = mQuestionFontSize + 2; setOrientation(LinearLayout.VERTICAL); setGravity(Gravity.TOP); //TODO: This whole view should probably be inflated somehow int padding = this.getResources().getDimensionPixelSize(R.dimen.question_widget_side_padding); setPadding(padding, 8, padding, 8); mLayout = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.FILL_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); addQuestionText(); addHelpPlaceholder(); addHintText(); } protected void acceptFocus() { } private void addHelpPlaceholder() { if (!mPrompt.hasHelp()) { return; } helpPlaceholder = new FrameLayout(this.getContext()); helpPlaceholder.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT)); final ImageButton trigger = new ImageButton(getContext()); trigger.setScaleType(ScaleType.FIT_CENTER); trigger.setImageResource(R.drawable.icon_info_outline_lightcool); trigger.setBackgroundDrawable(null); trigger.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { trigger.setImageResource(R.drawable.icon_info_fill_lightcool); fireHelpText(new Runnable() { @Override public void run() { // back to the old icon trigger.setImageResource(R.drawable.icon_info_outline_lightcool); } }); } }); trigger.setId(847294011); LinearLayout triggerLayout = new LinearLayout(getContext()); triggerLayout.setOrientation(LinearLayout.HORIZONTAL); triggerLayout.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); triggerLayout.setGravity(Gravity.RIGHT); triggerLayout.addView(trigger); MediaLayout helpLayout = createHelpLayout(); helpLayout.setBackgroundResource(R.color.very_light_blue); helpPlaceholder.addView(helpLayout); this.addView(triggerLayout); this.addView(helpPlaceholder); helpPlaceholder.setVisibility(View.GONE); } public FormEntryPrompt getPrompt() { return mPrompt; } // Abstract methods public abstract IAnswerData getAnswer(); public abstract void clearAnswer(); public abstract void setFocus(Context context); @Override public abstract void setOnLongClickListener(OnLongClickListener l); @Override public void applyExtension(QuestionDataExtension extension) { // Intentionally empty method body -- subclasses of QuestionWidget that expect // to ever receive an extension should override this method and implement it accordingly } private class URLSpanNoUnderline extends URLSpan { public URLSpanNoUnderline(String url) { super(url); } @Override public void updateDrawState(TextPaint ds) { super.updateDrawState(ds); ds.setUnderlineText(false); } } /** * Add notification (e.g., validation error) to this question. * * @param text Text of message. * @param strong If true, display a visually stronger, negative background. * @param requestFocus If true, bring focus to this question. */ private void notifyOnScreen(String text, boolean strong, boolean requestFocus){ int warningBackdropId; if (strong) { warningBackdropId = R.drawable.strong_warning_backdrop; } else { warningBackdropId = R.drawable.weak_warning_backdrop; } ViewUtil.setBackgroundRetainPadding(this, this.getContext().getResources().getDrawable(warningBackdropId)); if (this.warningView == null) { // note: this is lame, but we bleed out the margins on the left and right here to make this overlap. // We could accomplish the same thing by having two backgrounds, one for the widget as a whole, and // one for the internals (or splitting up the layout), but this'll do for now this.warningView = View.inflate(this.getContext(), R.layout.question_warning_text_view, this).findViewById(R.id.warning_root); focusPending = requestFocus; } else { if (this.warningView.getVisibility() != View.VISIBLE) { this.warningView.setVisibility(View.VISIBLE); focusPending = requestFocus; } } TextView messageView = (TextView)this.warningView.findViewById(R.id.message); messageView.setText(text); //If the warningView already exists, we can just scroll to it right now //if not, we actually have to do it later, when we lay this all back out if (!focusPending && requestFocus) { requestChildViewOnScreen(messageView); } } private void notifyWarning(String text) { notifyOnScreen(text, false, true); } public void notifyInvalid(String text, boolean requestFocus) { notifyOnScreen(text, true, requestFocus); } protected void checkForOversizedMedia(IAnswerData widgetAnswer) { if (widgetAnswer instanceof InvalidData) { String fileSizeString = widgetAnswer.getValue() + ""; showOversizedMediaWarning(fileSizeString); } } private void showOversizedMediaWarning(String fileSizeString) { String maxAcceptable = FileUtil.bytesToMeg(FormUploadUtil.MAX_BYTES) + ""; String[] args = new String[]{fileSizeString, maxAcceptable}; notifyInvalid(StringUtils.getStringRobust(getContext(), R.string.attachment_above_size_limit, args), true); } /** * Use to signal that there's a portion of this view that wants to be * visible to the user on the screen. This method will place the sub * view on the screen, and will also place as much of this view as possible * on the screen. If this view is smaller than the viewable area available, it * will be fully visible in addition to the subview. */ private void requestChildViewOnScreen(View child) { //Take focus so the user can be prepared to interact with this question, since //they will need to be fixing the input acceptFocus(); //Get the rectangle that wants to put itself on the screen Rect vitalPortion = new Rect(); child.getDrawingRect(vitalPortion); //Save a reference to it in case we have to manipulate it later. Rect vitalPortionSaved = new Rect(); child.getDrawingRect(vitalPortionSaved); //Then get the bounding rectangle for this whole view. Rect wholeView = new Rect(); this.getDrawingRect(wholeView); //If we don't know enough about the screen, just default to asking to see the //subview that was requested. if(mFrameHeight == -1){ child.requestRectangleOnScreen(vitalPortion); return; } //If the whole view fits, just request that we display the whole thing. if(wholeView.height() < mFrameHeight) { this.requestRectangleOnScreen(wholeView); return; } //The whole view will not fit, we need to scale down our requested focus. //Trying to construct the "ideal" rectangle here is actually pretty hard //but the base case is just to see if we can get the view onto the screen from //the bottom or the top int topY = wholeView.top; int bottomY = wholeView.bottom; //shrink the view to contain only the current frame size. wholeView.inset(0, (wholeView.height() - mFrameHeight) / 2); wholeView.offsetTo(wholeView.left, topY); //The view is now the size of the frame and anchored back at the top. //Now let's contextualize where the child view actually is in this frame. this.offsetDescendantRectToMyCoords(child, vitalPortion); //If the newly transformed view now contains the child portion, we're good if(wholeView.contains(vitalPortion)) { this.requestRectangleOnScreen(wholeView); return; } //otherwise, move to the requested frame to be at the bottom of this view wholeView.offsetTo(wholeView.left, bottomY - wholeView.height()); //now see if the transformed view contains the vital portion if(wholeView.contains(vitalPortion)) { this.requestRectangleOnScreen(wholeView); return; } //Otherwise the child is hidden in the frame, so it won't matter which //we choose. child.requestRectangleOnScreen(vitalPortionSaved); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); //If we're coming back in after we just laid out adding a new element that needs //focus, we can now scroll to it, since it's actually had its spacing declared. if (changed && focusPending) { focusPending = false; if (this.warningView != null) { TextView messageView = (TextView)this.warningView.findViewById(R.id.message); requestChildViewOnScreen(messageView); } } } private void stripUnderlines(TextView textView) { Spannable s = (Spannable)textView.getText(); URLSpan[] spans = s.getSpans(0, s.length(), URLSpan.class); for (URLSpan span: spans) { int start = s.getSpanStart(span); int end = s.getSpanEnd(span); s.removeSpan(span); span = new URLSpanNoUnderline(span.getURL()); s.setSpan(span, start, end, 0); } textView.setText(s); } public void setQuestionText(TextView textView, FormEntryPrompt prompt){ if(prompt.getMarkdownText() != null){ textView.setText(forceMarkdown(prompt.getMarkdownText())); textView.setMovementMethod(LinkMovementMethod.getInstance()); // Wrap to the size of the parent view textView.setHorizontallyScrolling(false); } else { textView.setText(mPrompt.getLongText()); } } public void setChoiceText(TextView choiceText, SelectChoice choice){ String markdownText = mPrompt.getSelectItemMarkdownText(choice); if(markdownText != null){ choiceText.setText(forceMarkdown(markdownText)); } else{ choiceText.setText(mPrompt.getSelectChoiceText(choice)); } } /** * Add a Views containing the question text, audio (if applicable), and image (if applicable). * To satisfy the RelativeLayout constraints, we add the audio first if it exists, then the * TextView to fit the rest of the space, then the image if applicable. */ protected void addQuestionText() { mQuestionText = (TextView)LayoutInflater.from(getContext()).inflate(R.layout.question_widget_text, this, false); mQuestionText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, mQuestionFontSize); mQuestionText.setId(38475483); // assign random id setQuestionText(mQuestionText, mPrompt); if (mPrompt.getLongText() != null) { if (mPrompt.getLongText().contains("\u260E")) { if (Linkify.addLinks(mQuestionText, Linkify.PHONE_NUMBERS)) { stripUnderlines(mQuestionText); } else { Log.d(TAG, "this should be an error I'm thinking?"); } } } if (mPrompt.getLongText() == null) { mQuestionText.setVisibility(GONE); } // Create the layout for audio, image, text String imageURI = mPrompt.getImageText(); String audioURI = mPrompt.getAudioText(); String expandedAudioURI = mPrompt.getSpecialFormQuestionText("expanded-audio"); String videoURI = mPrompt.getSpecialFormQuestionText("video"); String inlineVideoUri = mPrompt.getSpecialFormQuestionText("video-inline"); String qrCodeContent = mPrompt.getSpecialFormQuestionText("qrcode"); // shown when image is clicked String bigImageURI = mPrompt.getSpecialFormQuestionText("big-image"); MediaLayout mediaLayout = MediaLayout.buildComprehensiveLayout(getContext(), mQuestionText, audioURI, imageURI, videoURI, bigImageURI, qrCodeContent, inlineVideoUri, expandedAudioURI, mPrompt.getIndex().hashCode()); addView(mediaLayout, mLayout); } /** * Display extra help, triggered by user request. */ private void fireHelpText(final Runnable r) { if (!mPrompt.hasHelp()) { return; } // Depending on ODK setting, help may be displayed either as // a dialog or inline, underneath the question text if (showHelpWithDialog()) { AlertDialog mAlertDialog = new AlertDialog.Builder(this.getContext()).create(); ScrollView scrollView = new ScrollView(this.getContext()); scrollView.addView(createHelpLayout()); mAlertDialog.setView(scrollView); DialogInterface.OnClickListener errorListener = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int i) { switch (i) { case DialogInterface.BUTTON1: dialog.cancel(); if(r != null) r.run(); break; } } }; mAlertDialog.setCancelable(true); mAlertDialog.setButton(StringUtils.getStringSpannableRobust(this.getContext(), R.string.ok), errorListener); mAlertDialog.show(); } else { if(helpPlaceholder.getVisibility() == View.GONE) { expand(helpPlaceholder); } else { collapse(helpPlaceholder); } } } private boolean showHelpWithDialog() { return !PreferenceManager.getDefaultSharedPreferences(this.getContext().getApplicationContext()). getBoolean(FormEntryPreferences.KEY_HELP_MODE_TRAY, false); } /** * Build MediaLayout for displaying any help associated with given FormEntryPrompt. */ private MediaLayout createHelpLayout() { TextView text = new TextView(getContext()); String markdownText = mPrompt.getHelpMultimedia(FormEntryCaption.TEXT_FORM_MARKDOWN); if (markdownText != null) { text.setText(forceMarkdown(markdownText)); text.setMovementMethod(LinkMovementMethod.getInstance()); } else { text.setText(mPrompt.getHelpText()); } text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, mQuestionFontSize); int padding = (int)getResources().getDimension(R.dimen.help_text_padding); text.setPadding(0, 0, 0, 7); text.setId(38475483); // assign random id MediaLayout helpLayout = MediaLayout.buildAudioImageVisualLayout(getContext(), text, mPrompt.getHelpMultimedia(FormEntryCaption.TEXT_FORM_AUDIO), mPrompt.getHelpMultimedia(FormEntryCaption.TEXT_FORM_IMAGE), mPrompt.getHelpMultimedia(FormEntryCaption.TEXT_FORM_VIDEO), null); helpLayout.setPadding(padding, padding, padding, padding); return helpLayout; } private static void expand(final View v) { v.measure(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT); final int targetHeight = v.getMeasuredHeight(); v.getLayoutParams().height = 0; v.setVisibility(View.VISIBLE); Animation a = new Animation() { @Override protected void applyTransformation(float interpolatedTime, Transformation t) { v.getLayoutParams().height = interpolatedTime == 1 ? LayoutParams.WRAP_CONTENT : (int)(targetHeight * interpolatedTime); v.requestLayout(); } @Override public boolean willChangeBounds() { return true; } }; // 1dp/ms a.setDuration((int)(targetHeight / v.getContext().getResources().getDisplayMetrics().density)); v.startAnimation(a); } private static void collapse(final View v) { final int initialHeight = v.getMeasuredHeight(); Animation a = new Animation() { @Override protected void applyTransformation(float interpolatedTime, Transformation t) { if(interpolatedTime == 1){ v.setVisibility(View.GONE); }else{ v.getLayoutParams().height = initialHeight - (int)(initialHeight * interpolatedTime); v.requestLayout(); } } @Override public boolean willChangeBounds() { return true; } }; // 1dp/ms a.setDuration((int)(initialHeight / v.getContext().getResources().getDisplayMetrics().density)); v.startAnimation(a); } public void updateFrameSize(int height) { mFrameHeight = height; } public void updateHintHeight(int maxHintHeight) { if (mHintText != null) { mHintText.updateMaxHeight(maxHintHeight); } hintTextNeedsHeightSpec = false; } /** * Add a TextView containing the help text. */ private void addHintText() { String s = mPrompt.getHintText(); if (s != null && !s.equals("")) { mHintText = new ShrinkingTextView(getContext(),this.getMaxHintHeight()); mHintText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, mQuestionFontSize - 3); mHintText.setPadding(0, -5, 0, 7); // wrap to the widget of view mHintText.setHorizontallyScrolling(false); mHintText.setText(s); mHintText.setTypeface(null, Typeface.ITALIC); addView(mHintText, mLayout); } } private int getMaxHintHeight() { if (mFrameHeight != -1) { return mFrameHeight / 4; } else { hintTextNeedsHeightSpec = true; return -1; } } /** * Every subclassed widget should override this, adding any views they may contain, and calling * super.cancelLongPress() */ @Override public void cancelLongPress() { super.cancelLongPress(); if (mQuestionText != null) { mQuestionText.cancelLongPress(); } if (mHintText != null) { mHintText.cancelLongPress(); } } protected IAnswerData getCurrentAnswer() { IAnswerData current = mPrompt.getAnswerValue(); if(current == null) { return null; } return getTemplate().cast(current.uncast()); } private IAnswerData getTemplate() { return AnswerDataFactory.template(mPrompt.getControlType(), mPrompt.getDataType()); } public void hideHintText() { mHintText.setVisibility(View.GONE); } public FormIndex getFormId(){ return mPrompt.getIndex(); } public void setChangedListeners(WidgetChangedListener wcl, BlockingActionsManager blockingActionsManager){ widgetChangedListener = wcl; this.blockingActionsManager = blockingActionsManager; } protected void fireDelayed(DelayedBlockingAction delayedBlockingAction) { if (this.blockingActionsManager != null) { blockingActionsManager.queue(delayedBlockingAction); } } protected void widgetEntryChangedDelayed() { fireDelayed(new DelayedBlockingAction(System.identityHashCode(this), 400) { @Override protected void runAction() { widgetEntryChanged(); } }); } public void unsetListeners() { setOnLongClickListener(null); setChangedListeners(null, null); } public void widgetEntryChanged() { clearWarningMessage(); if (hasListener()) { widgetChangedListener.widgetEntryChanged(this); } } public void clearWarningMessage() { if (this.warningView != null) { this.warningView.setVisibility(View.GONE); ViewUtil.setBackgroundRetainPadding(this, null); } } public boolean hasListener() { return widgetChangedListener != null; } /** * @return True if file is too big to upload. */ protected boolean checkFileSize(File file){ if (FileUtil.isFileTooLargeToUpload(file)) { String fileSize = FileUtil.getFileSizeInMegs(file) + ""; showOversizedMediaWarning(fileSize); return true; } else if (FileUtil.isFileOversized(file)) { notifyWarning(StringUtils.getStringRobust(getContext(), R.string.attachment_oversized, FileUtil.getFileSize(file) + "")); } return false; } /* * Method to make localization and styling easier for devs * copied from CommCareActivity */ protected Spannable forceMarkdown(String text){ return MarkupUtil.returnMarkdown(getContext(), text); } /** * Implemented by questions that read binary data from and external source, * such as the image chooser or a custom intent callout. * * @param answer generic object that individual implementations know how to * process */ public void setBinaryData(Object answer) { String instanceClass = this.getClass().getSimpleName(); Logger.log(AndroidLogger.SOFT_ASSERT, "Calling empty implementation of " + instanceClass + ".setBinaryData"); } }