Compare commits

...

6 commits

17 changed files with 595 additions and 102 deletions

View file

@ -18,7 +18,6 @@ import com.p_vacho.neat_calendar.R
import com.p_vacho.neat_calendar.adapters.CategoryAdapter import com.p_vacho.neat_calendar.adapters.CategoryAdapter
import com.p_vacho.neat_calendar.api.RetrofitClient 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.CategoryResponse
import com.p_vacho.neat_calendar.api.models.EventResponse
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -38,7 +37,12 @@ class CategoriesActivity : AppCompatActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val newCategory: CategoryResponse? = result.data?.getParcelableExtra("newCategory") val newCategory: CategoryResponse? = result.data?.getParcelableExtra("newCategory")
newCategory?.let { categoryCreateReply(it) } @Suppress("DEPRECATION")
val editedCategory: CategoryResponse? = result.data?.getParcelableExtra("editedCategory")
if (newCategory != null && editedCategory != null) throw IllegalStateException("Got both edit & new response")
if (newCategory != null) categoryCreateReply(newCategory, CategoryMode.CREATE)
if (editedCategory != null) categoryCreateReply(editedCategory, CategoryMode.EDIT)
} }
} }
@ -59,14 +63,14 @@ class CategoriesActivity : AppCompatActivity() {
tvEmptyState = findViewById(R.id.tvEmptyState) tvEmptyState = findViewById(R.id.tvEmptyState)
btnBack.setOnClickListener { finish() } btnBack.setOnClickListener { finish() }
btnAddCategory.setOnClickListener { navigateToCreateCategory() } btnAddCategory.setOnClickListener { handleCreateCategory() }
rvCategories.layoutManager = LinearLayoutManager(this) rvCategories.layoutManager = LinearLayoutManager(this)
lifecycleScope.launch { lifecycleScope.launch {
categories = fetchCategories().toMutableList() categories = fetchCategories().toMutableList()
val adapter = CategoryAdapter(categories, ::handleDeleteCategory) val adapter = CategoryAdapter(categories, ::handleDeleteCategory, ::handleEditCategory)
rvCategories.adapter = adapter rvCategories.adapter = adapter
updateEmptyState() updateEmptyState()
@ -105,20 +109,44 @@ class CategoriesActivity : AppCompatActivity() {
} }
/** /**
* Navigates to the activity for adding a new category. * Handle create category button getting pressed.
*
* Navigates to the create category activity in create mode.
*/ */
private fun navigateToCreateCategory() { private fun handleCreateCategory() {
val intent = Intent(this, CreateCategoryActivity::class.java) val intent = Intent(this, CreateCategoryActivity::class.java).apply {
startActivity(intent) putExtra("mode", CategoryMode.CREATE.name)
}
createActivityLauncher.launch(intent)
}
/**
* Handle edit category button being pressed.
*
* Navigates to the create category activity in edit mode.
*/
private fun handleEditCategory(category: CategoryResponse, position: Int) {
val intent = Intent(this, CreateCategoryActivity::class.java).apply {
putExtra("mode", CategoryMode.EDIT.name)
putExtra("category", category)
}
createActivityLauncher.launch(intent)
} }
/** /**
* Used as a callback, triggered when the CreateCategory Activity returns a result. * Used as a callback, triggered when the CreateCategory Activity returns a result.
* *
* The returned value (the new category data) is passed over as a parameter. * The returned value (the new / edited category data) is passed over as a parameter.
*/ */
private fun categoryCreateReply(category: CategoryResponse) { private fun categoryCreateReply(category: CategoryResponse, mode: CategoryMode) {
(rvCategories.adapter as CategoryAdapter).addCategory(category) val adapter = (rvCategories.adapter as CategoryAdapter)
when (mode) {
CategoryMode.CREATE -> {
adapter.addCategory(category)
updateEmptyState()
}
CategoryMode.EDIT -> { adapter.editCategory(category) }
}
} }
/** /**

View file

@ -1,13 +1,48 @@
package com.p_vacho.neat_calendar.activities package com.p_vacho.neat_calendar.activities
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
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 com.github.dhaval2404.colorpicker.ColorPickerDialog
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputEditText
import com.google.gson.Gson
import com.p_vacho.neat_calendar.R import com.p_vacho.neat_calendar.R
import com.p_vacho.neat_calendar.api.RetrofitClient
import com.p_vacho.neat_calendar.api.models.CategoryRequest
import com.p_vacho.neat_calendar.api.models.CategoryResponse
import com.p_vacho.neat_calendar.api.models.PartialCategoryRequest
import com.p_vacho.neat_calendar.api.models.ValidationError
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import kotlin.properties.Delegates
enum class CategoryMode {
CREATE, EDIT
}
class CreateCategoryActivity : AppCompatActivity() { class CreateCategoryActivity : AppCompatActivity() {
private var selectedColor by Delegates.notNull<Int>()
private var existingCategory: CategoryResponse? = null
private lateinit var mode: CategoryMode
// UI components
private lateinit var etCategoryName: TextInputEditText
private lateinit var btnColorPicker: MaterialButton
private lateinit var btnSaveCategory: MaterialButton
private lateinit var btnCancel: MaterialButton
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@ -17,5 +52,107 @@ class CreateCategoryActivity : AppCompatActivity() {
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
insets insets
} }
initializeViews()
mode = intent.getStringExtra("mode")!!.let { CategoryMode.valueOf(it) }
if (mode == CategoryMode.EDIT) {
@Suppress("DEPRECATION")
existingCategory = intent.getParcelableExtra("category")!!
etCategoryName.setText(existingCategory?.name)
selectedColor = existingCategory?.color?.toArgb() ?: ContextCompat.getColor(this, R.color.category_indicator_color)
btnSaveCategory.setText(R.string.update_category)
} else {
selectedColor = ContextCompat.getColor(this, R.color.category_indicator_color)
}
btnColorPicker.iconTint = ColorStateList.valueOf(selectedColor)
setupListeners()
} }
}
private fun initializeViews() {
etCategoryName = findViewById(R.id.etCategoryName)
btnColorPicker = findViewById(R.id.btnColorPicker)
btnSaveCategory = findViewById(R.id.btnSaveCategory)
btnCancel = findViewById(R.id.btnCancel)
}
private fun setupListeners() {
btnColorPicker.setOnClickListener { openColorPickerDialog() }
btnSaveCategory.setOnClickListener { saveCategory() }
btnCancel.setOnClickListener { finish() }
}
private fun saveCategory() {
val categoryName = etCategoryName.text.toString().trim()
if (categoryName.isEmpty()) {
Toast.makeText(this, getString(R.string.please_enter_a_category_name), Toast.LENGTH_SHORT).show()
return
}
lifecycleScope.launch(Dispatchers.IO) {
try {
val resultCategory = if (mode == CategoryMode.CREATE) {
val request =
CategoryRequest(name = categoryName, color = Color.valueOf(selectedColor))
RetrofitClient.categoryService.createCategory(request)
} else {
val request = PartialCategoryRequest(
name = categoryName.takeIf { it != existingCategory?.name },
color = Color.valueOf(selectedColor)
.takeIf { it != existingCategory?.color }
)
RetrofitClient.categoryService.updateCategory(existingCategory!!.id, request)
}
withContext(Dispatchers.Main) { handleCategorySaved(resultCategory) }
} catch (e: HttpException) {
if (e.code() != 422) {
throw e
}
val errorBody = e.response()?.errorBody()?.string()
val validationError = Gson().fromJson(errorBody, ValidationError::class.java)
val errMsg = validationError.detail.joinToString("\n")
Log.e("CategoryCreate", "Got HTTP 422: $validationError")
withContext(Dispatchers.Main) {
Toast.makeText(
this@CreateCategoryActivity,
getString(R.string.failed_to_save_category, errMsg),
Toast.LENGTH_SHORT
).show()
}
}
}
}
private fun handleCategorySaved(category: CategoryResponse) {
Log.w("CreateCategory", "Ending")
val intent = Intent().apply {
if (mode == CategoryMode.CREATE) {
putExtra("newCategory", category)
Log.w("CreateCategory", "New")
} else {
putExtra("editedCategory", category)
Log.w("CreateCategory", "Edit")
}
}
setResult(RESULT_OK, intent)
finish()
}
private fun openColorPickerDialog() {
ColorPickerDialog
.Builder(this)
.setTitle(getString(R.string.select_color))
.setDefaultColor(selectedColor)
.setColorListener { color, _ ->
selectedColor = color
btnColorPicker.iconTint = ColorStateList.valueOf(color)
}
.show()
}
}

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

@ -11,12 +11,14 @@ import com.p_vacho.neat_calendar.api.models.CategoryResponse
class CategoryAdapter( class CategoryAdapter(
private val categories: MutableList<CategoryResponse>, private val categories: MutableList<CategoryResponse>,
private val onDeleteClick: (CategoryResponse, Int) -> Unit private val onDeleteClick: (CategoryResponse, Int) -> Unit,
private val onEditClick: (CategoryResponse, Int) -> Unit
) : RecyclerView.Adapter<CategoryAdapter.CategoryViewHolder>() { ) : RecyclerView.Adapter<CategoryAdapter.CategoryViewHolder>() {
inner class CategoryViewHolder(view: View) : RecyclerView.ViewHolder(view) { inner class CategoryViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val colorIndicator: View = view.findViewById(R.id.colorIndicator) val colorIndicator: View = view.findViewById(R.id.colorIndicator)
val categoryName: TextView = view.findViewById(R.id.tvCategoryName) val categoryName: TextView = view.findViewById(R.id.tvCategoryName)
val editButton: ImageButton = view.findViewById(R.id.btnEditCategory)
val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteCategory) val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteCategory)
} }
@ -33,10 +35,9 @@ class CategoryAdapter(
holder.colorIndicator.setBackgroundColor(category.color.toArgb()) holder.colorIndicator.setBackgroundColor(category.color.toArgb())
holder.categoryName.text = category.name holder.categoryName.text = category.name
// Set click listener for the delete button // Set click listeners for the buttons
holder.deleteButton.setOnClickListener { holder.deleteButton.setOnClickListener { onDeleteClick(category, position) }
onDeleteClick(category, position) holder.editButton.setOnClickListener { onEditClick(category, position) }
}
} }
override fun getItemCount(): Int = categories.size override fun getItemCount(): Int = categories.size
@ -64,4 +65,15 @@ class CategoryAdapter(
categories.add(category) categories.add(category)
notifyItemInserted(categories.size) notifyItemInserted(categories.size)
} }
/**
* Edit an existing category, updating it in the UI.
*
* Call this after the onEditClick callback edits the category from the backend API.
*/
fun editCategory(category: CategoryResponse) {
val position = categories.indexOfFirst { it.id == category.id }
categories[position] = category
notifyItemChanged(position)
}
} }

View file

@ -2,11 +2,9 @@ package com.p_vacho.neat_calendar.adapters
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView 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.R
import com.p_vacho.neat_calendar.api.models.CategoryResponse import com.p_vacho.neat_calendar.api.models.CategoryResponse
@ -14,47 +12,37 @@ class CategoryChipAdapter(
private val categories: List<CategoryResponse>, private val categories: List<CategoryResponse>,
private val isRemovable: Boolean = false, private val isRemovable: Boolean = false,
private val onRemoveCategory: ((CategoryResponse, Int) -> Unit)? = null private val onRemoveCategory: ((CategoryResponse, Int) -> Unit)? = null
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<CategoryChipAdapter.CategoryChipViewHolder>() {
inner class CategoryViewHolder(val textView: TextView) : RecyclerView.ViewHolder(textView) inner class CategoryChipViewHolder(val chip: Chip) : RecyclerView.ViewHolder(chip)
inner class RemovableCategoryViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val textView: TextView = view.findViewById(R.id.chipText)
val removeButton: ImageButton = view.findViewById(R.id.removeButton)
}
override fun getItemViewType(position: Int): Int { override fun getItemViewType(position: Int): Int {
return if (isRemovable) 1 else 0 return if (isRemovable) 1 else 0
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CategoryChipViewHolder {
return if (viewType == 1) { val layoutResId = if (viewType == 1) {
val view = LayoutInflater.from(parent.context) R.layout.item_removable_category_chip
.inflate(R.layout.item_removable_category_chip, parent, false)
RemovableCategoryViewHolder(view)
} else { } else {
val view = LayoutInflater.from(parent.context) R.layout.item_category_chip
.inflate(R.layout.item_category_chip, parent, false) as TextView
CategoryViewHolder(view)
} }
val chip = LayoutInflater.from(parent.context)
.inflate(layoutResId, parent, false) as Chip
return CategoryChipViewHolder(chip)
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: CategoryChipViewHolder, position: Int) {
val category = categories[position] val category = categories[position]
val chip = holder.chip
if (holder is CategoryViewHolder) { // Set the chip's text and background color
// Regular category chip chip.text = category.name
holder.textView.text = category.name chip.chipStrokeColor = ColorStateList.valueOf(category.color.toArgb())
holder.textView.backgroundTintList = ColorStateList.valueOf(category.color.toArgb())
} else if (holder is RemovableCategoryViewHolder) {
// Removable category chip
holder.textView.text = category.name
// Apply background tint to the LinearLayout (container) if (isRemovable) {
holder.itemView.backgroundTintList = ColorStateList.valueOf(category.color.toArgb()) // Set close icon click listener for removable chips
chip.setOnCloseIconClickListener {
// Set up the remove button's click listener
holder.removeButton.setOnClickListener {
onRemoveCategory?.invoke(category, position) onRemoveCategory?.invoke(category, position)
} }
} }

View file

@ -80,7 +80,7 @@ class EventCardAdapter(
// Initialize empty state for categories // Initialize empty state for categories
holder.rvCategories.layoutManager = holder.rvCategories.layoutManager =
LinearLayoutManager(holder.itemView.context, LinearLayoutManager.HORIZONTAL, false) LinearLayoutManager(holder.itemView.context, LinearLayoutManager.HORIZONTAL, false)
holder.rvCategories.adapter = CategoryChipAdapter(emptyList()) holder.rvCategories.adapter = CategoryChipAdapter(emptyList(), isRemovable = false)
// Events list might contain events that are owned by others (invited events) // Events list might contain events that are owned by others (invited events)
// we can't show the categories & edit / delete buttons for these, as we don't // we can't show the categories & edit / delete buttons for these, as we don't
@ -138,7 +138,7 @@ class EventCardAdapter(
} }
// Update the RecyclerView adapter on the main thread // Update the RecyclerView adapter on the main thread
holder.rvCategories.adapter = CategoryChipAdapter(categories) holder.rvCategories.adapter = CategoryChipAdapter(categories, isRemovable = false)
} }
} }
} }

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

@ -24,7 +24,7 @@ interface CategoryService {
suspend fun createCategory(@Body categoryData: CategoryRequest): CategoryResponse suspend fun createCategory(@Body categoryData: CategoryRequest): CategoryResponse
@PATCH("/categories/{category_id}") @PATCH("/categories/{category_id}")
suspend fun createCategory(@Path("category_id") categoryId: String, @Body categoryData: PartialCategoryRequest): CategoryResponse suspend fun updateCategory(@Path("category_id") categoryId: String, @Body categoryData: PartialCategoryRequest): CategoryResponse
@DELETE("/categories/{category_id}") @DELETE("/categories/{category_id}")
suspend fun deleteCategory(@Path("category_id") categoryId: String): Unit suspend fun deleteCategory(@Path("category_id") categoryId: String): Unit

View file

@ -46,7 +46,7 @@
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:text="@string/select_color" android:text="@string/select_color"
app:icon="@drawable/ic_circle" app:icon="@drawable/ic_circle"
app:iconTint="@android:color/holo_blue_dark" app:iconTint="@color/category_indicator_color"
app:cornerRadius="8dp" app:cornerRadius="8dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" /> style="@style/Widget.MaterialComponents.Button.OutlinedButton" />

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

@ -20,7 +20,7 @@
android:id="@+id/colorIndicator" android:id="@+id/colorIndicator"
android:layout_width="8dp" android:layout_width="8dp"
android:layout_height="0dp" android:layout_height="0dp"
android:background="@android:color/holo_blue_dark" android:background="@color/category_indicator_color"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />
@ -40,20 +40,39 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/colorIndicator" app:layout_constraintStart_toEndOf="@id/colorIndicator"
app:layout_constraintEnd_toStartOf="@id/btnDeleteCategory" /> app:layout_constraintEnd_toStartOf="@id/buttonContainer" />
<!-- Delete Button --> <!-- Button Container -->
<ImageButton <LinearLayout
android:id="@+id/btnDeleteCategory" android:id="@+id/buttonContainer"
android:layout_width="40dp" android:layout_width="wrap_content"
android:layout_height="40dp" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless" android:orientation="horizontal"
android:src="@drawable/ic_trashbin" android:gravity="center"
android:contentDescription="@string/delete_category"
app:tint="?android:attr/textColorSecondary"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent">
<!-- Edit Button -->
<ImageButton
android:id="@+id/btnEditCategory"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_pencil"
android:contentDescription="@string/edit_category"
app:tint="?android:attr/textColorSecondary" />
<!-- Delete Button -->
<ImageButton
android:id="@+id/btnDeleteCategory"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_trashbin"
android:contentDescription="@string/delete_category"
app:tint="?android:attr/textColorSecondary" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>

View file

@ -17,6 +17,6 @@
app:chipIconTint="?android:attr/textColorSecondary" app:chipIconTint="?android:attr/textColorSecondary"
app:chipBackgroundColor="@android:color/transparent" app:chipBackgroundColor="@android:color/transparent"
app:chipSurfaceColor="@android:color/transparent" app:chipSurfaceColor="@android:color/transparent"
app:chipStrokeColor="@android:color/holo_blue_dark" app:chipStrokeColor="@color/category_indicator_color"
app:closeIconEnabled="false" app:closeIconEnabled="false"
tools:text="Work" /> tools:text="Work" />

View file

@ -1,37 +1,24 @@
<?xml version="1.0" encoding="utf-8"?> <com.google.android.material.chip.Chip
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/removableChip"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:background="@drawable/bg_category_chip"
android:backgroundTint="@android:color/holo_blue_dark"
android:paddingStart="8dp" android:paddingStart="8dp"
android:paddingEnd="4dp" android:paddingEnd="8dp"
android:paddingTop="4dp" android:paddingTop="4dp"
android:paddingBottom="4dp"> android:paddingBottom="4dp"
android:textSize="12sp"
<!-- Text for the category --> android:textColor="?android:attr/textColorPrimary"
<TextView app:chipIcon="@drawable/ic_tag"
android:id="@+id/chipText" app:chipIconTint="?android:attr/textColorSecondary"
android:layout_width="wrap_content" app:chipBackgroundColor="@android:color/transparent"
android:layout_height="wrap_content" app:chipSurfaceColor="@android:color/transparent"
android:textSize="12sp" app:chipStrokeColor="@android:color/holo_blue_dark"
android:textColor="?android:attr/textColorPrimary" app:chipStrokeWidth="1dp"
tools:text="Work" /> app:closeIconEnabled="true"
app:closeIcon="@drawable/ic_close"
<!-- X button to remove the category --> app:closeIconTint="?android:attr/textColorSecondary"
<ImageButton tools:text="Work" />
android:id="@+id/removeButton"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/remove_category"
android:src="@drawable/ic_close"
app:tint="?android:attr/textColorPrimary" />
</LinearLayout>

View file

@ -7,5 +7,6 @@
<color name="splash_dark_background">#121212</color> <!-- Dark gray --> <color name="splash_dark_background">#121212</color> <!-- Dark gray -->
<color name="event_indicator_color">#0035D0</color> <color name="event_indicator_color">#0035D0</color>
<color name="category_indicator_color">#0035D0</color>
<color name="unreadIndicator">#bb6633</color> <color name="unreadIndicator">#bb6633</color>
</resources> </resources>

View file

@ -44,6 +44,7 @@
<string name="remove_category">Remove the category</string> <string name="remove_category">Remove the category</string>
<string name="add_category">Add category</string> <string name="add_category">Add category</string>
<string name="update_event">Update Event</string> <string name="update_event">Update Event</string>
<string name="update_category">Update Category</string>
<string name="leave_invited_event">Leave invited event</string> <string name="leave_invited_event">Leave invited event</string>
<string name="open_app_settings">Open Settings</string> <string name="open_app_settings">Open Settings</string>
<string name="open_notifications">Open Notifications</string> <string name="open_notifications">Open Notifications</string>
@ -87,4 +88,16 @@
<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>
<string name="edit_category">Edit category</string>
<string name="please_enter_a_category_name">Please enter a category name</string>
<string name="failed_to_save_category">Failed to save category: %1$s</string>
</resources> </resources>