package cgeo.geocaching.ui; import cgeo.geocaching.CacheDetailActivity; import cgeo.geocaching.R; import cgeo.geocaching.enumerations.CacheListType; import cgeo.geocaching.filter.IFilter; import cgeo.geocaching.location.Geopoint; import cgeo.geocaching.models.Geocache; import cgeo.geocaching.sensors.GeoData; import cgeo.geocaching.sensors.Sensors; import cgeo.geocaching.settings.Settings; import cgeo.geocaching.sorting.CacheComparator; import cgeo.geocaching.sorting.DistanceComparator; import cgeo.geocaching.sorting.EventDateComparator; import cgeo.geocaching.sorting.InverseComparator; import cgeo.geocaching.sorting.SeriesNameComparator; import cgeo.geocaching.sorting.VisitComparator; import cgeo.geocaching.utils.AngleUtils; import cgeo.geocaching.utils.CalendarUtils; import cgeo.geocaching.utils.Formatter; import cgeo.geocaching.utils.Log; import cgeo.geocaching.utils.MapUtils; import android.annotation.SuppressLint; import android.app.Activity; import android.content.res.Resources; import android.graphics.drawable.BitmapDrawable; import android.support.annotation.NonNull; import android.support.v4.content.ContextCompat; import android.text.Spannable; import android.text.Spanned; import android.text.style.ForegroundColorSpan; import android.text.style.StrikethroughSpan; import android.view.GestureDetector; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.CheckBox; import android.widget.ImageView; import android.widget.TextView; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import butterknife.BindView; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.functions.Consumer; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; public class CacheListAdapter extends ArrayAdapter<Geocache> { private LayoutInflater inflater = null; private static CacheComparator cacheComparator = null; private Geopoint coords; private float azimuth = 0; private long lastSort = 0L; private boolean selectMode = false; private IFilter currentFilter = null; private List<Geocache> originalList = null; private final boolean isLiveList = Settings.isLiveList(); private final Set<CompassMiniView> compasses = new LinkedHashSet<>(); private final Set<DistanceView> distances = new LinkedHashSet<>(); private final CacheListType cacheListType; private final Resources res; /** Resulting list of caches */ private final List<Geocache> list; private boolean eventsOnly; private boolean inverseSort = false; /** * {@code true} if the caches in this list are a complete series and should be sorted by name instead of distance */ private boolean series = false; private static final int SWIPE_MIN_DISTANCE = 60; private static final int SWIPE_MAX_OFF_PATH = 100; /** * time in milliseconds after which the list may be resorted due to position updates */ private static final int PAUSE_BETWEEN_LIST_SORT = 1000; private static final int[] RATING_BACKGROUND = new int[3]; /** * automatically order cache series by name, if they all have a common suffix or prefix at least these many * characters */ private static final int MIN_COMMON_CHARACTERS_SERIES = 4; static { if (Settings.isLightSkin()) { RATING_BACKGROUND[0] = R.drawable.favorite_background_red_light; RATING_BACKGROUND[1] = R.drawable.favorite_background_orange_light; RATING_BACKGROUND[2] = R.drawable.favorite_background_green_light; } else { RATING_BACKGROUND[0] = R.drawable.favorite_background_red_dark; RATING_BACKGROUND[1] = R.drawable.favorite_background_orange_dark; RATING_BACKGROUND[2] = R.drawable.favorite_background_green_dark; } } /** * view holder for the cache list adapter * */ public static class ViewHolder extends AbstractViewHolder { @BindView(R.id.checkbox) protected CheckBox checkbox; @BindView(R.id.log_status_mark) protected ImageView logStatusMark; @BindView(R.id.text) protected TextView text; @BindView(R.id.distance) protected DistanceView distance; @BindView(R.id.favorite) protected TextView favorite; @BindView(R.id.info) protected TextView info; @BindView(R.id.inventory) protected TextView inventory; @BindView(R.id.direction) protected CompassMiniView direction; @BindView(R.id.dirimg) protected ImageView dirImg; private CacheListType cacheListType; public Geocache cache = null; public ViewHolder(final View view) { super(view); } } public CacheListAdapter(final Activity activity, final List<Geocache> list, final CacheListType cacheListType) { super(activity, 0, list); final GeoData currentGeo = Sensors.getInstance().currentGeo(); coords = currentGeo.getCoords(); this.res = activity.getResources(); this.list = list; this.cacheListType = cacheListType; checkSpecialSortOrder(); } /** * change the sort order * */ public void setComparator(final CacheComparator comparator) { cacheComparator = comparator; forceSort(); } public void resetInverseSort() { inverseSort = false; } public void toggleInverseSort() { inverseSort = !inverseSort; } /** * Set the inverseSort order. * * @param inverseSort * True if sort is inverted */ public void setInverseSort(final boolean inverseSort) { this.inverseSort = inverseSort; } /** * Obtain the current inverseSort order. * * @return * True if sort is inverted */ public boolean getInverseSort() { return inverseSort; } public CacheComparator getCacheComparator() { if (isHistory()) { return VisitComparator.singleton; } if (cacheComparator == null && eventsOnly) { return EventDateComparator.INSTANCE; } if (cacheComparator == null && series) { return SeriesNameComparator.INSTANCE; } if (cacheComparator == null) { return DistanceComparator.INSTANCE; } return cacheComparator; } private boolean isHistory() { return cacheListType == CacheListType.HISTORY; } public Geocache findCacheByGeocode(final String geocode) { for (int i = 0; i < getCount(); i++) { if (getItem(i).getGeocode().equalsIgnoreCase(geocode)) { return getItem(i); } } return null; } /** * Called when a new page of caches was loaded. */ public void reFilter() { if (currentFilter != null) { // Back up the list again originalList = new ArrayList<>(list); currentFilter.filter(list); } } /** * Called after a user action on the filter menu. */ public void setFilter(final IFilter filter) { // Backup current caches list if it isn't backed up yet if (originalList == null) { originalList = new ArrayList<>(list); } // If there is already a filter in place, this is a request to change or clear the filter, so we have to // replace the original cache list if (currentFilter != null) { list.clear(); list.addAll(originalList); } // Do the filtering or clear it if (filter != null) { filter.filter(list); } currentFilter = filter; notifyDataSetChanged(); } public boolean isFiltered() { return currentFilter != null; } public String getFilterName() { return currentFilter.getName(); } public int getCheckedCount() { int checked = 0; for (final Geocache cache : list) { if (cache.isStatusChecked()) { checked++; } } return checked; } public void setSelectMode(final boolean selectMode) { this.selectMode = selectMode; if (!selectMode) { for (final Geocache cache : list) { cache.setStatusChecked(false); } } notifyDataSetChanged(); } public boolean isSelectMode() { return selectMode; } public void switchSelectMode() { setSelectMode(!isSelectMode()); } public void invertSelection() { for (final Geocache cache : list) { cache.setStatusChecked(!cache.isStatusChecked()); } notifyDataSetChanged(); } public void forceSort() { if (CollectionUtils.isEmpty(list) || selectMode) { return; } if (isSortedByDistance()) { lastSort = 0; updateSortByDistance(); } else { Collections.sort(list, getPotentialInversion(getCacheComparator())); } notifyDataSetChanged(); } public void setActualCoordinates(@NonNull final Geopoint coords) { this.coords = coords; updateSortByDistance(); for (final DistanceView distance : distances) { distance.update(coords); } for (final CompassMiniView compass : compasses) { compass.updateCurrentCoords(coords); } } private void updateSortByDistance() { if (CollectionUtils.isEmpty(list)) { return; } if (selectMode) { return; } if ((System.currentTimeMillis() - lastSort) <= PAUSE_BETWEEN_LIST_SORT) { return; } if (!isSortedByDistance()) { return; } if (coords == null) { return; } final List<Geocache> oldList = new ArrayList<>(list); Collections.sort(list, getPotentialInversion(new DistanceComparator(coords, list))); // avoid an update if the list has not changed due to location update if (list.equals(oldList)) { return; } notifyDataSetChanged(); lastSort = System.currentTimeMillis(); } private Comparator<? super Geocache> getPotentialInversion(final CacheComparator comparator) { if (inverseSort) { return new InverseComparator(comparator); } return comparator; } private boolean isSortedByDistance() { final CacheComparator comparator = getCacheComparator(); return comparator == null || comparator instanceof DistanceComparator; } private boolean isSortedByEvent() { final CacheComparator comparator = getCacheComparator(); return comparator == null || comparator instanceof EventDateComparator; } private boolean isSortedBySeries() { final CacheComparator comparator = getCacheComparator(); return comparator == null || comparator instanceof SeriesNameComparator; } public void setActualHeading(final float direction) { if (Math.abs(AngleUtils.difference(azimuth, direction)) < 5) { return; } azimuth = direction; for (final CompassMiniView compass : compasses) { compass.updateAzimuth(azimuth); } } public static void updateViewHolder(final ViewHolder holder, final Geocache cache, final Resources res) { if (cache.isFound() && cache.isLogOffline()) { holder.logStatusMark.setImageResource(R.drawable.mark_green_orange); holder.logStatusMark.setVisibility(View.VISIBLE); } else if (cache.isFound()) { holder.logStatusMark.setImageResource(R.drawable.mark_green_more); holder.logStatusMark.setVisibility(View.VISIBLE); } else if (cache.isLogOffline()) { holder.logStatusMark.setImageResource(R.drawable.mark_orange); holder.logStatusMark.setVisibility(View.VISIBLE); } else { holder.logStatusMark.setVisibility(View.GONE); } holder.text.setCompoundDrawablesWithIntrinsicBounds(MapUtils.getCacheMarker(res, cache, holder.cacheListType), null, null, null); } @Override public View getView(final int position, final View rowView, final ViewGroup parent) { if (inflater == null) { inflater = LayoutInflater.from(getContext()); } if (position > getCount()) { Log.w("CacheListAdapter.getView: Attempt to access missing item #" + position); return null; } final Geocache cache = getItem(position); View v = rowView; final ViewHolder holder; if (v == null) { v = inflater.inflate(R.layout.cacheslist_item, parent, false); holder = new ViewHolder(v); } else { holder = (ViewHolder) v.getTag(); } holder.cache = cache; final boolean lightSkin = Settings.isLightSkin(); final TouchListener touchListener = new TouchListener(cache, this); v.setOnClickListener(touchListener); v.setOnLongClickListener(touchListener); v.setOnTouchListener(touchListener); holder.checkbox.setVisibility(selectMode ? View.VISIBLE : View.GONE); holder.checkbox.setChecked(cache.isStatusChecked()); holder.checkbox.setOnClickListener(new SelectionCheckBoxListener(cache)); distances.add(holder.distance); holder.distance.setContent(cache.getCoords()); compasses.add(holder.direction); holder.direction.setTargetCoords(cache.getCoords()); Spannable spannable = null; if (cache.isDisabled() || cache.isArchived() || CalendarUtils.isPastEvent(cache)) { // strike spannable = Spannable.Factory.getInstance().newSpannable(cache.getName()); spannable.setSpan(new StrikethroughSpan(), 0, spannable.toString().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (cache.isArchived()) { // red color if (spannable == null) { spannable = Spannable.Factory.getInstance().newSpannable(cache.getName()); } spannable.setSpan(new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.archived_cache_color)), 0, spannable.toString().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (spannable != null) { holder.text.setText(spannable, TextView.BufferType.SPANNABLE); } else { holder.text.setText(cache.getName(), TextView.BufferType.NORMAL); } holder.cacheListType = cacheListType; updateViewHolder(holder, cache, res); final int inventorySize = cache.getInventoryItems(); if (inventorySize > 0) { holder.inventory.setText(Integer.toString(inventorySize)); holder.inventory.setVisibility(View.VISIBLE); } else { holder.inventory.setVisibility(View.GONE); } if (cache.getDistance() != null) { holder.distance.setDistance(cache.getDistance()); } if (cache.getCoords() != null && coords != null) { holder.distance.update(coords); } // only show the direction if this is enabled in the settings if (isLiveList) { if (cache.getCoords() != null) { holder.direction.setVisibility(View.VISIBLE); holder.dirImg.setVisibility(View.GONE); holder.direction.updateAzimuth(azimuth); if (coords != null) { holder.direction.updateCurrentCoords(coords); } } else if (cache.getDirection() != null) { holder.direction.setVisibility(View.VISIBLE); holder.dirImg.setVisibility(View.GONE); holder.direction.updateAzimuth(azimuth); holder.direction.updateHeading(cache.getDirection()); } else if (StringUtils.isNotBlank(cache.getDirectionImg())) { holder.dirImg.setVisibility(View.INVISIBLE); holder.direction.setVisibility(View.GONE); DirectionImage.fetchDrawable(cache.getDirectionImg()).observeOn(AndroidSchedulers.mainThread()).subscribe(new Consumer<BitmapDrawable>() { @Override public void accept(final BitmapDrawable bitmapDrawable) { if (cache == holder.cache) { holder.dirImg.setImageDrawable(bitmapDrawable); holder.dirImg.setVisibility(View.VISIBLE); } } }); } else { holder.dirImg.setVisibility(View.GONE); holder.direction.setVisibility(View.GONE); } } holder.favorite.setText(Integer.toString(cache.getFavoritePoints())); int favoriteBack; // set default background, neither vote nor rating may be available if (lightSkin) { favoriteBack = R.drawable.favorite_background_light; } else { favoriteBack = R.drawable.favorite_background_dark; } final float rating = cache.getRating(); if (rating >= 3.5) { favoriteBack = RATING_BACKGROUND[2]; } else if (rating >= 2.1) { favoriteBack = RATING_BACKGROUND[1]; } else if (rating > 0.0) { favoriteBack = RATING_BACKGROUND[0]; } holder.favorite.setBackgroundResource(favoriteBack); if (isHistory() && cache.getVisitedDate() > 0) { holder.info.setText(Formatter.formatCacheInfoHistory(cache)); } else { holder.info.setText(Formatter.formatCacheInfoLong(cache)); } return v; } @Override public void notifyDataSetChanged() { super.notifyDataSetChanged(); distances.clear(); compasses.clear(); } private static class SelectionCheckBoxListener implements View.OnClickListener { private final Geocache cache; SelectionCheckBoxListener(final Geocache cache) { this.cache = cache; } @Override public void onClick(final View view) { final boolean checkNow = ((CheckBox) view).isChecked(); cache.setStatusChecked(checkNow); } } private static class TouchListener implements View.OnClickListener, View.OnLongClickListener, View.OnTouchListener { private final Geocache cache; private final GestureDetector gestureDetector; @NonNull private final WeakReference<CacheListAdapter> adapterRef; TouchListener(final Geocache cache, @NonNull final CacheListAdapter adapter) { this.cache = cache; gestureDetector = new GestureDetector(adapter.getContext(), new FlingGesture(cache, adapter)); adapterRef = new WeakReference<>(adapter); } // Tap on item @Override public void onClick(final View view) { final CacheListAdapter adapter = adapterRef.get(); if (adapter == null) { return; } if (adapter.isSelectMode()) { cache.setStatusChecked(!cache.isStatusChecked()); adapter.notifyDataSetChanged(); } else { CacheDetailActivity.startActivity(adapter.getContext(), cache.getGeocode(), cache.getName()); } } // Long tap on item @Override public boolean onLongClick(final View view) { view.showContextMenu(); return true; } // Swipe on item @SuppressLint("ClickableViewAccessibility") @Override public boolean onTouch(final View view, final MotionEvent event) { return gestureDetector.onTouchEvent(event); } } private static class FlingGesture extends GestureDetector.SimpleOnGestureListener { private final Geocache cache; @NonNull private final WeakReference<CacheListAdapter> adapterRef; FlingGesture(final Geocache cache, @NonNull final CacheListAdapter adapter) { this.cache = cache; adapterRef = new WeakReference<>(adapter); } @Override public boolean onFling(final MotionEvent e1, final MotionEvent e2, final float velocityX, final float velocityY) { try { if (Math.abs(e1.getY() - e2.getY()) > SWIPE_MAX_OFF_PATH) { return false; } final CacheListAdapter adapter = adapterRef.get(); if (adapter == null) { return false; } // left to right swipe if ((e2.getX() - e1.getX()) > SWIPE_MIN_DISTANCE && Math.abs(velocityX) > Math.abs(velocityY)) { if (!adapter.selectMode) { adapter.switchSelectMode(); cache.setStatusChecked(true); } return true; } // right to left swipe if ((e1.getX() - e2.getX()) > SWIPE_MIN_DISTANCE && Math.abs(velocityX) > Math.abs(velocityY)) { if (adapter.selectMode) { adapter.switchSelectMode(); } return true; } } catch (final Exception e) { Log.w("CacheListAdapter.FlingGesture.onFling", e); } return false; } } public List<Geocache> getFilteredList() { return list; } public List<Geocache> getCheckedCaches() { final List<Geocache> result = new ArrayList<>(); for (final Geocache cache : list) { if (cache.isStatusChecked()) { result.add(cache); } } return result; } public List<Geocache> getCheckedOrAllCaches() { final List<Geocache> result = getCheckedCaches(); if (!result.isEmpty()) { return result; } return new ArrayList<>(list); } public int getCheckedOrAllCount() { final int checked = getCheckedCount(); if (checked > 0) { return checked; } return list.size(); } public void checkSpecialSortOrder() { checkEvents(); checkSeries(); if (!eventsOnly && isSortedByEvent()) { setComparator(DistanceComparator.INSTANCE); } if (!series && isSortedBySeries()) { setComparator(DistanceComparator.INSTANCE); } } private void checkEvents() { eventsOnly = true; for (final Geocache cache : list) { if (!cache.isEventCache()) { eventsOnly = false; return; } } } /** * detect whether all caches in this list belong to a series with similar names */ private void checkSeries() { series = false; if (list.size() < 3 || list.size() > 50) { return; } final ArrayList<String> names = new ArrayList<>(); final ArrayList<String> reverseNames = new ArrayList<>(); for (final Geocache cache : list) { final String name = cache.getName(); names.add(name); reverseNames.add(StringUtils.reverse(name)); } final String commonPrefix = StringUtils.getCommonPrefix(names.toArray(new String[names.size()])); if (StringUtils.length(commonPrefix) >= MIN_COMMON_CHARACTERS_SERIES) { series = true; } else { final String commonSuffix = StringUtils.getCommonPrefix(reverseNames.toArray(new String[reverseNames.size()])); if (StringUtils.length(commonSuffix) >= MIN_COMMON_CHARACTERS_SERIES) { series = true; } } if (series) { setComparator(new SeriesNameComparator()); } } public boolean isEventsOnly() { return eventsOnly; } }