package org.commcare.views;
import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.Point;
import android.os.Build;
import android.support.v4.util.Pair;
import android.support.v4.widget.Space;
import android.support.v7.widget.GridLayout;
import android.text.Spannable;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Display;
import android.view.Gravity;
import android.view.View;
import android.widget.ImageView;
import android.widget.ImageView.ScaleType;
import android.widget.TextView;
import org.commcare.cases.entity.Entity;
import org.commcare.dalvik.R;
import org.commcare.models.AsyncEntity;
import org.commcare.suite.model.Detail;
import org.commcare.util.GridCoordinate;
import org.commcare.util.GridStyle;
import org.commcare.utils.CachingAsyncImageLoader;
import org.commcare.utils.MarkupUtil;
import org.commcare.utils.MediaUtil;
import org.commcare.views.media.AudioPlaybackButton;
import org.commcare.views.media.ViewId;
import org.javarosa.core.services.Logger;
import org.javarosa.xpath.XPathUnhandledException;
import java.util.Arrays;
/**
* This class defines an individual panel that is shown either within an advanced case list
* or as a case tile. Each panel is defined by a Detail and an Entity.
*
* @author wspride
*/
public class EntityViewTile extends GridLayout {
private String[] searchTerms;
// All of the views that are being shown in this tile, one for each field of the entity's detail
private View[] mFieldViews;
private boolean mFuzzySearchEnabled = false;
private boolean mIsAsynchronous = false;
// load the screen-size-dependent font sizes
private final float SMALL_FONT = getResources().getDimension(R.dimen.font_size_small);
private final float MEDIUM_FONT = getResources().getDimension(R.dimen.font_size_medium);
private final float LARGE_FONT = getResources().getDimension(R.dimen.font_size_large);
private final float XLARGE_FONT = getResources().getDimension(R.dimen.font_size_xlarge);
private final float DENSITY = getResources().getDisplayMetrics().density;
private final int DEFAULT_TILE_PADDING_HORIZONTAL =
(int)getResources().getDimension(R.dimen.row_padding_horizontal);
private final int DEFAULT_TILE_PADDING_VERTICAL =
(int)getResources().getDimension(R.dimen.row_padding_vertical);
// Constants used to calibrate how many tiles should be shown on a screen at once -- This is
// saying that we expect a device with a density of 160 dpi and a height of 4 inches to look
// good in portrait mode with 4 tiles of 6 rows each being displayed at once.
private static final double DEFAULT_SCREEN_HEIGHT_IN_INCHES = 4.0;
private static final int DEFAULT_SCREEN_DENSITY = DisplayMetrics.DENSITY_MEDIUM;
private static final double DEFAULT_NUM_TILES_PER_SCREEN_PORTRAIT = 4;
private static final int DEFAULT_NUMBER_ROWS_PER_GRID = 6;
private static final double LANDSCAPE_TO_PORTRAIT_RATIO = .75;
// this is fixed for all tiles
private static final int NUMBER_COLUMNS_PER_GRID = 12;
private final int numRowsPerTile;
private final int numTilesPerRow;
private double cellWidth;
private double cellHeight;
private final CachingAsyncImageLoader mImageLoader;
private final boolean beingDisplayedInAwesomeMode;
public static EntityViewTile createTileForIndividualDisplay(Context context, Detail detail,
Entity entity) {
return new EntityViewTile(context, detail, entity, new String[0],
new CachingAsyncImageLoader(context), false, false);
}
public static EntityViewTile createTileForEntitySelectDisplay(Context context, Detail detail,
Entity entity,
String[] searchTerms,
CachingAsyncImageLoader loader,
boolean fuzzySearchEnabled,
boolean inAwesomeMode) {
return new EntityViewTile(context, detail, entity, searchTerms, loader,
fuzzySearchEnabled, inAwesomeMode);
}
private EntityViewTile(Context context, Detail detail, Entity entity, String[] searchTerms,
CachingAsyncImageLoader loader, boolean fuzzySearchEnabled,
boolean inAwesomeMode) {
super(context);
this.searchTerms = searchTerms;
this.mIsAsynchronous = entity instanceof AsyncEntity;
this.mImageLoader = loader;
this.mFuzzySearchEnabled = fuzzySearchEnabled;
this.numRowsPerTile = getMaxRows(detail);
this.numTilesPerRow = detail.getNumEntitiesToDisplayPerRow();
this.beingDisplayedInAwesomeMode = inAwesomeMode;
setEssentialGridLayoutValues();
setCellWidthAndHeight(context, detail);
addFieldViews(context, detail, entity);
}
/**
* @return the maximum height of the grid view for the given detail
*/
private static int getMaxRows(Detail detail) {
GridCoordinate[] coordinates = detail.getGridCoordinates();
int currentMaxHeight = 0;
for (GridCoordinate coordinate : coordinates) {
int yCoordinate = coordinate.getY();
int height = coordinate.getHeight();
int maxHeight = yCoordinate + height;
if (maxHeight > currentMaxHeight) {
currentMaxHeight = maxHeight;
}
}
return currentMaxHeight;
}
private void setEssentialGridLayoutValues() {
setColumnCount(NUMBER_COLUMNS_PER_GRID);
setRowCount(numRowsPerTile);
setPaddingIfNotInGridView();
}
private void setCellWidthAndHeight(Context context, Detail detail) {
Pair<Integer, Integer> tileWidthAndHeight = computeTileWidthAndHeight(context);
cellWidth = tileWidthAndHeight.first / (double)NUMBER_COLUMNS_PER_GRID;
if (detail.useUniformUnitsInCaseTile()) {
cellHeight = cellWidth;
} else {
cellHeight = tileWidthAndHeight.second / (double)numRowsPerTile;
}
}
/**
* Compute what the width and height of a single tile should be, based upon the available
* screen space, how many columns there should be, and how many rows we want to show at a time.
* Round up to the nearest integer since the GridView's width and height will ultimately be
* computed indirectly from these values, and those values need to be integers, and we don't
* want to end up cutting things off
*/
private Pair<Integer, Integer> computeTileWidthAndHeight(Context context) {
double screenWidth, screenHeight;
Display display = ((Activity)context).getWindowManager().getDefaultDisplay();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB_MR2) {
Point size = new Point();
display.getSize(size);
screenWidth = size.x;
screenHeight = size.y;
} else {
screenWidth = display.getWidth();
screenHeight = display.getHeight();
}
if (!tileBeingShownInGridView()) {
// If we added padding, subtract that space since we can't use it
screenWidth = screenWidth - DEFAULT_TILE_PADDING_HORIZONTAL * 2;
}
int tileHeight;
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
if (beingDisplayedInAwesomeMode) {
// if in awesome mode, split available width in half
screenWidth = screenWidth / 2;
}
tileHeight = (int)Math.ceil(screenHeight / computeNumTilesPerScreen(false, screenHeight));
} else {
tileHeight = (int)Math.ceil(screenHeight / computeNumTilesPerScreen(true, screenHeight));
}
int tileWidth = (int)Math.ceil(screenWidth / numTilesPerRow);
return new Pair<>(tileWidth, tileHeight);
}
/**
* @return - The number of tiles that should be displayed on a single screen on this device,
* calibrated against our default values based upon both the screen density and the screen
* height in inches
*/
private double computeNumTilesPerScreen(boolean inPortrait, double screenHeightInPixels) {
double numTilesPerScreenPortrait = DEFAULT_NUM_TILES_PER_SCREEN_PORTRAIT *
(DEFAULT_NUMBER_ROWS_PER_GRID / (float)numRowsPerTile);
double baseNumberOfTiles;
if (inPortrait) {
baseNumberOfTiles = numTilesPerScreenPortrait;
} else {
baseNumberOfTiles = numTilesPerScreenPortrait * LANDSCAPE_TO_PORTRAIT_RATIO;
}
int screenDensity = getResources().getDisplayMetrics().densityDpi;
return (baseNumberOfTiles + getAdditionalTilesBasedOnScreenDensity(screenDensity))
* getScreenHeightMultiplier(screenDensity, screenHeightInPixels);
}
private static double getAdditionalTilesBasedOnScreenDensity(int screenDensity) {
// For every additional 160dpi from the default density, show one more tile on the screen
int defaultDensityDpi = DEFAULT_SCREEN_DENSITY;
return (screenDensity - defaultDensityDpi) / 160;
}
private static double getScreenHeightMultiplier(int screenDensity, double screenHeightInPixels) {
double screenHeightInInches = screenHeightInPixels / screenDensity;
return screenHeightInInches / DEFAULT_SCREEN_HEIGHT_IN_INCHES;
}
/**
* Add Spaces to this GridLayout to strictly enforce that Grid columns and rows stay the width/height we want
* Android GridLayout tries to be very "smart" about moving entries placed arbitrarily within the grid so that
* they look "ordered" even though this often ends up looking not how we want it. It will only make these adjustments
* to rows/columns that end up empty or near empty, so we solve this by adding spaces to every row and column.
* We just add a space of width cellWidth and height 1 to every column of the first row, and likewise a sapce of height
* cellHeight and width 1 to every row of the first column. These are then written on top of if need be.
*/
private void addBuffers(Context context) {
addBuffersToView(context, numRowsPerTile, true);
addBuffersToView(context, NUMBER_COLUMNS_PER_GRID, false);
}
private void addBuffersToView(Context context, int count, boolean isRow) {
for (int i = 0; i < count; i++) {
GridLayout.LayoutParams gridParams;
if (isRow) {
gridParams = new GridLayout.LayoutParams(GridLayout.spec(i), GridLayout.spec(0));
gridParams.width = 1;
gridParams.height = (int)cellHeight;
} else {
gridParams = new GridLayout.LayoutParams(GridLayout.spec(0), GridLayout.spec(i));
gridParams.width = (int)cellWidth;
gridParams.height = 1;
}
Space space = new Space(context);
space.setLayoutParams(gridParams);
this.addView(space, gridParams);
}
}
/**
* Add the view for each field in the detail
*
* @param detail - the Detail describing how to display each entry
* @param entity - the Entity describing the actual data of each entry
*/
public void addFieldViews(Context context, Detail detail, Entity entity) {
this.removeAllViews();
addBuffers(context); // add spacers to enforce regularized column and row size
GridCoordinate[] coordinatesOfEachField = detail.getGridCoordinates();
String[] typesOfEachField = detail.getTemplateForms();
GridStyle[] stylesOfEachField = detail.getGridStyles();
Log.v("TempForms", "Template: " + Arrays.toString(typesOfEachField) +
" | Coords: " + Arrays.toString(coordinatesOfEachField) +
" | Styles: " + Arrays.toString(stylesOfEachField));
setPaddingIfNotInGridView();
if (tileBeingShownInGridView()) {
// Fake dividers between each square in the grid view by using contrasting
// background colors for the grid view as a whole and each element in the grid view
setBackgroundColor(Color.WHITE);
}
mFieldViews = new View[coordinatesOfEachField.length];
for (int i = 0; i < mFieldViews.length; i++) {
addFieldView(context, typesOfEachField[i], stylesOfEachField[i],
coordinatesOfEachField[i], entity.getFieldString(i), entity.getSortField(i), i);
}
}
private void addFieldView(Context context, String form,
GridStyle style, GridCoordinate coordinateData, String fieldString,
String sortField, int index) {
if (coordinatesInvalid(coordinateData)) {
return;
}
ViewId uniqueId = ViewId.buildTableViewId(coordinateData.getX(), coordinateData.getY(), false);
GridLayout.LayoutParams gridParams = getLayoutParamsForField(coordinateData);
View view = getView(context, style, form, fieldString, uniqueId, sortField,
gridParams.width, gridParams.height);
if (!(view instanceof ImageView)) {
gridParams.height = LayoutParams.WRAP_CONTENT;
}
view.setLayoutParams(gridParams);
mFieldViews[index] = view;
this.addView(view, gridParams);
}
private GridLayout.LayoutParams getLayoutParamsForField(GridCoordinate coordinateData) {
Spec columnSpec = GridLayout.spec(coordinateData.getX(), coordinateData.getWidth());
Spec rowSpec = GridLayout.spec(coordinateData.getY(), coordinateData.getHeight());
GridLayout.LayoutParams gridParams = new GridLayout.LayoutParams(rowSpec, columnSpec);
gridParams.width = (int)Math.ceil(cellWidth * coordinateData.getWidth());
gridParams.height = (int)Math.ceil(cellHeight * coordinateData.getHeight());
return gridParams;
}
private boolean coordinatesInvalid(GridCoordinate coordinate) {
if (coordinate.getX() + coordinate.getWidth() > NUMBER_COLUMNS_PER_GRID ||
coordinate.getY() + coordinate.getHeight() > numRowsPerTile) {
Logger.log("e", "Grid entry dimensions exceed allotted sizes");
throw new XPathUnhandledException("grid coordinates out of bounds: " +
coordinate.getX() + " " + coordinate.getWidth() + ", " +
coordinate.getY() + " " + coordinate.getHeight());
}
return (coordinate.getX() < 0 || coordinate.getY() < 0);
}
/**
* Get the correct View for this particular activity.
*
* @param fieldForm either "image", "audio", or default text. Describes how this XPath result should be displayed.
* @param rowData The actual data to display, either an XPath to media or a String to display.
*/
private View getView(Context context, GridStyle style, String fieldForm, String rowData,
ViewId uniqueId, String searchField, int maxWidth, int maxHeight) {
// How the text should be aligned horizontally - left, center, or right
String horzAlign = style.getHorzAlign();
// How the text should be aligned vertically - top, center, or bottom
String vertAlign = style.getVertAlign();
View retVal;
switch (fieldForm) {
case EntityView.FORM_IMAGE:
retVal = new ImageView(context);
setScaleType((ImageView)retVal, horzAlign);
// make the image's padding proportional to its size
retVal.setPadding(maxWidth / 6, maxHeight / 6, maxWidth / 6, maxHeight / 6);
if (rowData != null && !rowData.equals("")) {
if (mImageLoader != null) {
mImageLoader.display(rowData, ((ImageView)retVal), R.drawable.info_bubble,
maxWidth, maxHeight);
} else {
Bitmap b = MediaUtil.inflateDisplayImage(getContext(), rowData,
maxWidth, maxHeight, true);
((ImageView)retVal).setImageBitmap(b);
}
}
break;
case EntityView.FORM_AUDIO:
boolean isButtonVisible = rowData != null && rowData.length() > 0;
retVal = new AudioPlaybackButton(context, rowData, uniqueId, isButtonVisible);
break;
default:
retVal = new TextView(context);
//the html spanner currently does weird stuff like converts "a a" into "a a"
//so we've gotta mirror that for the search text. Booooo. I dunno if there's any
//other other side effects (newlines? nbsp?)
String htmlIfiedSearchField = searchField == null ? null : MarkupUtil.getSpannable(searchField).toString();
String cssid = style.getCssID();
if (cssid != null && !cssid.equals("none")) {
// user defined a style we want to use
Spannable mSpannable = MarkupUtil.getCustomSpannable(cssid, rowData);
EntityView.highlightSearches(searchTerms, mSpannable, htmlIfiedSearchField, mFuzzySearchEnabled, mIsAsynchronous);
((TextView)retVal).setText(mSpannable);
} else {
// just process inline markup
Spannable mSpannable = MarkupUtil.returnCSS(rowData);
EntityView.highlightSearches(searchTerms, mSpannable, htmlIfiedSearchField, mFuzzySearchEnabled, mIsAsynchronous);
((TextView)retVal).setText(mSpannable);
}
int gravity = computeGravity(horzAlign, vertAlign);
if (gravity != 0) {
((TextView)retVal).setGravity(gravity);
}
// handle text resizing
switch (style.getFontSize()) {
case "large":
((TextView)retVal).setTextSize(LARGE_FONT / DENSITY);
break;
case "small":
((TextView)retVal).setTextSize(SMALL_FONT / DENSITY);
break;
case "medium":
((TextView)retVal).setTextSize(MEDIUM_FONT / DENSITY);
break;
case "xlarge":
((TextView)retVal).setTextSize(XLARGE_FONT / DENSITY);
break;
}
}
return retVal;
}
private static int computeGravity(String horzAlign, String vertAlign) {
int gravity = 0;
// handle horizontal alignments
switch (horzAlign) {
case "center":
gravity |= Gravity.CENTER_HORIZONTAL;
break;
case "left":
gravity |= Gravity.LEFT;
break;
case "right":
gravity |= Gravity.RIGHT;
break;
}
// handle vertical alignment
switch (vertAlign) {
case "center":
gravity |= Gravity.CENTER_VERTICAL;
break;
case "top":
gravity |= Gravity.TOP;
break;
case "bottom":
gravity |= Gravity.BOTTOM;
break;
}
return gravity;
}
private static void setScaleType(ImageView imageView, String horizontalAlignment) {
switch (horizontalAlignment) {
case "center":
imageView.setScaleType(ScaleType.CENTER_INSIDE);
break;
case "left":
imageView.setScaleType(ScaleType.FIT_START);
break;
case "right":
imageView.setScaleType(ScaleType.FIT_END);
break;
}
}
public void setSearchTerms(String[] currentSearchTerms) {
this.searchTerms = currentSearchTerms;
}
public void setTextColor(int color) {
for (View rowView : mFieldViews) {
if (rowView instanceof TextView) {
((TextView)rowView).setTextColor(color);
}
}
}
public void setTitleTextColor(int color) {
for (View rowView : mFieldViews) {
if (rowView instanceof TextView) {
((TextView)rowView).setTextColor(color);
return;
}
}
}
private boolean tileBeingShownInGridView() {
return numTilesPerRow > 1;
}
private void setPaddingIfNotInGridView() {
if (!tileBeingShownInGridView()) {
setPadding(DEFAULT_TILE_PADDING_HORIZONTAL, DEFAULT_TILE_PADDING_VERTICAL,
DEFAULT_TILE_PADDING_HORIZONTAL, DEFAULT_TILE_PADDING_VERTICAL);
}
}
}