From 1aa3f0477fa334dc1edfe4e173273645ca1198be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Marques?= <53789763+Goncalo-Marques@users.noreply.github.com> Date: Sat, 25 May 2024 20:35:39 +0100 Subject: [PATCH] feat(android): display container information on marker click (#216) Refs: closes #17, closes #20 ## Summary Add an info window that displays the container information of the clicked marker. Also add a button to give the user directions to that marker. --- .../java/com/ecomap/ecomap/MainActivity.kt | 137 +++++++++++++++++- .../java/com/ecomap/ecomap/domain/GeoJSON.kt | 14 ++ .../ContainerCategoriesRecyclerViewAdapter.kt | 53 +++++++ .../com/ecomap/ecomap/map/ContainerMarker.kt | 23 ++- .../app/src/main/res/layout/activity_main.xml | 69 +++++++++ .../frame_layout_container_categories.xml | 42 ++++++ .../app/src/main/res/values-en/strings.xml | 3 + .../app/src/main/res/values-pt/strings.xml | 3 + android/app/src/main/res/values/colors.xml | 2 + android/app/src/main/res/values/strings.xml | 3 + cspell.yml | 1 + 11 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/java/com/ecomap/ecomap/map/ContainerCategoriesRecyclerViewAdapter.kt create mode 100644 android/app/src/main/res/layout/frame_layout_container_categories.xml diff --git a/android/app/src/main/java/com/ecomap/ecomap/MainActivity.kt b/android/app/src/main/java/com/ecomap/ecomap/MainActivity.kt index 6b7466b1..5026a841 100644 --- a/android/app/src/main/java/com/ecomap/ecomap/MainActivity.kt +++ b/android/app/src/main/java/com/ecomap/ecomap/MainActivity.kt @@ -4,19 +4,30 @@ import android.Manifest import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Bundle import android.util.Log +import android.view.View +import android.widget.Button +import android.widget.TextView import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.Group import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.ecomap.ecomap.clients.ecomap.http.ApiClient import com.ecomap.ecomap.clients.ecomap.http.ApiRequestQueue import com.ecomap.ecomap.data.UserStore import com.ecomap.ecomap.domain.ContainerCategory import com.ecomap.ecomap.domain.ContainersPaginated +import com.ecomap.ecomap.map.ContainerCategoriesRecyclerViewAdapter +import com.ecomap.ecomap.map.ContainerCategoryRecyclerViewData import com.ecomap.ecomap.map.ContainerClusterRenderer import com.ecomap.ecomap.map.ContainerMarker import com.ecomap.ecomap.signin.SignInActivity @@ -57,6 +68,36 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { */ private lateinit var containerClusterManager: ClusterManager + /** + * Defines the group of buttons view. + */ + private lateinit var groupButtonsView: Group + + /** + * Defines the container info window view. + */ + private lateinit var containerInfoWindowView: ConstraintLayout + + /** + * Defines the container info window title view. + */ + private lateinit var containerInfoWindowTitleText: TextView + + /** + * Defines the container info window snippet view. + */ + private lateinit var containerInfoWindowSnippetText: TextView + + /** + * Defines the container info window categories recycler view. + */ + private lateinit var containerInfoWindowRecyclerCategories: RecyclerView + + /** + * Defines the container info window directions button. + */ + private lateinit var containerInfoWindowDirectionsButton: Button + /** * Defines the authentication token. */ @@ -116,10 +157,21 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { // Get activity views. val chipGroupContainerFilter: ChipGroup = findViewById(R.id.chip_group_container_filter) val buttonMyLocation: FloatingActionButton = findViewById(R.id.button_my_location) + groupButtonsView = findViewById(R.id.group_buttons) + containerInfoWindowView = findViewById(R.id.info_window) + containerInfoWindowTitleText = findViewById(R.id.info_window_text_title) + containerInfoWindowSnippetText = findViewById(R.id.info_window_text_snippet) + containerInfoWindowRecyclerCategories = findViewById(R.id.info_window_recycler_categories) + containerInfoWindowRecyclerCategories.layoutManager = + GridLayoutManager(this, CONTAINER_INFO_WINDOW_RECYCLER_CATEGORIES_SPAN_COUNT) + containerInfoWindowDirectionsButton = findViewById(R.id.info_window_button_directions) // Set button functions. populateChipGroupContainerFilter(chipGroupContainerFilter) buttonMyLocation.setOnClickListener { focusMyLocation() } + + // Start with the container info window closed. + closeContainerInfoWindow() } /** @@ -166,6 +218,28 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { map.setOnCameraIdleListener(containerClusterManager) map.setOnMarkerClickListener(containerClusterManager) + // Set container info window functions. + containerClusterManager.setOnClusterItemClickListener { container -> + // Display the container information window and move to the container location. + showContainerInfoWindow(container) + map.animateCamera( + CameraUpdateFactory.newLatLngZoom( + container.position, + map.cameraPosition.zoom + ) + ) + + // Returns true so that the default info window is not displayed. + true + } + containerClusterManager.setOnClusterClickListener { + closeContainerInfoWindow() + + // Returns false, so the default behavior is still used. + false + } + map.setOnMapClickListener { closeContainerInfoWindow() } + // Prompt the user for permission. getLocationPermission() @@ -283,15 +357,16 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { val existingContainer = filteredContainers[containerPosition] if (existingContainer == null) { val containerMarker = ContainerMarker( - containerPosition, - container.geoJSON.properties.getLocationName(this), - arrayListOf(container.category.getStringResource(this)) + this, + container.id, + container.geoJSON, + arrayListOf(container.category) ) containerClusterManager.addItem(containerMarker) filteredContainers[containerPosition] = containerMarker } else { - existingContainer.categories.add(container.category.getStringResource(this)) + existingContainer.categories.add(container.category) } } @@ -360,6 +435,58 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { } } + /** + * Opens the info window for the given container marker. + * It hides the main buttons and shows the info window with the given container marker data. + */ + private fun showContainerInfoWindow(container: ContainerMarker) { + groupButtonsView.visibility = View.GONE + containerInfoWindowView.visibility = View.VISIBLE + containerInfoWindowTitleText.text = container.geoJSON.properties.municipalityName + containerInfoWindowSnippetText.text = container.geoJSON.properties.getWayName(this) + + val containerCategoriesDataSet = + ArrayList(container.categories.size) + for (containerCategory in container.categories) { + val data = ContainerCategoryRecyclerViewData( + containerCategory.getIconResource(), + containerCategory.getStringResource(this) + ) + if (containerCategoriesDataSet.contains(data)) { + // The category already exists in the current data set. + continue + } + + containerCategoriesDataSet.add(data) + } + containerInfoWindowRecyclerCategories.adapter = + ContainerCategoriesRecyclerViewAdapter(containerCategoriesDataSet.toTypedArray()) + + containerInfoWindowDirectionsButton.setOnClickListener { + val intentMapDirections = Intent(Intent.ACTION_VIEW) + intentMapDirections.data = + Uri.parse("geo:0,0?q=${container.position.latitude},${container.position.longitude}(${container.snippet})") + + if (intentMapDirections.resolveActivity(packageManager) != null) { + // Start activity only if there is an app that can resolve it. + startActivity(intentMapDirections) + } + } + } + + /** + * Closes the container info window. + * It makes the main buttons visible and hides the info window. + */ + private fun closeContainerInfoWindow() { + if (!containerInfoWindowView.isVisible) { + return + } + + groupButtonsView.visibility = View.VISIBLE + containerInfoWindowView.visibility = View.GONE + } + companion object { private val LOG_TAG = MainActivity::class.java.simpleName @@ -378,5 +505,7 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { private const val MAP_CAMERA_ZOOM_DEFAULT = 15.0F private const val REQUEST_LIST_CONTAINER_LIMIT = 100 + + private const val CONTAINER_INFO_WINDOW_RECYCLER_CATEGORIES_SPAN_COUNT = 2 } } diff --git a/android/app/src/main/java/com/ecomap/ecomap/domain/GeoJSON.kt b/android/app/src/main/java/com/ecomap/ecomap/domain/GeoJSON.kt index 6b1eee23..87570a48 100644 --- a/android/app/src/main/java/com/ecomap/ecomap/domain/GeoJSON.kt +++ b/android/app/src/main/java/com/ecomap/ecomap/domain/GeoJSON.kt @@ -20,8 +20,22 @@ data class GeoJSONProperties( var wayName: String = "", var municipalityName: String = "", ) { + /** + * Returns the way name or unknown if not defined. + * @param context Activity context. + * @return Way name or unknown way. + */ + fun getWayName(context: Context): String { + if (wayName.isBlank()) { + return context.getString(R.string.way_unknown) + } + + return this.wayName + } + /** * Returns the location name based on the way and municipality name. + * @param context Activity context. * @return Location name. */ fun getLocationName(context: Context): String { diff --git a/android/app/src/main/java/com/ecomap/ecomap/map/ContainerCategoriesRecyclerViewAdapter.kt b/android/app/src/main/java/com/ecomap/ecomap/map/ContainerCategoriesRecyclerViewAdapter.kt new file mode 100644 index 00000000..25cf0952 --- /dev/null +++ b/android/app/src/main/java/com/ecomap/ecomap/map/ContainerCategoriesRecyclerViewAdapter.kt @@ -0,0 +1,53 @@ +package com.ecomap.ecomap.map + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.ecomap.ecomap.R + +/** + * Represents the structure of an item in the container category recycler view. + * @param iconResourceID Category icon resource ID. + * @param category Category description. + */ +data class ContainerCategoryRecyclerViewData( + val iconResourceID: Int, + val category: String, +) + +/** + * Recycler view adapter for the container categories. + */ +class ContainerCategoriesRecyclerViewAdapter(private val dataSet: Array) : + RecyclerView.Adapter() { + + /** + * Defines the views in the adapter. + */ + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val imageView: ImageView = view.findViewById(R.id.image_view_icon) + val textView: TextView = view.findViewById(R.id.text_view_category) + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { + // Create a new view, which defines the UI of the list item. + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.frame_layout_container_categories, viewGroup, false) + + return ViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val data = dataSet[position] + + viewHolder.imageView.setImageResource(data.iconResourceID) + viewHolder.textView.text = data.category + } + + override fun getItemCount(): Int { + return dataSet.size + } +} diff --git a/android/app/src/main/java/com/ecomap/ecomap/map/ContainerMarker.kt b/android/app/src/main/java/com/ecomap/ecomap/map/ContainerMarker.kt index e17f9359..07941cbf 100644 --- a/android/app/src/main/java/com/ecomap/ecomap/map/ContainerMarker.kt +++ b/android/app/src/main/java/com/ecomap/ecomap/map/ContainerMarker.kt @@ -1,5 +1,8 @@ package com.ecomap.ecomap.map +import android.content.Context +import com.ecomap.ecomap.domain.ContainerCategory +import com.ecomap.ecomap.domain.GeoJSONFeaturePoint import com.google.android.gms.maps.model.LatLng import com.google.maps.android.clustering.ClusterItem @@ -7,20 +10,28 @@ import com.google.maps.android.clustering.ClusterItem * A marker that represents one or more containers at the same position on the map. */ class ContainerMarker( - private val position: LatLng, - private val locationName: String, - val categories: ArrayList + internal val context: Context, + internal val id: String, + internal val geoJSON: GeoJSONFeaturePoint, + val categories: ArrayList ) : ClusterItem { override fun getPosition(): LatLng { - return position + val coordinates = geoJSON.geometry.coordinates + return LatLng(coordinates[1], coordinates[0]) } override fun getTitle(): String { - return locationName + return geoJSON.properties.getLocationName(context) } override fun getSnippet(): String { - return categories.toSet().joinToString(", ") + val categoriesString = ArrayList(categories.size) + for (category in categories) { + categoriesString.add(category.getStringResource(context)) + } + + // Convert to set to avoid duplicate categories. + return categoriesString.toSet().joinToString(", ") } override fun getZIndex(): Float { diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 223e2e35..90d0348e 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -46,7 +46,18 @@ app:layout_constraintTop_toTopOf="parent" tools:layout="@layout/fragment_map" /> + + + + + + + + + + +