package org.commcare.views;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.commcare.android.logging.ForceCloseLogger;
import org.commcare.cases.entity.Entity;
import org.commcare.dalvik.R;
import org.commcare.graph.model.GraphData;
import org.commcare.graph.util.GraphException;
import org.commcare.graph.view.GraphView;
import org.commcare.models.AsyncEntity;
import org.commcare.suite.model.Detail;
import org.commcare.suite.model.DetailField;
import org.commcare.utils.AndroidUtil;
import org.commcare.utils.MediaUtil;
import org.commcare.utils.StringUtils;
import org.commcare.views.media.AudioPlaybackButton;
import org.commcare.views.media.ViewId;
import org.javarosa.core.services.Logger;
import org.javarosa.core.services.locale.Localization;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Vector;
/**
* @author ctsims
*/
public class EntityView extends LinearLayout {
private final static String TAG = EntityView.class.getSimpleName();
private final ArrayList<View> views;
private ArrayList<String> forms;
private String[] searchTerms;
private final ArrayList<String> mHints;
// index => { orientation => GraphView }
private Hashtable<Long, Hashtable<Integer, View>> renderedGraphsCache;
private long rowId;
public static final String FORM_AUDIO = "audio";
public static final String FORM_IMAGE = "image";
public static final String FORM_GRAPH = "graph";
public static final String FORM_CALLLOUT = "callout";
// Flag indicating if onMeasure has already been called for the first time on this view
private boolean onMeasureCalled = false;
// Maintains a queue of image layouts that need to be re-drawn once onMeasure has been called
private final HashMap<View, String> imageViewsToRedraw = new HashMap<>();
private boolean mFuzzySearchEnabled = true;
private boolean mIsAsynchronous = false;
private String extraData = null;
/**
* Creates row entry for entity
*/
private EntityView(Context context, Detail d, Entity e,
String[] searchTerms, long rowId,
boolean mFuzzySearchEnabled, String extraData) {
super(context);
//this is bad :(
mIsAsynchronous = e instanceof AsyncEntity;
this.searchTerms = searchTerms;
this.renderedGraphsCache = new Hashtable<>();
this.rowId = rowId;
this.views = new ArrayList<>(e.getNumFields());
this.forms = new ArrayList<>(Arrays.asList(d.getTemplateForms()));
this.mHints = new ArrayList<>(Arrays.asList(d.getTemplateSizeHints()));
for (int col = 0; col < e.getNumFields(); ++col) {
Object field = e.getField(col);
String sortField = e.getSortField(col);
views.add(addCell(col, field, forms.get(col), mHints.get(col), sortField, -1, true));
}
if (d.getCallout() != null) {
addExtraData(d.getCallout().getResponseDetailField(), extraData);
}
this.mFuzzySearchEnabled = mFuzzySearchEnabled;
}
/**
* Creates row entry for column headers
*/
private EntityView(Context context, Detail d, String[] columnTitles,
boolean hasCalloutResponseData) {
super(context);
DetailField calloutResponseDetailField = null;
if (hasCalloutResponseData && d.getCallout() != null) {
calloutResponseDetailField = d.getCallout().getResponseDetailField();
columnTitles = addColumnTitleForCalloutData(columnTitles, calloutResponseDetailField);
}
int columnCount = columnTitles.length;
this.views = new ArrayList<>(columnCount);
this.mHints = new ArrayList<>(columnCount);
String[] headerForms = new String[columnCount];
int i = 0;
for (DetailField field : d.getFields()) {
mHints.add(field.getHeaderWidthHint());
headerForms[i] = field.getHeaderForm();
i++;
}
if (calloutResponseDetailField != null) {
mHints.add(calloutResponseDetailField.getHeaderWidthHint());
headerForms[columnCount - 1] = calloutResponseDetailField.getHeaderForm();
}
int[] colors = AndroidUtil.getThemeColorIDs(getContext(),
new int[]{R.attr.entity_view_header_background_color,
R.attr.entity_view_header_text_color});
if (colors[0] != -1) {
this.setBackgroundColor(colors[0]);
}
for (int col = 0; col < columnCount; ++col) {
views.add(addCell(col, columnTitles[col], headerForms[col], mHints.get(col), null,
colors[1], false));
}
}
private static String[] addColumnTitleForCalloutData(String[] columnTitles,
DetailField calloutResponseDetailField) {
String[] headerTextWithCalloutResponse =
new String[columnTitles.length + 1];
System.arraycopy(columnTitles, 0,
headerTextWithCalloutResponse, 0, columnTitles.length);
headerTextWithCalloutResponse[columnTitles.length] =
calloutResponseDetailField.getHeader().evaluate();
return headerTextWithCalloutResponse;
}
public static EntityView buildEntryEntityView(Context context, Detail detail,
Entity entity,
String[] searchTerms,
long rowId, boolean isFuzzySearchEnabled,
String extraData) {
return new EntityView(context, detail, entity,
searchTerms, rowId, isFuzzySearchEnabled, extraData);
}
public static EntityView buildHeadersEntityView(Context context,
Detail detail,
String[] headerText,
boolean hasCalloutResponseData) {
return new EntityView(context, detail, headerText, hasCalloutResponseData);
}
private View addCell(int columnIndex, Object data, String form, String hint, String sortField,
int textColor, boolean shouldRefresh) {
View view = null;
if (isNonZeroWidth(hint)) {
ViewId uniqueId = ViewId.buildTableViewId(rowId, columnIndex, false);
view = initView(data, form, uniqueId, sortField);
view.setId(AndroidUtil.generateViewId());
if (textColor != -1) {
TextView tv = (TextView)view.findViewById(R.id.entity_view_text);
if (tv != null) tv.setTextColor(textColor);
}
if (shouldRefresh) {
refreshViewForNewEntity(view, data, form, sortField, columnIndex, rowId);
}
LayoutParams l = new LinearLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
addView(view, l);
}
return view;
}
/**
* Creates up a new view in the view with ID uniqueid, based upon
* the entity's text and form
*/
private View initView(Object data, String form, ViewId uniqueId, String sortField) {
if (FORM_IMAGE.equals(form)) {
return View.inflate(getContext(), R.layout.entity_item_image, null);
} else if (FORM_AUDIO.equals(form)) {
String text = (String)data;
boolean isVisible = (text != null && text.length() > 0);
return new AudioPlaybackButton(getContext(), text, uniqueId, isVisible);
} else if (FORM_GRAPH.equals(form) && data instanceof GraphData) {
return View.inflate(getContext(), R.layout.entity_item_graph, null);
} else if (FORM_CALLLOUT.equals(form)) {
return View.inflate(getContext(), R.layout.entity_item_graph, null);
} else {
View layout = View.inflate(getContext(), R.layout.component_text, null);
setupText(layout, (String)data, sortField);
return layout;
}
}
public void setSearchTerms(String[] terms) {
this.searchTerms = terms;
}
public void setExtraData(DetailField responseDetail, String newExtraData) {
removeExistingExtraData();
addExtraData(responseDetail, newExtraData);
}
private void addExtraData(DetailField responseDetail, String newExtraData) {
if (responseDetail == null) {
if (newExtraData != null) {
Log.w(TAG, "Trying to set extra data with no display info present");
}
} else {
String hint = responseDetail.getTemplateWidthHint();
if (isNonZeroWidth(hint) && newExtraData != null && !"".equals(newExtraData)) {
extraData = newExtraData;
views.add(addCell(views.size(), newExtraData, "", "", "", -1, false));
mHints.add(hint);
forms.add(responseDetail.getTemplateForm());
}
}
}
private static boolean isNonZeroWidth(String hintText) {
return hintText == null || !hintText.startsWith("0");
}
private void removeExistingExtraData() {
if (extraData != null) {
extraData = null;
removeView(views.get(views.size() - 1));
views.remove(views.size() - 1);
mHints.remove(mHints.size() - 1);
forms.remove(forms.size() - 1);
}
}
public void refreshViewsForNewEntity(Entity e, boolean currentlySelected, long rowId) {
for (int i = 0; i < e.getNumFields(); ++i) {
Object field = e.getField(i);
View view = views.get(i);
if (view != null) {
refreshViewForNewEntity(view, field, forms.get(i), e.getSortField(i), i, rowId);
}
}
View extraDataView = views.get(views.size() - 1);
if (extraData != null && extraDataView != null) {
refreshViewForNewEntity(extraDataView, extraData, forms.get(forms.size() - 1), "", views.size() - 1, rowId);
}
if (currentlySelected) {
this.setBackgroundResource(R.drawable.grey_bordered_box);
} else {
this.setBackgroundDrawable(null);
}
}
private void refreshViewForNewEntity(View view, Object field, String form, String sortField,
int columnIndex, long rowId) {
if (FORM_AUDIO.equals(form)) {
ViewId uniqueId = ViewId.buildTableViewId(rowId, columnIndex, false);
setupAudioLayout(view, (String)field, uniqueId);
} else if (FORM_IMAGE.equals(form)) {
setupImageLayout(view, (String)field);
} else if (FORM_GRAPH.equals(form) && field instanceof GraphData) {
int orientation = getResources().getConfiguration().orientation;
GraphView g = new GraphView(getContext(), "", false);
View rendered = null;
if (renderedGraphsCache.get(rowId) != null) {
rendered = renderedGraphsCache.get(rowId).get(orientation);
} else {
renderedGraphsCache.put(rowId, new Hashtable<Integer, View>());
}
if (rendered == null) {
try {
rendered = g.getView(((GraphData)field).getGraphHTML(" "));
} catch (GraphException ex) {
rendered = new TextView(getContext());
((TextView)rendered).setText(ex.getMessage());
}
renderedGraphsCache.get(rowId).put(orientation, rendered);
}
((LinearLayout)view).removeAllViews();
((LinearLayout)view).addView(rendered, GraphView.getLayoutParams());
view.setVisibility(VISIBLE);
} else {
setupText(view, (String)field, sortField);
}
}
/**
* Updates the AudioPlaybackButton layout that is passed in, based on the
* new id and source
*/
private void setupAudioLayout(View layout, String source, ViewId uniqueId) {
AudioPlaybackButton b = (AudioPlaybackButton)layout;
if (source != null && source.length() > 0) {
b.modifyButtonForNewView(uniqueId, source, true);
} else {
b.modifyButtonForNewView(uniqueId, source, false);
}
}
/**
* Updates the text layout that is passed in, based on the new text
*/
private void setupText(View layout, final String text, String searchField) {
TextView tv = (TextView)layout.findViewById(R.id.entity_view_text);
tv.setVisibility(View.VISIBLE);
Spannable rawText = new SpannableString(text == null ? "" : text);
tv.setText(highlightSearches(searchTerms, rawText, searchField, mFuzzySearchEnabled, mIsAsynchronous));
}
private void addLayoutToRedrawQueue(View layout, String source) {
imageViewsToRedraw.put(layout, source);
}
private void redrawImageLayoutsInQueue() {
for (View v : imageViewsToRedraw.keySet()) {
setupImageLayout(v, imageViewsToRedraw.get(v));
}
imageViewsToRedraw.clear();
}
/**
* Updates the ImageView layout that is passed in, based on the new id and source
*/
private void setupImageLayout(View layout, final String source) {
ImageView iv = (ImageView)layout;
if (source.equals("")) {
iv.setImageDrawable(getResources().getDrawable(R.color.transparent));
return;
}
if (onMeasureCalled) {
int columnWidthInPixels = layout.getLayoutParams().width;
Bitmap b = MediaUtil.inflateDisplayImage(getContext(), source, columnWidthInPixels, columnWidthInPixels, true);
if (b == null) {
// Means the input stream could not be used to derive the bitmap, so showing
// error-indicating image
iv.setImageDrawable(getResources().getDrawable(R.drawable.ic_menu_archive));
} else {
iv.setImageBitmap(b);
}
} else {
// Since case list images are scaled down based on the width of the column they
// go into, we cannot set up an image layout until onMeasure() has been called
addLayoutToRedrawQueue(layout, source);
}
}
//TODO: This method now really does two different things and should possibly be different
//methods.
/**
* Based on the search terms provided, highlight the aspects of the spannable provided which
* match. A background string can be provided which provides the exact data that is being
* matched.
*/
public static Spannable highlightSearches(String[] searchTerms, Spannable raw,
String backgroundString, boolean fuzzySearchEnabled,
boolean strictMode) {
if (searchTerms == null) {
return raw;
}
try {
//TOOD: Only do this if we're in strict mode
if (strictMode) {
if (backgroundString == null) {
return raw;
}
//make sure that we have the same consistency for our background match
backgroundString = StringUtils.normalize(backgroundString).trim();
} else {
//Otherwise we basically want to treat the "Search" string and the display string
//the same way.
backgroundString = StringUtils.normalize(raw.toString());
}
String normalizedDisplayString = StringUtils.normalize(raw.toString());
removeSpans(raw);
Vector<int[]> matches = new Vector<>();
//Highlight direct substring matches
for (String searchText : searchTerms) {
if ("".equals(searchText)) {
continue;
}
//TODO: Assuming here that our background string exists and
//isn't null due to the background string check above
//check to see where we should start displaying this chunk
int offset = TextUtils.indexOf(normalizedDisplayString, backgroundString);
if (offset == -1) {
//We can't safely highlight any of this, due to this field not actually
//containing the same string we're searching by.
continue;
}
int index = backgroundString.indexOf(searchText);
//int index = TextUtils.indexOf(normalizedDisplayString, searchText);
while (index >= 0) {
//grab the display offset for actually displaying things
int displayIndex = index + offset;
raw.setSpan(new BackgroundColorSpan(Color.parseColor(Localization.get("odk_perfect_match_color"))), displayIndex, displayIndex
+ searchText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
matches.add(new int[]{index, index + searchText.length()});
//index=TextUtils.indexOf(raw, searchText, index + searchText.length());
index = backgroundString.indexOf(searchText, index + searchText.length());
//we have a non-fuzzy match, so make sure we don't fuck with it
}
}
//now insert the spans for any fuzzy matches (if enabled)
if (fuzzySearchEnabled && backgroundString != null) {
backgroundString += " ";
for (String searchText : searchTerms) {
if ("".equals(searchText)) {
continue;
}
int curStart = 0;
int curEnd = backgroundString.indexOf(" ", curStart);
while (curEnd != -1) {
boolean skip = matches.size() != 0;
//See whether the fuzzy match overlaps at all with the concrete matches
for (int[] textMatch : matches) {
if (curStart < textMatch[0] && curEnd <= textMatch[0]) {
skip = false;
} else if (curStart >= textMatch[1] && curEnd > textMatch[1]) {
skip = false;
} else {
//We're definitely inside of this span, so
//don't do any fuzzy matching!
skip = true;
break;
}
}
if (!skip) {
//Walk the string to find words that are fuzzy matched
String currentSpan = backgroundString.substring(curStart, curEnd);
//First, figure out where we should be matching (if we don't
//have anywhere to match, that means there's nothing to display
//anyway)
int indexInDisplay = normalizedDisplayString.indexOf(currentSpan);
int length = (curEnd - curStart);
if (indexInDisplay != -1 &&
org.commcare.cases.util.StringUtils.fuzzyMatch(currentSpan, searchText).first) {
raw.setSpan(new BackgroundColorSpan(Color.parseColor(Localization.get("odk_fuzzy_match_color"))), indexInDisplay,
indexInDisplay + length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
curStart = curEnd + 1;
curEnd = backgroundString.indexOf(" ", curStart);
}
}
}
} catch (Exception excp) {
removeSpans(raw);
Logger.log("search-hl", excp.toString() + " " + ForceCloseLogger.getStackTrace(excp));
}
return raw;
}
/**
* Removes all background color spans from the Spannable
*
* @param raw Spannable to remove background colors from
*/
private static void removeSpans(Spannable raw) {
//Zero out the existing spans
BackgroundColorSpan[] spans = raw.getSpans(0, raw.length(), BackgroundColorSpan.class);
for (BackgroundColorSpan span : spans) {
raw.removeSpan(span);
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// calculate the view and its children's default measurements
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// Adjust the children view's widths based on percentage size hints
int[] widths = ViewUtil.calculateColumnWidths(mHints, getMeasuredWidth());
int i = 0;
for (View view : views) {
if (view != null) {
adjustPadding(view, widths[i]);
LayoutParams params = (LinearLayout.LayoutParams)view.getLayoutParams();
params.width = widths[i];
view.setLayoutParams(params);
}
i++;
}
onMeasureCalled = true;
if (imageViewsToRedraw.size() > 0) {
redrawImageLayoutsInQueue();
}
// Re-calculate the view's measurements based on the percentage adjustments above
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
/**
* Remove view's if the padding is greater than or close to the view's width.
*/
private static void adjustPadding(View view, int width) {
int horizontalPadding = view.getPaddingLeft() + view.getPaddingRight();
if (horizontalPadding >= width || horizontalPadding / (float)width > 0.9f) {
view.setPadding(0, 0, 0, 0);
}
}
}