From 224f8642bb7fa1292404a37ba01293584c3009fc Mon Sep 17 00:00:00 2001 From: Peter Vacho Date: Sun, 5 Jan 2025 13:13:15 +0100 Subject: [PATCH] feat: Finish event details page --- .../activities/EventDetailsActivity.kt | 238 +++++++++++++++++- .../neat_calendar/adapters/UserChipAdapter.kt | 30 +++ .../neat_calendar/api/models/UserModels.kt | 5 +- .../res/layout/activity_event_details.xml | 49 +++- ...m_attendee_chip.xml => item_user_chip.xml} | 0 app/src/main/res/values/strings.xml | 9 + 6 files changed, 324 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/p_vacho/neat_calendar/adapters/UserChipAdapter.kt rename app/src/main/res/layout/{item_attendee_chip.xml => item_user_chip.xml} (100%) diff --git a/app/src/main/java/com/p_vacho/neat_calendar/activities/EventDetailsActivity.kt b/app/src/main/java/com/p_vacho/neat_calendar/activities/EventDetailsActivity.kt index 5699e2f..3e7a5b9 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/activities/EventDetailsActivity.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/activities/EventDetailsActivity.kt @@ -1,15 +1,51 @@ package com.p_vacho.neat_calendar.activities import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.ImageButton +import android.widget.TextView import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.p_vacho.neat_calendar.MyApplication import com.p_vacho.neat_calendar.R +import com.p_vacho.neat_calendar.adapters.CategoryChipAdapter +import com.p_vacho.neat_calendar.adapters.UserChipAdapter +import com.p_vacho.neat_calendar.api.RetrofitClient +import com.p_vacho.neat_calendar.api.models.CategoryResponse import com.p_vacho.neat_calendar.api.models.EventResponse +import com.p_vacho.neat_calendar.api.models.UserResponse +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.format.DateTimeFormatter class EventDetailsActivity : AppCompatActivity() { private lateinit var event: EventResponse + private lateinit var categories: Map + private lateinit var users: Map + + // UI components + private lateinit var btnBack: ImageButton + private lateinit var tvEventTitle: TextView + private lateinit var tvEventStartTime: TextView + private lateinit var tvEventEndTime: TextView + private lateinit var tvEventDescription: TextView + private lateinit var rvEventCategories: RecyclerView + private lateinit var rvEventAttendees: RecyclerView + private lateinit var tvEventCreatedAt: TextView + private lateinit var tvNoCategories: TextView + private lateinit var tvNoAttendees: TextView + private lateinit var tvEventOwner: TextView + private lateinit var chipEventOwner: Chip override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -21,7 +57,205 @@ class EventDetailsActivity : AppCompatActivity() { insets } + initializeViews() + @Suppress("DEPRECATION") - event = intent.getParcelableExtra("event")!! + event = intent.getParcelableExtra("event")!! + + @Suppress("DEPRECATION") + categories = intent.getParcelableArrayListExtra("categories")?.associateBy { it.id } ?: emptyMap() + + @Suppress("DEPRECATION") + users = intent.getParcelableArrayListExtra("users")?.associateBy { it.user_id } ?: emptyMap() + + lifecycleScope.launch { + // Collect tasks for missing data + // This will also start the fetching coroutines + val fetchCategoriesTask = determineCategoryFetchTask() + val fetchUsersTask = determineUserFetchTask() + + // Await results + val fetchedCategories = fetchCategoriesTask?.await() + val fetchedUsers = fetchUsersTask?.await() + + // Update data maps + if (fetchedCategories != null) categories = categories + fetchedCategories + if (fetchedUsers != null) users = users + fetchedUsers + + setEventDetails() + } } -} \ No newline at end of file + + private fun initializeViews() { + btnBack = findViewById(R.id.btnBack) + tvEventTitle = findViewById(R.id.tvEventTitle) + tvEventStartTime = findViewById(R.id.tvEventStartTime) + tvEventEndTime = findViewById(R.id.tvEventEndTime) + tvEventDescription = findViewById(R.id.tvEventDescription) + rvEventCategories = findViewById(R.id.rvEventCategories) + rvEventAttendees = findViewById(R.id.rvEventAttendees) + tvEventCreatedAt = findViewById(R.id.tvEventCreatedAt) + tvNoCategories = findViewById(R.id.tvNoCategories) + tvNoAttendees = findViewById(R.id.tvNoAttendees) + tvEventOwner = findViewById(R.id.tvEventOwner) + chipEventOwner = findViewById(R.id.chipEventOwner) + + rvEventCategories.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) + rvEventCategories.adapter = CategoryChipAdapter(emptyList(), isRemovable = false) + + rvEventAttendees.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) + rvEventAttendees.adapter = UserChipAdapter(emptyList()) + + btnBack.setOnClickListener { finish() } + } + + /** + * Populate the individual UI items with actual content about the event. + * + * This can only be called once all of the necessary data is fetched. + */ + private fun setEventDetails() { + // Set basic event details + tvEventTitle.text = event.title + tvEventStartTime.text = getString(R.string.event_start_time, formatDateTime(event.start_time.toLocalDateTime())) + tvEventEndTime.text = getString(R.string.event_end_time, formatDateTime(event.end_time.toLocalDateTime())) + tvEventDescription.text = event.description + tvEventCreatedAt.text = getString(R.string.event_created_at, formatDateTime(event.created_at.toLocalDateTime())) + + val userId = (application as MyApplication).tokenManager.userId + + // Set categories or show the placeholder + val categoryList = event.category_ids.mapNotNull { categories[it] } + rvEventCategories.adapter = CategoryChipAdapter(categoryList, isRemovable = false) + tvNoCategories.visibility = if (categoryList.isEmpty()) View.VISIBLE else View.GONE + if (event.owner_user_id == userId) { + tvNoCategories.text = getString(R.string.no_categories_placeholder) + } else { + tvNoCategories.text = getString(R.string.no_invited_categories_placeholder) + } + + + // Set attendees or show the placeholder + val attendeeList = event.attendee_ids.mapNotNull { users[it] } + rvEventAttendees.adapter = UserChipAdapter(attendeeList) + tvNoAttendees.visibility = if (attendeeList.isEmpty()) View.VISIBLE else View.GONE + tvNoAttendees.text = getString(R.string.no_attendees_placeholder) + + // Show the owner section only if this event isn't owned by the logged user + if (event.owner_user_id != userId) { + val owner = users[event.owner_user_id] + if (owner == null) throw IllegalStateException("Event owner wasn't fetched") + tvEventOwner.visibility = View.VISIBLE + chipEventOwner.visibility = View.VISIBLE + chipEventOwner.text = owner.username + } else { + tvEventOwner.visibility = View.GONE + chipEventOwner.visibility = View.GONE + } + } + + /** + * Determines whether event categories need to be fetched. + * + * Categories will be fetched if: + * - They weren't already set + * - One (or more) of the event categories wasn't found in the list of existing categories. + * Note that this case will also produce a warning. + * + * Categories won't be fetched if the currently logged in user isn't also the event owner, + * as they don't have the rights to access the invitor's categories. + * + * This will return back a Deferred object, being obtained in an already started coroutine. + */ + private fun determineCategoryFetchTask(): Deferred>? { + val userId = (application as MyApplication).tokenManager.userId + + if (userId != event.owner_user_id) return null + + return if (categories.isEmpty()) { + lifecycleScope.async { fetchEventCategories(event.id) } + } else if (categories.keys.intersect(event.category_ids).size != event.category_ids.size) { + Log.w( + "EventDetailsActivity", + "One or more of the event categories wasn't found in the list of categories. Categories will be re-fetched." + ) + lifecycleScope.async { fetchEventCategories(event.id) } + } else null + } + + /** + * Determines which users need to be fetched and creates a fetch task if necessary. + * + * This will fetch all the users that are attending the event and the event's user id, + * skipping any users that are already fetched. Note that if any of the event attendees + * aren't already in the users list, yet the list isn't empty, a warning will be produced. + * + * This will return back a Deferred object, being obtained in an already started coroutine. + */ + private fun determineUserFetchTask(): Deferred>? { + val userIdsToFetch = mutableSetOf().apply { + if (users.isEmpty()) { + addAll(event.attendee_ids) + add(event.owner_user_id) + } else { + if (!users.containsKey(event.owner_user_id)) add(event.owner_user_id) + + // Check for missing attendees and log a warning for each + val missingAttendees = event.attendee_ids.filter { !users.containsKey(it) } + if (missingAttendees.isNotEmpty()) { + missingAttendees.forEach { attendeeId -> + Log.w( + "EventDetailsActivity", + "Missing attendee ID: $attendeeId in the provided users list. Will fetch it additionally." + ) + } + addAll(missingAttendees) + } + } + } + + return if (userIdsToFetch.isNotEmpty()) { + lifecycleScope.async { fetchUsers(userIdsToFetch.toList()) } + } else null + } + + /** + * Fetch all the categories of given event. + * + * This will return a hash map, with the category IDs as keys. + */ + private suspend fun fetchEventCategories(eventId: String): Map { + val fetchedCategories = withContext(Dispatchers.IO) { + RetrofitClient.categoryService.eventCategories(eventId) + } + + return fetchedCategories.associateBy { it.id } + } + + /** + * Fetch all the requested users (by IDs). + * + * This will return a hash map, with the user IDs as keys. + * + * Note that this will make an API request for each requested user ID. + * These requests will be made in parallel. + */ + private suspend fun fetchUsers(userIds: List): Map { + val fetchedUsers = withContext(Dispatchers.IO) { + val usersDeferred = userIds.map { async { RetrofitClient.usersService.getUser(it) }} + usersDeferred.map { it.await() } + + } + + return fetchedUsers.associateBy { it.user_id } + } + + /** + * Format a date time object into a string. + * + * This is used to format the datetime shown for the event start, end & creation times. + */ + private fun formatDateTime(dateTime: java.time.LocalDateTime): String { + return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + } +} diff --git a/app/src/main/java/com/p_vacho/neat_calendar/adapters/UserChipAdapter.kt b/app/src/main/java/com/p_vacho/neat_calendar/adapters/UserChipAdapter.kt new file mode 100644 index 0000000..27183dd --- /dev/null +++ b/app/src/main/java/com/p_vacho/neat_calendar/adapters/UserChipAdapter.kt @@ -0,0 +1,30 @@ +package com.p_vacho.neat_calendar.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip +import com.p_vacho.neat_calendar.R +import com.p_vacho.neat_calendar.api.models.UserResponse + +class UserChipAdapter( + private val users: List +) : RecyclerView.Adapter() { + + inner class UserChipViewHolder(val chip: Chip) : RecyclerView.ViewHolder(chip) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserChipViewHolder { + val chip = LayoutInflater.from(parent.context) + .inflate(R.layout.item_user_chip, parent, false) as Chip + return UserChipViewHolder(chip) + } + + override fun onBindViewHolder(holder: UserChipViewHolder, position: Int) { + val user = users[position] + val chip = holder.chip + + chip.text = user.username + } + + override fun getItemCount(): Int = users.size +} diff --git a/app/src/main/java/com/p_vacho/neat_calendar/api/models/UserModels.kt b/app/src/main/java/com/p_vacho/neat_calendar/api/models/UserModels.kt index 2a8248f..2102f72 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/api/models/UserModels.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/api/models/UserModels.kt @@ -1,13 +1,16 @@ package com.p_vacho.neat_calendar.api.models +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import java.time.OffsetDateTime +@Parcelize data class UserResponse( val user_id: String, val username: String, val email: String, val created_at: OffsetDateTime, -) +) : Parcelable data class PartialUserRequest( val username: String?, diff --git a/app/src/main/res/layout/activity_event_details.xml b/app/src/main/res/layout/activity_event_details.xml index 2d7da54..dd362cc 100644 --- a/app/src/main/res/layout/activity_event_details.xml +++ b/app/src/main/res/layout/activity_event_details.xml @@ -121,7 +121,7 @@ android:textSize="14sp" android:textColor="?android:attr/textColorPrimary" android:textStyle="bold" - tools:text="Categories:" /> + android:text="@string/categories_section" /> + + + + android:text="@string/attendees_section" /> + + + + + + + + - diff --git a/app/src/main/res/layout/item_attendee_chip.xml b/app/src/main/res/layout/item_user_chip.xml similarity index 100% rename from app/src/main/res/layout/item_attendee_chip.xml rename to app/src/main/res/layout/item_user_chip.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ce6ea0..7cdf14c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -87,4 +87,13 @@ Event Details Category Name Open category management + "Start: %1$s" + End: %1$s + Created on: %1$s + Categories: + Attendees: + This event has no attendees. + This event has no categories. + Event owner: + You can\'t see categories for invited events \ No newline at end of file