package org.commcare.android.adapters;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import org.commcare.android.models.Entity;
import org.commcare.android.models.notifications.NotificationMessageFactory;
import org.commcare.android.models.notifications.NotificationMessageFactory.StockMessages;
import org.commcare.android.util.SessionUnavailableException;
import org.commcare.android.util.CachingAsyncImageLoader;
import org.commcare.android.view.GridEntityView;
import org.commcare.android.util.StringUtils;
import org.commcare.android.view.EntityView;
import org.commcare.android.view.TextImageAudioView;
import org.commcare.dalvik.R;
import org.commcare.dalvik.application.CommCareApplication;
import org.commcare.dalvik.preferences.CommCarePreferences;
import org.commcare.suite.model.Detail;
import org.commcare.suite.model.DetailField;
import org.javarosa.core.model.Constants;
import org.javarosa.core.model.instance.TreeReference;
import org.javarosa.xpath.XPathTypeMismatchException;
import org.javarosa.xpath.expr.XPathFuncExpr;
import org.odk.collect.android.views.media.AudioController;
import android.content.Context;
import android.database.DataSetObserver;
import android.speech.tts.TextToSpeech;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ListAdapter;
/**
* @author ctsims
* @author wspride
*
* This adapter class handles displaying the cases for a CommCareODK user.
* Depending on the <grid> block of the Detail this adapter is constructed with, cases might be
* displayed as normal EntityViews or as AdvancedEntityViews
*/
public class EntityListAdapter implements ListAdapter {
public static final int SPECIAL_ACTION = -2;
private int actionPosition = -1;
private boolean actionEnabled;
private boolean mFuzzySearchEnabled = true;
Context context;
Detail detail;
List<DataSetObserver> observers;
List<Entity<TreeReference>> full;
List<Entity<TreeReference>> current;
List<TreeReference> references;
TextToSpeech tts;
AudioController controller;
private TreeReference selected;
private boolean hasWarned;
int currentSort[] = {};
boolean reverseSort = false;
private String[] currentSearchTerms;
public static int SCALE_FACTOR = 4; // How much we want to degrade the image quality to enable faster laoding. TODO: get cleverer
private CachingAsyncImageLoader mImageLoader; // Asyncronous image loader, allows rows with images to scroll smoothly
private boolean usesGridView = false; // false until we determine the Detail has at least one <grid> block
private boolean inAwesomeMode = false;
public EntityListAdapter(Context context, Detail detail, List<TreeReference> references, List<Entity<TreeReference>> full,
int[] sort, TextToSpeech tts, AudioController controller) throws SessionUnavailableException {
this.detail = detail;
this.full = full;
current = new ArrayList<Entity<TreeReference>>();
this.references = references;
this.context = context;
this.observers = new ArrayList<DataSetObserver>();
if(sort.length != 0) {
sort(sort);
}
filterValues("");
this.tts = tts;
this.controller = controller;
if(android.os.Build.VERSION.SDK_INT >= 14){
mImageLoader = new CachingAsyncImageLoader(context, SCALE_FACTOR);
}
else{
mImageLoader = null;
}
if(detail.getCustomAction() != null) {
}
usesGridView = detail.usesGridView();
this.mFuzzySearchEnabled = CommCarePreferences.isFuzzySearchEnabled();
}
private void filterValues(String filterRaw) {
String[] searchTerms = filterRaw.split(" ");
for(int i = 0 ; i < searchTerms.length ; ++i) {
searchTerms[i] = StringUtils.normalize(searchTerms[i]);
}
current.clear();
full:
for(Entity<TreeReference> e : full) {
if("".equals(filterRaw)) {
current.add(e);
continue;
}
boolean add = false;
filter:
for(String filter: searchTerms) {
add = false;
for(int i = 0 ; i < e.getNumFields(); ++i) {
String field = StringUtils.normalize(e.getFieldString(i));
if(field.toLowerCase().contains(filter)) {
add = true;
continue filter;
} else {
//We possibly now want to test for edit distance for fuzzy matching
if(mFuzzySearchEnabled) {
String sortField = e.getSortField(i);
if(sortField != null) {
//We always fuzzy match on the sort field and only if it is available
//(as a way to restrict possible matching)
sortField = StringUtils.normalize(sortField);
for(String fieldChunk : sortField.split(" ")) {
if(StringUtils.fuzzyMatch(fieldChunk, filter)) {
add = true;
continue filter;
}
}
}
}
}
}
if(!add) { break; }
}
if(add) {
current.add(e);
continue full;
}
}
this.currentSearchTerms = searchTerms;
if(actionEnabled) {
actionPosition = current.size();
}
}
private void sort(int[] fields) {
//The reversing here is only relevant if there's only one sort field and we're on it
sort(fields, (currentSort.length == 1 && currentSort[0] == fields[0]) ? !reverseSort : false);
}
private void sort(int[] fields, boolean reverse) {
this.reverseSort = reverse;
hasWarned = false;
currentSort = fields;
java.util.Collections.sort(full, new Comparator<Entity<TreeReference>>() {
public int compare(Entity<TreeReference> object1, Entity<TreeReference> object2) {
for(int i = 0 ; i < currentSort.length ; ++i) {
boolean reverseLocal = (detail.getFields()[currentSort[i]].getSortDirection() == DetailField.DIRECTION_DESCENDING) ^ reverseSort;
int cmp = (reverseLocal ? -1 : 1) * getCmp(object1, object2, currentSort[i]);
if(cmp != 0 ) { return cmp;}
}
return 0;
}
private int getCmp(Entity<TreeReference> object1, Entity<TreeReference> object2, int index) {
int i = detail.getFields()[index].getSortType();
String a1 = object1.getSortField(index);
String a2 = object2.getSortField(index);
if(a1 == null) { a1 = object1.getFieldString(i); }
if(a2 == null) { a2 = object2.getFieldString(i); }
//TODO: We might want to make this behavior configurable (Blanks go first, blanks go last, etc);
//For now, regardless of typing, blanks are always smaller than non-blanks
if(a1.equals("")) {
if(a2.equals("")) { return 0; }
else { return -1; }
} else if(a2.equals("")) {
return 1;
}
Comparable c1 = applyType(i, a1);
Comparable c2 = applyType(i, a2);
if(c1 == null || c2 == null) {
//Don't do something smart here, just bail.
return -1;
}
return c1.compareTo(c2);
}
private Comparable applyType(int sortType, String value) {
try {
if(sortType == Constants.DATATYPE_TEXT) {
return value.toLowerCase();
} else if(sortType == Constants.DATATYPE_INTEGER) {
//Double int compares just fine here and also
//deals with NaN's appropriately
double ret = XPathFuncExpr.toInt(value);
if(Double.isNaN(ret)){
String[] stringArgs = new String[3];
stringArgs[2] = value;
if(!hasWarned){
CommCareApplication._().reportNotificationMessage(NotificationMessageFactory.message(StockMessages.Bad_Case_Filter, stringArgs));
hasWarned = true;
}
}
return ret;
} else if(sortType == Constants.DATATYPE_DECIMAL) {
double ret = XPathFuncExpr.toDouble(value);
if(Double.isNaN(ret)){
String[] stringArgs = new String[3];
stringArgs[2] = value;
if(!hasWarned){
CommCareApplication._().reportNotificationMessage(NotificationMessageFactory.message(StockMessages.Bad_Case_Filter, stringArgs));
hasWarned = true;
}
}
return ret;
} else {
//Hrmmmm :/ Handle better?
return value;
}
} catch(XPathTypeMismatchException e) {
//So right now this will fail 100% silently, which is bad.
return null;
}
}
});
}
/* (non-Javadoc)
* @see android.widget.ListAdapter#areAllItemsEnabled()
*/
public boolean areAllItemsEnabled() {
return true;
}
/* (non-Javadoc)
* @see android.widget.ListAdapter#isEnabled(int)
*/
public boolean isEnabled(int position) {
return true;
}
/*
* Includes action, if enabled, as an item.
*
* (non-Javadoc)
* @see android.widget.Adapter#getCount()
*/
public int getCount() {
return getCount(false, false);
}
/*
* Returns total number of items, ignoring any filter.
*/
public int getFullCount() {
return getCount(false, true);
}
/*
* Get number of items, with a parameter to decide whether or not action counts as an item.
*/
public int getCount(boolean ignoreAction, boolean fullCount) {
//Always one extra element if the action is defined
return (fullCount ? full.size() : current.size()) + (actionEnabled && !ignoreAction ? 1 : 0);
}
/* (non-Javadoc)
* @see android.widget.Adapter#getItem(int)
*/
public TreeReference getItem(int position) {
return current.get(position).getElement();
}
/* (non-Javadoc)
* @see android.widget.Adapter#getItemId(int)
*/
public long getItemId(int position) {
if(actionEnabled) {
if(position == actionPosition) {
return SPECIAL_ACTION;
}
}
return references.indexOf(current.get(position).getElement());
}
/* (non-Javadoc)
* @see android.widget.Adapter#getItemViewType(int)
*/
public int getItemViewType(int position) {
if(actionEnabled) {
if(position == actionPosition) {
return 1;
}
}
return 0;
}
public void setController(AudioController controller) {
this.controller = controller;
}
/* (non-Javadoc)
* @see android.widget.Adapter#getView(int, android.view.View, android.view.ViewGroup)
*/
/* Note that position gives a unique "row" id, EXCEPT that the header row AND the first content row
* are both assigned position 0 -- this is not an issue for current usage, but it could be in future
*/
public View getView(int position, View convertView, ViewGroup parent) {
if(actionEnabled && position == actionPosition) {
TextImageAudioView tiav =(TextImageAudioView)convertView;
if(tiav == null) {
tiav = new TextImageAudioView(context);
}
tiav.setDisplay(detail.getCustomAction().getDisplay());
tiav.setBackgroundResource(R.drawable.list_bottom_tab);
//We're gonna double pad this because we want to give it some visual distinction
//and keep the icon more centered
int padding = (int) context.getResources().getDimension(R.dimen.entity_padding);
tiav.setPadding(padding, padding, padding, padding);
return tiav;
}
Entity<TreeReference> entity = current.get(position);
// if we use a <grid>, setup an AdvancedEntityView
if(usesGridView){
GridEntityView emv =(GridEntityView)convertView;
if(emv == null) {
emv = new GridEntityView(context, detail, entity, currentSearchTerms, mImageLoader, controller);
} else{
emv.setViews(context, detail, entity);
}
return emv;
}
// if not, just use the normal row
else{
EntityView emv =(EntityView)convertView;
if (emv == null) {
emv = new EntityView(context, detail, entity, tts, currentSearchTerms, controller, position, mFuzzySearchEnabled);
} else {
emv.setSearchTerms(currentSearchTerms);
emv.refreshViewsForNewEntity(entity, entity.getElement().equals(selected), position);
}
return emv;
}
}
/* (non-Javadoc)
* @see android.widget.Adapter#getViewTypeCount()
*/
public int getViewTypeCount() {
return actionEnabled? 2 : 1;
}
/* (non-Javadoc)
* @see android.widget.Adapter#hasStableIds()
*/
public boolean hasStableIds() {
return true;
}
/* (non-Javadoc)
* @see android.widget.Adapter#isEmpty()
*/
public boolean isEmpty() {
return getCount() > 0;
}
public void applyFilter(String s) {
filterValues(s);
update();
}
private void update() {
for(DataSetObserver o : observers) {
o.onChanged();
}
}
public void sortEntities(int[] keys) {
sort(keys);
}
public int[] getCurrentSort() {
return currentSort;
}
public boolean isCurrentSortReversed() {
return reverseSort;
}
/* (non-Javadoc)
* @see android.widget.Adapter#registerDataSetObserver(android.database.DataSetObserver)
*/
public void registerDataSetObserver(DataSetObserver observer) {
this.observers.add(observer);
}
/* (non-Javadoc)
* @see android.widget.Adapter#unregisterDataSetObserver(android.database.DataSetObserver)
*/
public void unregisterDataSetObserver(DataSetObserver observer) {
this.observers.remove(observer);
}
public void notifyCurrentlyHighlighted(TreeReference chosen) {
this.selected = chosen;
update();
}
public void setAwesomeMode(boolean awesome){
inAwesomeMode = awesome;
}
public int getPosition(TreeReference chosen) {
for(int i = 0 ; i < current.size() ; ++i) {
Entity<TreeReference> e = current.get(i);
if(e.getElement().equals(chosen)) {
return i;
}
}
return -1;
}
}