From 5d65edd48acd9b6b2f3a17357b1fce459bd58478 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Marques?= <53789763+Goncalo-Marques@users.noreply.github.com> Date: Sat, 8 Jun 2024 01:40:49 +0100 Subject: [PATCH] feat(android): list user container bookmarks (#229) Refs: closes #225, closes #226 ## Summary List the user's container bookmarks in the user account activity. For each bookmark, display the container categories and allow the user to remove the bookmark. --------- Co-authored-by: joaotomaspinheiro --- .../java/com/ecomap/ecomap/MainActivity.kt | 66 +++++++- .../ecomap/clients/ecomap/http/ApiClient.kt | 14 +- .../ContainerCategoriesRecyclerViewAdapter.kt | 9 +- .../ContainerBookmarksRecyclerViewAdapter.kt | 114 +++++++++++++ .../ecomap/ecomap/user/UserAccountActivity.kt | 159 +++++++++++++++++- .../background_container_bookmark.xml | 6 + .../res/drawable/background_info_window.xml | 10 ++ .../app/src/main/res/drawable/bookmark.xml | 16 +- .../src/main/res/drawable/bookmark_fill.xml | 16 +- .../res/layout/activity_create_account.xml | 5 +- .../app/src/main/res/layout/activity_main.xml | 12 +- .../main/res/layout/activity_user_account.xml | 35 +++- .../main/res/layout/container_bookmark.xml | 61 +++++++ .../main/res/layout/container_category.xml | 35 ++++ .../frame_layout_container_categories.xml | 42 ----- .../app/src/main/res/values-en/strings.xml | 2 + .../app/src/main/res/values-pt/strings.xml | 2 + android/app/src/main/res/values/colors.xml | 3 +- android/app/src/main/res/values/strings.xml | 2 + android/app/src/main/res/values/themes.xml | 7 +- android/gradle/libs.versions.toml | 4 +- cspell.yml | 1 + 22 files changed, 543 insertions(+), 78 deletions(-) create mode 100644 android/app/src/main/java/com/ecomap/ecomap/user/ContainerBookmarksRecyclerViewAdapter.kt create mode 100644 android/app/src/main/res/drawable/background_container_bookmark.xml create mode 100644 android/app/src/main/res/drawable/background_info_window.xml create mode 100644 android/app/src/main/res/layout/container_bookmark.xml create mode 100644 android/app/src/main/res/layout/container_category.xml delete 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 5e7cbf25..d008484a 100644 --- a/android/app/src/main/java/com/ecomap/ecomap/MainActivity.kt +++ b/android/app/src/main/java/com/ecomap/ecomap/MainActivity.kt @@ -70,6 +70,17 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { */ private lateinit var containerClusterManager: ClusterManager + /** + * Map containing the container markers, merging those that are in the same position to be + * contained in the same marker. + */ + private val containerMarkers = mutableMapOf() + + /** + * Defines the current container category filter. + */ + private var currentContainerCategoryFilter: ContainerCategory? = null + /** * Defines the group of buttons view. */ @@ -196,6 +207,34 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { closeContainerInfoWindow() } + override fun onStart() { + super.onStart() + + // Focus on the start location, if available. + if (startFocusLocation != null) { + val container = containerMarkers[startFocusLocation] + if (container != null) { + showContainerInfoWindow(container) + map.animateCamera( + CameraUpdateFactory.newLatLngZoom( + container.position, + MAP_CAMERA_ZOOM_CONTAINER_FOCUS + ) + ) + } + } + + // Get user container bookmarks. + getUserContainerBookmarks() + } + + override fun onStop() { + super.onStop() + + // Reset the start focus location. + startFocusLocation = null + } + /** * Populates the given chip group with all the available container categories. */ @@ -230,6 +269,10 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { */ private fun openUserAccountScreen() { val intentUserAccountActivity = Intent(this, UserAccountActivity::class.java) + intentUserAccountActivity.putExtra( + UserAccountActivity.INTENT_EXTRA_CONTAINER_CATEGORY, + currentContainerCategoryFilter?.ordinal + ) startActivity(intentUserAccountActivity) } @@ -279,9 +322,6 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { // Adds the containers in the map. updateContainersUI() - // Get user container bookmarks. - getUserContainerBookmarks() - // Get the current location of the device and set the position of the map. focusMyLocation() } @@ -368,12 +408,11 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { * Updates the map UI by adding the containers as markers using the provided filter. */ private fun updateContainersUI(containerCategoryFilter: ContainerCategory? = null) { + currentContainerCategoryFilter = containerCategoryFilter + // Clear the current markers. containerClusterManager.clearItems() - - // Map containing the filtered containers, merging those that are in the same position to be - // contained in the same marker. - val filteredContainers = mutableMapOf() + containerMarkers.clear() // Helper function to handle a successful response. val handleSuccess = fun(paginatedContainers: ContainersPaginated) { @@ -387,7 +426,7 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { // Add the marker if it is not currently in the Cluster Manager, otherwise append // the container category to the existing marker. - val existingContainer = filteredContainers[containerPosition] + val existingContainer = containerMarkers[containerPosition] if (existingContainer == null) { val containerMarker = ContainerMarker( this, @@ -395,7 +434,7 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { ) containerClusterManager.addItem(containerMarker) - filteredContainers[containerPosition] = containerMarker + containerMarkers[containerPosition] = containerMarker } else { existingContainer.containers.add(container) } @@ -584,6 +623,7 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { // Execute the request to get all existing user container bookmarks and add them to the list. val request = ApiClient.listUserContainerBookmarks( userID, + null, REQUEST_LIST_CONTAINER_LIMIT, 0, token, @@ -593,6 +633,7 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { ApiRequestQueue.getInstance(applicationContext).add( ApiClient.listUserContainerBookmarks( userID, + null, REQUEST_LIST_CONTAINER_LIMIT, REQUEST_LIST_CONTAINER_LIMIT * i, token, @@ -610,6 +651,12 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { } companion object { + /** + * Defines the location to focus on start. + * It is reset to null on the stop activity event. + */ + var startFocusLocation: LatLng? = null + private val LOG_TAG = MainActivity::class.java.simpleName private const val PERMISSIONS_REQUEST_ACCESS_LOCATION = 1 @@ -625,6 +672,7 @@ class MainActivity : AppCompatActivity(), OnMapReadyCallback { private const val MAP_PADDING_BOTTOM = 32 private const val MAP_CAMERA_ZOOM_DEFAULT = 15.0F + private const val MAP_CAMERA_ZOOM_CONTAINER_FOCUS = 17.0F private const val REQUEST_LIST_CONTAINER_LIMIT = 100 diff --git a/android/app/src/main/java/com/ecomap/ecomap/clients/ecomap/http/ApiClient.kt b/android/app/src/main/java/com/ecomap/ecomap/clients/ecomap/http/ApiClient.kt index e1c56cd6..d9b13536 100644 --- a/android/app/src/main/java/com/ecomap/ecomap/clients/ecomap/http/ApiClient.kt +++ b/android/app/src/main/java/com/ecomap/ecomap/clients/ecomap/http/ApiClient.kt @@ -73,6 +73,7 @@ object ApiClient { private const val FIELD_CONTAINER_CREATED_AT = "createdAt" private const val FIELD_CONTAINER_MODIFIED_AT = "modifiedAt" private const val FIELD_CONTAINERS = "containers" + private const val FIELD_FILTER_CONTAINER_CATEGORY = "containerCategory" /** * Signs in a user with a given username and password. @@ -215,8 +216,10 @@ object ApiClient { } /** - * Returns the user container bookmarks with the specified filter. + * Returns the user container bookmarks with the specified filter. The bookmarks are sorted by + * descending order of the date they were created. * @param userID User identifier. + * @param containerCategory Container category to filter by. * @param limit Amount of resources to get for the provided filter. * @param offset Amount of resources to skip for the provided filter. * @param token JWT authorization token. @@ -226,15 +229,20 @@ object ApiClient { */ fun listUserContainerBookmarks( userID: String, + containerCategory: ContainerCategory? = null, limit: Int, offset: Int, token: String, listener: Listener, errorListener: ErrorListener ): JsonObjectRequest { - val url = "$URL_USERS/$userID$URL_BOOKMARK_CONTAINERS" + + var url = "$URL_USERS/$userID$URL_BOOKMARK_CONTAINERS" + "?$FIELD_NAME_PAGINATION_LIMIT=$limit" + - "&$FIELD_NAME_PAGINATION_OFFSET=$offset" + "&$FIELD_NAME_PAGINATION_OFFSET=$offset" + + "&sort=createdAt&order=desc" + if (containerCategory != null) { + url += "&$FIELD_FILTER_CONTAINER_CATEGORY=${mapDomainContainerCategory(containerCategory)}" + } return object : JsonObjectRequest( Method.GET, url, null, 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 index 25cf0952..c51677da 100644 --- a/android/app/src/main/java/com/ecomap/ecomap/map/ContainerCategoriesRecyclerViewAdapter.kt +++ b/android/app/src/main/java/com/ecomap/ecomap/map/ContainerCategoriesRecyclerViewAdapter.kt @@ -15,7 +15,7 @@ import com.ecomap.ecomap.R */ data class ContainerCategoryRecyclerViewData( val iconResourceID: Int, - val category: String, + val category: String = "", ) /** @@ -35,7 +35,7 @@ class ContainerCategoriesRecyclerViewAdapter(private val dataSet: Array) + +/** + * Recycler view adapter for the container bookmarks. + */ +class ContainerBookmarksRecyclerViewAdapter( + private val context: Context, + private val activity: Activity, + private val dataSet: ArrayList +) : + RecyclerView.Adapter() { + /** + * Invoked when a button container bookmark is clicked for a specific position in the data set. + */ + var onButtonContainerBookmarkClicked: ((position: Int) -> Unit)? = null + + /** + * Defines the views in the adapter. + */ + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val constraintLayout: ConstraintLayout = view.findViewById(R.id.constraint_layout) + val textViewMunicipalityName: TextView = view.findViewById(R.id.text_view_municipality_name) + val textViewWayName: TextView = view.findViewById(R.id.text_view_way_name) + val buttonContainerBookmark: ImageButton = + view.findViewById(R.id.button_container_bookmark) + val recyclerContainerCategories: RecyclerView = + view.findViewById(R.id.recycler_container_categories) + } + + 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.container_bookmark, viewGroup, false) + + return ViewHolder(view) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val data = dataSet[position] + + // Set location text data. + if (data.containers.isNotEmpty()) { + val container = data.containers[0] + + viewHolder.constraintLayout.setOnClickListener { focusContainer(container) } + + viewHolder.textViewMunicipalityName.text = container.geoJSON.properties.municipalityName + viewHolder.textViewWayName.text = container.geoJSON.properties.getWayName(context) + } + + // Set button functions. + viewHolder.buttonContainerBookmark.setOnClickListener { + onButtonContainerBookmarkClicked?.invoke(position) + } + + // Populate the container category recycler view. + val containerCategoriesDataSet = + ArrayList(data.containers.size) + for (container in data.containers) { + val categoryData = + ContainerCategoryRecyclerViewData(container.category.getIconResource()) + if (containerCategoriesDataSet.contains(categoryData)) { + // The category already exists in the current data set. + continue + } + + containerCategoriesDataSet.add(categoryData) + } + + viewHolder.recyclerContainerCategories.layoutManager = + LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) + viewHolder.recyclerContainerCategories.adapter = + ContainerCategoriesRecyclerViewAdapter(containerCategoriesDataSet.toTypedArray()) + } + + override fun getItemCount(): Int { + return dataSet.size + } + + /** + * Terminates the current activity focus on the container location. + * @param container Container to focus. + */ + private fun focusContainer(container: Container) { + val containerCoordinates = container.geoJSON.geometry.coordinates + val containerPosition = LatLng(containerCoordinates[1], containerCoordinates[0]) + + MainActivity.startFocusLocation = containerPosition + activity.finish() + } +} diff --git a/android/app/src/main/java/com/ecomap/ecomap/user/UserAccountActivity.kt b/android/app/src/main/java/com/ecomap/ecomap/user/UserAccountActivity.kt index 5924295d..896bee7a 100644 --- a/android/app/src/main/java/com/ecomap/ecomap/user/UserAccountActivity.kt +++ b/android/app/src/main/java/com/ecomap/ecomap/user/UserAccountActivity.kt @@ -11,23 +11,32 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.ecomap.ecomap.Common import com.ecomap.ecomap.R 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.signin.SignInActivity +import com.google.android.gms.maps.model.LatLng class UserAccountActivity : AppCompatActivity() { private lateinit var textViewFirstName: TextView private lateinit var textViewLastName: TextView private lateinit var textViewUsername: TextView + private lateinit var textViewContainerBookmarksEmpty: TextView + private lateinit var recyclerViewContainerBookmarks: RecyclerView private lateinit var progressBar: ProgressBar private lateinit var store: UserStore private lateinit var token: String private lateinit var userID: String + private lateinit var recyclerViewContainerBookmarksDataSet: ArrayList + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -44,16 +53,36 @@ class UserAccountActivity : AppCompatActivity() { // Enable back button on action bar. supportActionBar?.setDisplayHomeAsUpEnabled(true) + // Get extras. + val currentContainerBookmarkOrdinal = + intent.getIntExtra(INTENT_EXTRA_CONTAINER_CATEGORY, -1) + // Get activity views. val buttonSignOut: Button = findViewById(R.id.button_sign_out) textViewFirstName = findViewById(R.id.text_view_first_name_value) textViewLastName = findViewById(R.id.text_view_last_name_value) textViewUsername = findViewById(R.id.text_view_username_value) + textViewContainerBookmarksEmpty = findViewById(R.id.text_view_container_bookmarks_empty) + recyclerViewContainerBookmarks = findViewById(R.id.recycler_container_bookmarks) progressBar = findViewById(R.id.progress_bar_user_account) // Set up on click events for the buttons. buttonSignOut.setOnClickListener { signOutUser() } + // Set up container bookmarks recycler view. + recyclerViewContainerBookmarksDataSet = arrayListOf() + + recyclerViewContainerBookmarks.layoutManager = LinearLayoutManager(this) + val recyclerViewContainerBookmarksAdapter = + ContainerBookmarksRecyclerViewAdapter(this, this, recyclerViewContainerBookmarksDataSet) + recyclerViewContainerBookmarks.adapter = recyclerViewContainerBookmarksAdapter + + updateUserContainerBookmarksVisibility() + recyclerViewContainerBookmarksAdapter.onButtonContainerBookmarkClicked = { itemPosition -> + removeUserContainerBookmark(itemPosition) + updateUserContainerBookmarksVisibility() + } + // Get user store and token. store = UserStore(applicationContext) token = store.getToken().toString() @@ -63,8 +92,14 @@ class UserAccountActivity : AppCompatActivity() { progressBar.visibility = View.VISIBLE // Update UI with the user personal information and bookmarks. + var containerCategoryFilter: ContainerCategory? = null + if (currentContainerBookmarkOrdinal != -1) { + containerCategoryFilter = + ContainerCategory.entries.toTypedArray()[currentContainerBookmarkOrdinal] + } + updateUserPersonalInformationUI() - // TODO: Load bookmarks. + updateUserContainerBookmarksUI(containerCategoryFilter) } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -92,6 +127,42 @@ class UserAccountActivity : AppCompatActivity() { finishAffinity() } + /** + * Removes the user container bookmark associated with the data set at the specified position. + * It also notifies the recycler view adapter to update the affected item. + */ + private fun removeUserContainerBookmark(position: Int) { + // Remove the user container bookmark. + for (container in recyclerViewContainerBookmarksDataSet[position].containers) { + val request = + ApiClient.removeUserContainerBookmark(userID, container.id, token, + {}, {}) + ApiRequestQueue.getInstance(this).add(request) + } + + // Remove the item from the data set. + recyclerViewContainerBookmarksDataSet.removeAt(position) + recyclerViewContainerBookmarks.adapter?.notifyItemRemoved(position) + recyclerViewContainerBookmarks.adapter?.notifyItemRangeChanged( + position, + recyclerViewContainerBookmarksDataSet.size + ) + } + + /** + * Makes the container bookmark list visible if the data set is not empty, otherwise makes it + * invisible. + */ + private fun updateUserContainerBookmarksVisibility() { + if (recyclerViewContainerBookmarksDataSet.isEmpty()) { + textViewContainerBookmarksEmpty.visibility = View.VISIBLE + recyclerViewContainerBookmarks.visibility = View.GONE + } else { + textViewContainerBookmarksEmpty.visibility = View.GONE + recyclerViewContainerBookmarks.visibility = View.VISIBLE + } + } + /** * Gets the user's personal information and sets it in the UI. */ @@ -117,4 +188,90 @@ class UserAccountActivity : AppCompatActivity() { ApiRequestQueue.getInstance(applicationContext).add(request) } + + /** + * Gets the current list of containers that the user has bookmarked and sets them in the UI. + */ + private fun updateUserContainerBookmarksUI(containerCategoryFilter: ContainerCategory? = null) { + // Map containing the containers, merging those that are in the same position to be + // contained in the same item. + val mappedContainers = mutableMapOf() + + // Helper function to handle a successful response. + val handleSuccess = fun(paginatedContainers: ContainersPaginated) { + if (isFinishing || isDestroyed) { + return + } + + for (container in paginatedContainers.containers) { + val containerCoordinates = container.geoJSON.geometry.coordinates + val containerPosition = LatLng(containerCoordinates[1], containerCoordinates[0]) + + // Add the container if it is not currently in the data set, otherwise append the + // container category to the existing item. + val existingContainer = mappedContainers[containerPosition] + if (existingContainer == null) { + val containerBookmarkData = + ContainerBookmarkRecyclerViewData(arrayListOf(container)) + + recyclerViewContainerBookmarksDataSet.add(containerBookmarkData) + recyclerViewContainerBookmarks.adapter?.notifyItemInserted( + recyclerViewContainerBookmarksDataSet.size - 1 + ) + + mappedContainers[containerPosition] = containerBookmarkData + } else { + existingContainer.containers.add(container) + + // Find the position of the changed item. + for ((index, data) in recyclerViewContainerBookmarksDataSet.withIndex()) { + for (c in data.containers) { + if (c.id == container.id) { + recyclerViewContainerBookmarks.adapter?.notifyItemChanged(index) + break + } + } + } + } + } + + updateUserContainerBookmarksVisibility() + } + + // Execute the request to get all existing user container bookmarks and add them to the list. + val request = ApiClient.listUserContainerBookmarks( + userID, + containerCategoryFilter, + REQUEST_LIST_CONTAINER_LIMIT, + 0, + token, + { paginatedContainers -> + val remainingRequest = + paginatedContainers.total / REQUEST_LIST_CONTAINER_LIMIT + for (i in 1..remainingRequest) { + ApiRequestQueue.getInstance(applicationContext).add( + ApiClient.listUserContainerBookmarks( + userID, + containerCategoryFilter, + REQUEST_LIST_CONTAINER_LIMIT, + REQUEST_LIST_CONTAINER_LIMIT * i, + token, + { handleSuccess(it) }, + { Common.handleVolleyError(this, this, it) } + ) + ) + } + + handleSuccess(paginatedContainers) + }, + { Common.handleVolleyError(this, this, it) }) + + ApiRequestQueue.getInstance(applicationContext).add(request) + } + + companion object { + const val INTENT_EXTRA_CONTAINER_CATEGORY = "containerCategory" + + private const val REQUEST_LIST_CONTAINER_LIMIT = 100 + } } diff --git a/android/app/src/main/res/drawable/background_container_bookmark.xml b/android/app/src/main/res/drawable/background_container_bookmark.xml new file mode 100644 index 00000000..13776d78 --- /dev/null +++ b/android/app/src/main/res/drawable/background_container_bookmark.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/background_info_window.xml b/android/app/src/main/res/drawable/background_info_window.xml new file mode 100644 index 00000000..363fd4e3 --- /dev/null +++ b/android/app/src/main/res/drawable/background_info_window.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/bookmark.xml b/android/app/src/main/res/drawable/bookmark.xml index 509148b1..9d4d9b5e 100644 --- a/android/app/src/main/res/drawable/bookmark.xml +++ b/android/app/src/main/res/drawable/bookmark.xml @@ -1,5 +1,13 @@ - - - - + + + + + diff --git a/android/app/src/main/res/drawable/bookmark_fill.xml b/android/app/src/main/res/drawable/bookmark_fill.xml index af14a1de..bfde1b01 100644 --- a/android/app/src/main/res/drawable/bookmark_fill.xml +++ b/android/app/src/main/res/drawable/bookmark_fill.xml @@ -1,5 +1,13 @@ - - - - + + + + + diff --git a/android/app/src/main/res/layout/activity_create_account.xml b/android/app/src/main/res/layout/activity_create_account.xml index 055e9d96..787c721b 100644 --- a/android/app/src/main/res/layout/activity_create_account.xml +++ b/android/app/src/main/res/layout/activity_create_account.xml @@ -44,11 +44,10 @@ android:id="@+id/text_input_layout_first_name" android:layout_width="0dp" android:layout_height="wrap_content" - app:layout_constraintBottom_toBottomOf="parent" + android:layout_marginTop="16dp" app:layout_constraintEnd_toStartOf="@+id/guideline_end" app:layout_constraintStart_toStartOf="@+id/guideline_begin" - app:layout_constraintTop_toBottomOf="@+id/toolbar_create_account" - app:layout_constraintVertical_bias="0.16"> + app:layout_constraintTop_toBottomOf="@+id/toolbar_create_account"> + app:layout_constraintStart_toStartOf="parent" + android:overScrollMode="never" />