feat: Finish event details page

This commit is contained in:
Peter Vacho 2025-01-05 13:13:15 +01:00
parent 025234a93b
commit 224f8642bb
Signed by: school
GPG key ID: 8CFC3837052871B4
6 changed files with 324 additions and 7 deletions

View file

@ -1,15 +1,51 @@
package com.p_vacho.neat_calendar.activities package com.p_vacho.neat_calendar.activities
import android.os.Bundle 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.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat 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.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.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() { class EventDetailsActivity : AppCompatActivity() {
private lateinit var event: EventResponse private lateinit var event: EventResponse
private lateinit var categories: Map<String, CategoryResponse>
private lateinit var users: Map<String, UserResponse>
// 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -21,7 +57,205 @@ class EventDetailsActivity : AppCompatActivity() {
insets insets
} }
initializeViews()
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
event = intent.getParcelableExtra<EventResponse>("event")!! event = intent.getParcelableExtra("event")!!
@Suppress("DEPRECATION")
categories = intent.getParcelableArrayListExtra<CategoryResponse>("categories")?.associateBy { it.id } ?: emptyMap()
@Suppress("DEPRECATION")
users = intent.getParcelableArrayListExtra<UserResponse>("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()
}
}
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<Map<String, CategoryResponse>>? {
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<Map<String, UserResponse>>? {
val userIdsToFetch = mutableSetOf<String>().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<String, CategoryResponse> {
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<String>): Map<String, UserResponse> {
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"))
} }
} }

View file

@ -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<UserResponse>
) : RecyclerView.Adapter<UserChipAdapter.UserChipViewHolder>() {
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
}

View file

@ -1,13 +1,16 @@
package com.p_vacho.neat_calendar.api.models package com.p_vacho.neat_calendar.api.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.time.OffsetDateTime import java.time.OffsetDateTime
@Parcelize
data class UserResponse( data class UserResponse(
val user_id: String, val user_id: String,
val username: String, val username: String,
val email: String, val email: String,
val created_at: OffsetDateTime, val created_at: OffsetDateTime,
) ) : Parcelable
data class PartialUserRequest( data class PartialUserRequest(
val username: String?, val username: String?,

View file

@ -121,7 +121,7 @@
android:textSize="14sp" android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary" android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold" android:textStyle="bold"
tools:text="Categories:" /> android:text="@string/categories_section" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvEventCategories" android:id="@+id/rvEventCategories"
@ -133,6 +133,18 @@
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:orientation="horizontal" /> tools:orientation="horizontal" />
<!-- Placeholder for empty categories -->
<TextView
android:id="@+id/tvNoCategories"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/no_categories_placeholder"
android:textSize="14sp"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />
<!-- Attendees Section --> <!-- Attendees Section -->
<TextView <TextView
android:id="@+id/tvEventAttendees" android:id="@+id/tvEventAttendees"
@ -142,18 +154,48 @@
android:textSize="14sp" android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary" android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold" android:textStyle="bold"
tools:text="Attendees:" /> android:text="@string/attendees_section" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvEventAttendees" android:id="@+id/rvEventAttendees"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
tools:listitem="@layout/item_attendee_chip" tools:listitem="@layout/item_user_chip"
tools:itemCount="3" tools:itemCount="3"
tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" tools:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:orientation="horizontal" /> tools:orientation="horizontal" />
<!-- Placeholder for empty attendees -->
<TextView
android:id="@+id/tvNoAttendees"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/no_attendees_placeholder"
android:textSize="14sp"
android:textColor="?android:attr/textColorSecondary"
android:visibility="gone" />
<!-- Event Owner Section -->
<TextView
android:id="@+id/tvEventOwner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textSize="14sp"
android:textColor="?android:attr/textColorPrimary"
android:textStyle="bold"
android:text="@string/event_owner_section" />
<include
android:id="@+id/chipEventOwner"
layout="@layout/item_user_chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp" />
<!-- Created At --> <!-- Created At -->
<TextView <TextView
android:id="@+id/tvEventCreatedAt" android:id="@+id/tvEventCreatedAt"
@ -166,5 +208,4 @@
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -87,4 +87,13 @@
<string name="event_details">Event Details</string> <string name="event_details">Event Details</string>
<string name="category_name">Category Name</string> <string name="category_name">Category Name</string>
<string name="open_categories">Open category management</string> <string name="open_categories">Open category management</string>
<string name="event_start_time">"Start: %1$s"</string>
<string name="event_end_time">End: %1$s</string>
<string name="event_created_at">Created on: %1$s</string>
<string name="categories_section">Categories:</string>
<string name="attendees_section">Attendees:</string>
<string name="no_attendees_placeholder">This event has no attendees.</string>
<string name="no_categories_placeholder">This event has no categories.</string>
<string name="event_owner_section">Event owner:</string>
<string name="no_invited_categories_placeholder">You can\'t see categories for invited events</string>
</resources> </resources>