/*
* Copyright (C) 2010-2016 Stichting Akvo (Akvo Foundation)
*
* This file is part of Akvo Flow.
*
* Akvo Flow is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Akvo Flow is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Akvo Flow. If not, see <http://www.gnu.org/licenses/>.
*/
package org.akvo.flow.ui.view;
import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.res.Resources;
import android.os.Bundle;
import android.text.Html;
import android.text.Spanned;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.TextView.BufferType;
import org.akvo.flow.R;
import org.akvo.flow.domain.AltText;
import org.akvo.flow.domain.Dependency;
import org.akvo.flow.domain.Question;
import org.akvo.flow.domain.QuestionHelp;
import org.akvo.flow.domain.QuestionResponse;
import org.akvo.flow.event.QuestionInteractionEvent;
import org.akvo.flow.event.QuestionInteractionListener;
import org.akvo.flow.event.SurveyListener;
import org.akvo.flow.util.ConstantUtil;
import org.akvo.flow.util.PlatformUtil;
import org.akvo.flow.util.ViewUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;
public abstract class QuestionView extends LinearLayout implements QuestionInteractionListener {
private static final int PADDING_DIP = 8;
protected static String[] sColors = null;
protected Question mQuestion;
private QuestionResponse mResponse;
private List<QuestionInteractionListener> mListeners;
protected SurveyListener mSurveyListener;
private TextView mQuestionText;
private ImageButton mTipImage;
/**
* mError stores the presence of non-acceptable responses.
* Any non-null value will be considered as an invalid response.
*/
private String mError;
public QuestionView(final Context context, Question q, SurveyListener surveyListener) {
super(context);
setOrientation(VERTICAL);
final int padding = (int) PlatformUtil.dp2Pixel(getContext(), PADDING_DIP);
setPadding(padding, padding, padding, padding);
if (sColors == null) {
// must have enough colors for all enabled languages
sColors = context.getResources().getStringArray(R.array.colors);
}
mQuestion = q;
mSurveyListener = surveyListener;
mError = null;// so far so good.
}
/**
* Inflate the appropriate layout file, and retrieve the references to the common resources.
* Subclasses' layout files should ALWAYS contain the question_header view.
* Inflated layout will be attached to the View's root, thus all the elements within it
* will be accessible by calling findViewById(int)
*
* @param layoutRes resource containing the layout for the question.
*/
protected void setQuestionView(int layoutRes) {
LayoutInflater inflater = LayoutInflater.from(getContext());
inflater.inflate(layoutRes, this, true);
mQuestionText = (TextView) findViewById(R.id.question_tv);
mTipImage = (ImageButton) findViewById(R.id.tip_ib);
if (mQuestionText == null || mTipImage == null) {
throw new RuntimeException(
"Subclasses must inflate the common question header before calling this method.");
}
mQuestionText.setText(formText(), BufferType.SPANNABLE);
// if there is a tip for this question, construct an alert dialog box with the data
final int tips = mQuestion.getHelpTypeCount();
if (tips > 0) {
mTipImage.setVisibility(View.VISIBLE);// GONE by default
mTipImage.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (tips > 1) {
displayHelpChoices();
} else {
if (mQuestion.getHelpByType(ConstantUtil.TIP_HELP_TYPE)
.size() > 0) {
displayHelp(ConstantUtil.TIP_HELP_TYPE);
} else if (mQuestion.getHelpByType(
ConstantUtil.VIDEO_HELP_TYPE).size() > 0) {
displayHelp(ConstantUtil.VIDEO_HELP_TYPE);
} else if (mQuestion.getHelpByType(
ConstantUtil.IMAGE_HELP_TYPE).size() > 0) {
displayHelp(ConstantUtil.IMAGE_HELP_TYPE);
}
}
}
});
}
if (!isReadOnly()) {
mQuestionText.setLongClickable(true);
mQuestionText.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
onClearAnswer();
return true;
}
});
}
// if this question has 1 or more dependencies, then it needs to be invisible initially
if (mQuestion.getDependencies() != null && mQuestion.getDependencies().size() > 0) {
setVisibility(View.GONE);
}
}
protected void onClearAnswer() {
ViewUtil.showConfirmDialog(R.string.clearquestion,
R.string.clearquestiondesc, getContext(), true,
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
resetQuestion(true);
checkMandatory();
}
});
}
/**
* forms the question text based on the selected languages
*
* @return
*/
private Spanned formText() {
boolean isFirst = true;
StringBuilder text = new StringBuilder();
if (mQuestion.isMandatory()) {
text.append("<b>");
}
text.append(mQuestion.getOrder()).append(". ");// Prefix the text with the order
final String[] langs = getLanguages();
final String language = getDefaultLang();
for (int i = 0; i < langs.length; i++) {
if (language.equalsIgnoreCase(langs[i])) {
if (!isFirst) {
text.append(" / ");
} else {
isFirst = false;
}
text.append(mQuestion.getText());
} else {
AltText txt = mQuestion.getAltText(langs[i]);
if (txt != null) {
if (!isFirst) {
text.append(" / ");
} else {
isFirst = false;
}
text.append("<font color='").append(sColors[i % sColors.length]).append("'>")
.append(txt.getText()).append("</font>");
}
}
}
if (mQuestion.isMandatory()) {
text = text.append("*</b>");
}
return Html.fromHtml(text.toString());
}
public void notifyOptionsChanged() {
mQuestionText.setText(formText(), BufferType.SPANNABLE);
}
/**
* displays a dialog box with options for each of the help types that have
* been initialized for this particular question.
*/
@SuppressWarnings("rawtypes")
private void displayHelpChoices() {
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setTitle(R.string.helpheading);
final CharSequence[] items = new CharSequence[mQuestion
.getHelpTypeCount()];
final Resources resources = getResources();
int itemIndex = 0;
List tempList = mQuestion.getHelpByType(ConstantUtil.IMAGE_HELP_TYPE);
if (tempList != null && tempList.size() > 0) {
items[itemIndex++] = resources.getString(R.string.photohelpoption);
}
tempList = mQuestion.getHelpByType(ConstantUtil.VIDEO_HELP_TYPE);
if (tempList != null && tempList.size() > 0) {
items[itemIndex++] = resources.getString(R.string.videohelpoption);
}
tempList = mQuestion.getHelpByType(ConstantUtil.TIP_HELP_TYPE);
if (tempList != null && tempList.size() > 0) {
items[itemIndex++] = resources.getString(R.string.texthelpoption);
}
builder.setItems(items, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
String val = items[id].toString();
if (resources.getString(R.string.texthelpoption).equals(val)) {
displayHelp(ConstantUtil.TIP_HELP_TYPE);
} else if (resources.getString(R.string.videohelpoption)
.equals(val)) {
displayHelp(ConstantUtil.VIDEO_HELP_TYPE);
} else if (resources.getString(R.string.photohelpoption)
.equals(val)) {
displayHelp(ConstantUtil.IMAGE_HELP_TYPE);
}
if (dialog != null) {
dialog.dismiss();
}
}
});
builder.show();
}
/**
* displays the selected help type
*
* @param type
*/
private void displayHelp(String type) {
if (ConstantUtil.VIDEO_HELP_TYPE.equals(type)) {
notifyQuestionListeners(QuestionInteractionEvent.VIDEO_TIP_VIEW);
} else if (ConstantUtil.TIP_HELP_TYPE.equals(type)) {
AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
StringBuilder textBuilder = new StringBuilder();
List<QuestionHelp> helpItems = mQuestion.getHelpByType(type);
boolean isFirst = true;
String[] langs = getLanguages();
String language = getDefaultLang();
if (helpItems != null) {
for (int i = 0; i < helpItems.size(); i++) {
if (i > 0) {
textBuilder.append("<br>");
}
for (int j = 0; j < langs.length; j++) {
if (language.equalsIgnoreCase(langs[j])) {
textBuilder.append(helpItems.get(i).getText());
isFirst = false;
}
AltText aText = helpItems.get(i).getAltText(langs[j]);
if (aText != null) {
if (!isFirst) {
textBuilder.append(" / ");
} else {
isFirst = false;
}
textBuilder.append("<font color='").append(sColors[j]).append("'>")
.append(aText.getText()).append("</font>");
}
}
}
}
builder.setMessage(Html.fromHtml(textBuilder.toString()));
builder.setPositiveButton(R.string.okbutton,
new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
dialog.cancel();
}
});
builder.show();
} else if (ConstantUtil.IMAGE_HELP_TYPE.equals(type)) {
notifyQuestionListeners(QuestionInteractionEvent.PHOTO_TIP_VIEW);
} else {
notifyQuestionListeners(QuestionInteractionEvent.ACTIVITY_TIP_VIEW);
}
}
/**
* adds a listener to the internal list of clients to be notified on an
* event
*
* @param listener
*/
public void addQuestionInteractionListener(QuestionInteractionListener listener) {
if (mListeners == null) {
mListeners = new ArrayList<QuestionInteractionListener>();
}
if (listener != null && !mListeners.contains(listener) && listener != this) {
mListeners.add(listener);
}
}
/**
* notifies each QuestionInteractionListener registered with this question.
* This is done serially on the calling thread.
*/
protected void notifyQuestionListeners(String type, Bundle data) {
if (mListeners != null) {
QuestionInteractionEvent event = new QuestionInteractionEvent(type, this, data);
for (int i = 0; i < mListeners.size(); i++) {
mListeners.get(i).onQuestionInteraction(event);
}
}
}
protected void notifyQuestionListeners(String type) {
notifyQuestionListeners(type, null);
}
/**
* method that can be overridden by sub classes if they want to have some
* sort of visual response to a question interaction.
*/
public void questionComplete(Bundle data) {
// do nothing
}
/**
* method that should be overridden by sub classes to clear current value
*/
public void resetQuestion(boolean fireEvent) {
boolean suppressListeners = !fireEvent;
setResponse(null, suppressListeners);
setError(null);
if (fireEvent) {
notifyQuestionListeners(QuestionInteractionEvent.QUESTION_CLEAR_EVENT);
}
checkDependencies();
}
/**
* Show/Hide the Question, according to the dependencies
*/
public void checkDependencies() {
if (areDependenciesSatisfied()) {
setVisibility(View.VISIBLE);
} else {
setVisibility(View.GONE);
}
}
@Override
public void onQuestionInteraction(QuestionInteractionEvent event) {
if (QuestionInteractionEvent.QUESTION_ANSWER_EVENT.equals(event.getEventType())) {
// if this question is dependent, see if it has been satisfied
List<Dependency> dependencies = mQuestion.getDependencies();
if (dependencies != null) {
for (int i = 0; i < dependencies.size(); i++) {
Dependency d = dependencies.get(i);
if (d.getQuestion().equalsIgnoreCase(
event.getSource().getQuestion().getId())) {
if (handleDependencyParentResponse(d, event.getSource().getResponse())) {
break;
}
}
}
}
}
}
/**
* updates the state of this question view based on the value in the
* dependency parent response. This method returns true if there is a value
* match and false otherwise.
*
* @param dep
* @param resp
* @return
*/
public boolean handleDependencyParentResponse(Dependency dep, QuestionResponse resp) {
boolean isMatch = false;
if (dep.getAnswer() != null
&& resp != null
&& dep.isMatch(resp.getValue())
&& resp.getIncludeFlag()) {
isMatch = true;
} else if (dep.getAnswer() != null
&& resp != null
&& resp.getIncludeFlag()) {
if (resp.getValue() != null) {
StringTokenizer strTok = new StringTokenizer(resp.getValue(),
"|");
while (strTok.hasMoreTokens()) {
if (dep.isMatch(strTok.nextToken().trim())) {
isMatch = true;
}
}
}
}
boolean setVisible = false;
// if we're here, then the question on which we depend
// has been answered. Check the value to see if it's the
// one we are looking for
if (isMatch) {
setVisibility(View.VISIBLE);
if (mResponse != null) {
mResponse.setIncludeFlag(true);
}
setVisible = true;
} else {
if (mResponse != null) {
mResponse.setIncludeFlag(false);
}
setVisibility(View.GONE);
}
// now notify our own listeners to make sure we correctly toggle
// nested dependencies (i.e. if A -> B -> C and C changes, A needs to
// know too).
if (mResponse != null) {
notifyQuestionListeners(QuestionInteractionEvent.QUESTION_ANSWER_EVENT);
}
return setVisible;
}
public final void captureResponse() {
captureResponse(false);
}
/**
* this method should be overridden by subclasses so they can record input
* in a QuestionResponse object
*/
public abstract void captureResponse(boolean suppressListeners);
/**
* this method should be overridden by subclasses so they can manage the UI
* changes when resetting the value
*
* @param resp
*/
public void rehydrate(QuestionResponse resp) {
setResponse(resp);
}
/**
* Release any heavy resource associated with this view. This method will
* likely be overridden by subclasses. This callback should ALWAYS be called
* when the Activity is about to become invisible (paused, stopped,...) and
* this View's responses have been successfully cached. Any resource that
* can cause a memory leak or prevent this View from being GC should be
* freed/notified
*/
public void onPause() {
}
/**
* Instantiate any resource that depends on the Activity life-cycle (i.e. internal DB connections)
* This callback will be invoked *after* the question is instantiated and initialized.
*/
public void onResume() {
}
/**
* Activity callback propagation. Use this hook to release resources no longer needed.
*/
public void onDestroy() {
}
public QuestionResponse getResponse() {
return mResponse;
}
public void setResponse(QuestionResponse response) {
setResponse(response, false);
}
public void setResponse(QuestionResponse response, boolean suppressListeners) {
if (response != null) {
if (this.mResponse == null) {
this.mResponse = response;
} else {
// we need to preserve the ID so we don't get duplicates in the db
this.mResponse.setType(response.getType());
this.mResponse.setValue(response.getValue());
this.mResponse.setFilename(response.getFilename());
}
} else {
this.mResponse = response;
}
if (!suppressListeners) {
notifyQuestionListeners(QuestionInteractionEvent.QUESTION_ANSWER_EVENT);
}
setError(null);// Reset any error status
}
public Question getQuestion() {
return mQuestion;
}
public void setTextSize(float size) {
mQuestionText.setTextSize(size);
}
/**
* hides or shows the tips button
*
* @param isSuppress
*/
public void suppressHelp(boolean isSuppress) {
mTipImage.setVisibility(isSuppress ? View.GONE : View.VISIBLE);
}
protected String getDefaultLang() {
return mSurveyListener.getDefaultLanguage();
}
protected String[] getLanguages() {
return mSurveyListener.getLanguages();
}
protected boolean isReadOnly() {
return mSurveyListener.isReadOnly();
}
public void setError(String error) {
mError = error;
displayError(mError);
}
/**
* displayError will give visual feedback of non-valid responses.
* By default, we display the message in the question text, although subclasses may
* override the method and display it elsewhere (i.e. within an EditText)
*
* @param error Error text
*/
public void displayError(String error) {
mQuestionText.setError(error);
}
public void checkMandatory() {
if (mError == null && mQuestion.isMandatory() &&
(mResponse == null || !mResponse.isValid())) {
// Mandatory questions must have a response
setError(getResources().getString(R.string.error_question_mandatory));
}
}
/**
* isValid determines if the QuestionView contains a valid status.
* An invalid status can be set either explicitly with setError(String),
* or it can be automatically deducted if the question is mandatory and no response is set.
*
* @return true if the status is valid, false otherwise
*/
public boolean isValid() {
return mError == null;
}
public boolean isDoubleEntry() {
return mQuestion != null ? mQuestion.isDoubleEntry() : false;// Avoid NPE
}
/**
* Checks if the dependencies for the question passed in are satisfied
*
* @return true if no dependency is broken, false otherwise
*/
public boolean areDependenciesSatisfied() {
List<Dependency> dependencies = getQuestion().getDependencies();
if (dependencies != null) {
Map<String, QuestionResponse> responses = mSurveyListener.getResponses();
for (Dependency dependency : dependencies) {
QuestionResponse resp = responses.get(dependency.getQuestion());
if (resp == null || !resp.hasValue()
|| !dependency.isMatch(resp.getValue())
|| !resp.getIncludeFlag()) {
return false;
}
}
}
return true;
}
}