diff --git a/src/main/java/de/dennisguse/opentracks/ui/markers/MarkerListAdapter.java b/src/main/java/de/dennisguse/opentracks/ui/markers/MarkerListAdapter.java new file mode 100644 index 000000000..f02a65126 --- /dev/null +++ b/src/main/java/de/dennisguse/opentracks/ui/markers/MarkerListAdapter.java @@ -0,0 +1,276 @@ +package de.dennisguse.opentracks.ui.markers; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.ThumbnailUtils; +import android.net.Uri; +import android.util.Log; +import android.util.LruCache; +import android.util.SparseBooleanArray; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.view.ActionMode; +import androidx.recyclerview.widget.RecyclerView; + +import java.io.IOException; +import java.io.InputStream; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +import de.dennisguse.opentracks.R; +import de.dennisguse.opentracks.data.ContentProviderUtils; +import de.dennisguse.opentracks.data.models.Marker; +import de.dennisguse.opentracks.databinding.MarkerListItemBinding; +import de.dennisguse.opentracks.ui.util.ActivityUtils; +import de.dennisguse.opentracks.ui.util.ExecutorListViewService; +import de.dennisguse.opentracks.ui.util.ListItemUtils; +import de.dennisguse.opentracks.ui.util.ThemeUtils; +import de.dennisguse.opentracks.util.IntentUtils; +import de.dennisguse.opentracks.util.StringUtils; + +public class MarkerListAdapter extends RecyclerView.Adapter implements ActionMode.Callback { + + private static final String TAG = MarkerListAdapter.class.getSimpleName(); + + private final AppCompatActivity context; + private final RecyclerView recyclerView; + private List markers; + private final SparseBooleanArray selection; + private boolean selectionMode; + private ActivityUtils.ContextualActionModeCallback actionModeCallback; + private ActionMode actionMode; + + private final ExecutorListViewService executorService = new ExecutorListViewService<>(128); + + private final LruCache memoryCache; + + public MarkerListAdapter(AppCompatActivity context, RecyclerView recyclerView, List markers) { + this.context = context; + this.recyclerView = recyclerView; + this.markers = markers; + + selection = new SparseBooleanArray(); + selectionMode = false; + + // Get max available VM memory, exceeding this amount will throw an OutOfMemory exception. + final long usedMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); + final int cacheSize = (int) (Runtime.getRuntime().maxMemory() - usedMemory) / 8; + + memoryCache = new LruCache<>(cacheSize) { + @Override + protected int sizeOf(Marker.Id key, Bitmap value) { + return value.getByteCount(); + } + }; + } + + public void setActionModeCallback(ActivityUtils.ContextualActionModeCallback actionModeCallback) { + this.actionModeCallback = actionModeCallback; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.marker_list_item, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + ViewHolder viewHolder = (ViewHolder) holder; + + viewHolder.bind(markers.get(position)); + } + + @Override + public int getItemCount() { + if (markers == null) { + return 0; + } + return markers.size(); + } + + public void swapData(List markers) { + this.markers = markers; + this.notifyDataSetChanged(); + } + + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + actionMode.getMenuInflater().inflate(R.menu.list_context_menu, menu); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + selectionMode = true; + actionModeCallback.onPrepare(menu, getCheckedPositions(), getCheckedIds(), true); + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { + if (actionModeCallback.onClick(menuItem.getItemId(), getCheckedPositions(), getCheckedIds())) { + actionMode.finish(); + } + return true; + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + selectionMode = false; + + setAllSelected(false); + selection.clear(); + + actionModeCallback.onDestroy(); + } + + public void setAllSelected(boolean isSelected) { + for (int i = 0; i < getItemCount(); i++) { + ViewHolder holder = (ViewHolder) recyclerView.findViewHolderForLayoutPosition(i); + holder.setSelected(isSelected); + } + } + + private int[] getCheckedPositions() { + List positions = new ArrayList<>(); + + for (int i = 0; i < selection.size(); i++) { + if (selection.valueAt(i)) { + positions.add(selection.keyAt(i)); + } + } + + return positions.stream().mapToInt(i -> i).toArray(); + } + + private long[] getCheckedIds() { + List ids = new ArrayList<>(); + + for (int i = 0; i < selection.size(); i++) { + if (selection.valueAt(i)) { + ViewHolder holder = (ViewHolder) recyclerView.findViewHolderForLayoutPosition(selection.keyAt(i)); + ids.add(holder.getId()); + } + } + + return ids.stream().mapToLong(i -> i).toArray(); + } + + public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { + + private final MarkerListItemBinding viewBinding; + private final View view; + + private Marker marker; + + public ViewHolder(@NonNull View view) { + super(view); + + viewBinding = MarkerListItemBinding.bind(view); + this.view = view; + + view.setOnClickListener(this); + view.setOnLongClickListener(this); + } + + public void bind(Marker marker) { + this.marker = marker; + + viewBinding.markerListItemPhoto.setVisibility(marker.hasPhoto() ? View.VISIBLE : View.GONE); + viewBinding.markerListItemTextGradient.setVisibility(marker.hasPhoto() ? View.VISIBLE : View.GONE); + if (marker.hasPhoto()) { + int height = ThemeUtils.getPhotoHeight(context); + ViewGroup.LayoutParams params = viewBinding.markerListItemPhoto.getLayoutParams(); + params.height = height; + viewBinding.markerListItemPhoto.setLayoutParams(params); + viewBinding.markerListItemTextGradient.setLayoutParams(params); + + asyncLoadPhoto(viewBinding.markerListItemPhoto, marker.getPhotoURI(), marker.getId()); + } + + viewBinding.markerListItemName.setText(marker.getName()); + + { + ZoneOffset timeZone = new ContentProviderUtils(context).getTrack(marker.getTrackId()).getZoneOffset(); + ListItemUtils.setDateAndTime(context, viewBinding.markerListItemDate, viewBinding.markerListItemTime, marker.getTime(), timeZone); + } + + String categoryDescription = StringUtils.getCategoryDescription(marker.getCategory(), marker.getDescription()); + viewBinding.markerListItemTimeDistance.setText(categoryDescription); + + setSelected(selection.get(getLayoutPosition())); + } + + public void setSelected(boolean isSelected) { + selection.put(getLayoutPosition(), isSelected); + view.setActivated(isSelected); + } + + public long getId() { + return marker.getId().id(); + } + + @Override + public void onClick(View view) { + if (selectionMode) { + setSelected(!view.isActivated()); + actionMode.invalidate(); + } else { + Intent intent = IntentUtils.newIntent(context, MarkerDetailActivity.class) + .putExtra(MarkerDetailActivity.EXTRA_MARKER_ID, marker.getId()); + context.startActivity(intent); + } + } + + @Override + public boolean onLongClick(View view) { + actionMode = context.startSupportActionMode(MarkerListAdapter.this); + setSelected(true); + return true; + } + } + + /** + * It loads the photoUrl in the imageView from view. + * It takes the photo from cache or from storage if isn't in the cache. + * + * @param imageView view object where photo will be loaded. + * @param photoUri photo's url. + * @param id marker's id where photo belong. + */ + private void asyncLoadPhoto(ImageView imageView, Uri photoUri, Marker.Id id) { + + Bitmap bm = memoryCache.get(id); + imageView.setImageBitmap(bm); + if (bm != null) { + return; + } + + executorService.execute(id, () -> { + try (InputStream inputStream = context.getContentResolver().openInputStream(photoUri)) { + Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + int height = ThemeUtils.getPhotoHeight(context); + Log.d(TAG, "Width : " + (bitmap.getWidth() / (bitmap.getHeight() / height)) + " | Height: " + height); + final Bitmap finalPhoto = ThumbnailUtils.extractThumbnail(bitmap, bitmap.getWidth() / (bitmap.getHeight() / height), height); + context.runOnUiThread(() -> imageView.setImageBitmap(finalPhoto)); + memoryCache.put(id, finalPhoto); + } catch (IOException e) { + Log.e(TAG, "Failed to image " + e); + } + }); + } +} diff --git a/src/main/java/de/dennisguse/opentracks/ui/util/ListItemUtils.java b/src/main/java/de/dennisguse/opentracks/ui/util/ListItemUtils.java index 88db83581..b07b7455d 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/util/ListItemUtils.java +++ b/src/main/java/de/dennisguse/opentracks/ui/util/ListItemUtils.java @@ -24,7 +24,9 @@ import android.widget.TextView; import androidx.core.content.ContextCompat; +import java.time.Instant; import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import de.dennisguse.opentracks.R; @@ -180,4 +182,17 @@ public class ListItemUtils { textView.setShadowLayer(0, 0, 0, 0); } } + + public static void setDateAndTime(Context context, TextView dateView, TextView timeView, Instant time, ZoneOffset timeZone) { + OffsetDateTime offsetDateTime = OffsetDateTime.ofInstant(time, timeZone); + String dateContent = StringUtils.formatDateTodayRelative(context, offsetDateTime); + String pattern = "HH:mm"; + if (!offsetDateTime.getOffset().equals(OffsetDateTime.now().getOffset())) { + pattern = "HH:mm x"; + } + String timeContent = offsetDateTime.format(DateTimeFormatter.ofPattern(pattern)); + + dateView.setText(dateContent); + timeView.setText(timeContent); + } } diff --git a/src/main/java/de/dennisguse/opentracks/ui/util/ThemeUtils.java b/src/main/java/de/dennisguse/opentracks/ui/util/ThemeUtils.java index bc3845899..8725c663f 100644 --- a/src/main/java/de/dennisguse/opentracks/ui/util/ThemeUtils.java +++ b/src/main/java/de/dennisguse/opentracks/ui/util/ThemeUtils.java @@ -52,4 +52,15 @@ public class ThemeUtils { typedArray.recycle(); return fontSize; } + + /** + * Gets the photo height. + */ + public static int getPhotoHeight(Context context) { + int[] attrs = new int[]{android.R.attr.listPreferredItemHeight}; + TypedArray typeArray = context.obtainStyledAttributes(attrs); + int height = typeArray.getDimensionPixelSize(0, 128); + typeArray.recycle(); + return 2 * height; + } } diff --git a/src/main/res/layout/marker_list_item.xml b/src/main/res/layout/marker_list_item.xml new file mode 100644 index 000000000..39d301535 --- /dev/null +++ b/src/main/res/layout/marker_list_item.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file