Compare commits
No commits in common. "851b3c1f387672d818817fd25a181293d5e5131d" and "fa2bb2c78abc36c343dfde2f253f243803d5a565" have entirely different histories.
851b3c1f38
...
fa2bb2c78a
|
@ -5,27 +5,35 @@ import android.os.Bundle
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
|
||||||
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.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
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.CalendarDayItemAdapter
|
import com.p_vacho.neat_calendar.adapters.CalendarDayItemAdapter
|
||||||
|
import com.p_vacho.neat_calendar.api.RetrofitClient
|
||||||
|
import com.p_vacho.neat_calendar.api.models.EventResponse
|
||||||
import com.p_vacho.neat_calendar.models.CalendarDay
|
import com.p_vacho.neat_calendar.models.CalendarDay
|
||||||
import com.p_vacho.neat_calendar.viewmodels.CalendarViewModel
|
import kotlinx.coroutines.launch
|
||||||
import java.time.YearMonth
|
import java.time.YearMonth
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
|
||||||
class CalendarActivity : AppCompatActivity() {
|
class CalendarActivity : AppCompatActivity() {
|
||||||
private val viewModel: CalendarViewModel by viewModels()
|
|
||||||
private lateinit var adapter: CalendarDayItemAdapter
|
|
||||||
|
|
||||||
private lateinit var tvMonthYear: TextView
|
private lateinit var tvMonthYear: TextView
|
||||||
private lateinit var rvCalendar: RecyclerView
|
private lateinit var rvCalendar: RecyclerView
|
||||||
private lateinit var btnPreviousMonth: ImageButton
|
private lateinit var btnPreviousMonth: ImageButton
|
||||||
private lateinit var btnNextMonth: ImageButton
|
private lateinit var btnNextMonth: ImageButton
|
||||||
|
|
||||||
|
private val tokenManager by lazy { (application as MyApplication).tokenManager }
|
||||||
|
|
||||||
|
private var currentYearMonth: YearMonth = YearMonth.now()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
@ -36,31 +44,125 @@ class CalendarActivity : AppCompatActivity() {
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeViews()
|
|
||||||
|
|
||||||
// Observers
|
|
||||||
viewModel.monthYearText.observe(this) { text -> tvMonthYear.text = text }
|
|
||||||
viewModel.calendarDays.observe(this) {days -> adapter.updateDays(days) }
|
|
||||||
|
|
||||||
// Listeners
|
|
||||||
btnPreviousMonth.setOnClickListener { viewModel.prevMonth() }
|
|
||||||
btnNextMonth.setOnClickListener { viewModel.nextMonth() }
|
|
||||||
|
|
||||||
// Setup for current month
|
|
||||||
viewModel.setCalendarMonth(YearMonth.now())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeViews() {
|
|
||||||
tvMonthYear = findViewById(R.id.tvMonthYear)
|
tvMonthYear = findViewById(R.id.tvMonthYear)
|
||||||
rvCalendar = findViewById(R.id.rvCalendar)
|
rvCalendar = findViewById(R.id.rvCalendar)
|
||||||
btnPreviousMonth = findViewById(R.id.btnPreviousMonth)
|
btnPreviousMonth = findViewById(R.id.btnPreviousMonth)
|
||||||
btnNextMonth = findViewById(R.id.btnNextMonth)
|
btnNextMonth = findViewById(R.id.btnNextMonth)
|
||||||
|
|
||||||
adapter = CalendarDayItemAdapter(listOf(), ::navigateToDayActivity)
|
setCalendarForMonth(currentYearMonth)
|
||||||
rvCalendar.layoutManager = GridLayoutManager(this@CalendarActivity, 7) // 7 columns for days of the week
|
|
||||||
rvCalendar.adapter = adapter
|
btnPreviousMonth.setOnClickListener {
|
||||||
|
currentYearMonth = currentYearMonth.minusMonths(1)
|
||||||
|
setCalendarForMonth(currentYearMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
btnNextMonth.setOnClickListener {
|
||||||
|
currentYearMonth = currentYearMonth.plusMonths(1)
|
||||||
|
setCalendarForMonth(currentYearMonth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtain all of the events that occur for each day in the given month from the API.
|
||||||
|
*
|
||||||
|
* The returned structure is a hashmap with int keys, being the day numbers in the month.
|
||||||
|
* If an event spans multiple days, it will be added for each of those days.
|
||||||
|
*
|
||||||
|
* Note that this also includes the events this user was invited into.
|
||||||
|
*/
|
||||||
|
private suspend fun fetchEventsForMonth(yearMonth: YearMonth): Map<Int, List<EventResponse>> {
|
||||||
|
// Use the system's default time zone
|
||||||
|
val zoneId = ZoneId.systemDefault()
|
||||||
|
|
||||||
|
// Get the start and end of the month in the local time zone
|
||||||
|
val startFrom = yearMonth.atDay(1).atStartOfDay(zoneId).toOffsetDateTime()
|
||||||
|
val endTo = yearMonth.atEndOfMonth().atTime(23, 59, 59).atZone(zoneId).toOffsetDateTime()
|
||||||
|
|
||||||
|
val userId = tokenManager.userId
|
||||||
|
|
||||||
|
if (userId.isNullOrEmpty()) {
|
||||||
|
navigateToMainActivity()
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BUG: Events that last longer than a month will not show
|
||||||
|
// up in the following month due to the startFrom filter here.
|
||||||
|
val userEvents = RetrofitClient.eventsService.userEvents(
|
||||||
|
userId = userId,
|
||||||
|
startFrom = startFrom,
|
||||||
|
startTo = endTo
|
||||||
|
)
|
||||||
|
val invitedEvents = RetrofitClient.eventsService.userEventsInvited(
|
||||||
|
userId = userId,
|
||||||
|
startFrom = startFrom,
|
||||||
|
startTo = endTo,
|
||||||
|
inviteStatus = "accepted"
|
||||||
|
)
|
||||||
|
|
||||||
|
val allEvents = userEvents + invitedEvents
|
||||||
|
|
||||||
|
// Create a mapping of days to events
|
||||||
|
val eventsByDay = mutableMapOf<Int, MutableList<EventResponse>>()
|
||||||
|
|
||||||
|
for (event in allEvents) {
|
||||||
|
val startDay = maxOf(event.start_time.dayOfMonth, 1)
|
||||||
|
val endDay = minOf(event.end_time.dayOfMonth, yearMonth.lengthOfMonth())
|
||||||
|
|
||||||
|
for (day in startDay..endDay) {
|
||||||
|
eventsByDay.computeIfAbsent(day) { mutableListOf() }.add(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventsByDay
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCalendarForMonth(yearMonth: YearMonth) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val firstDayOfMonth = yearMonth.atDay(1)
|
||||||
|
val daysInMonth = yearMonth.lengthOfMonth()
|
||||||
|
|
||||||
|
val eventsByDay = fetchEventsForMonth(yearMonth)
|
||||||
|
|
||||||
|
// Get localized month name and year
|
||||||
|
val monthYearText = "${
|
||||||
|
yearMonth.month.getDisplayName(
|
||||||
|
TextStyle.FULL,
|
||||||
|
Locale.getDefault()
|
||||||
|
)
|
||||||
|
} ${yearMonth.year}"
|
||||||
|
tvMonthYear.text = monthYearText
|
||||||
|
|
||||||
|
// Calculate the amount of placeholder slots until the first day in the month
|
||||||
|
// (Our calendar starts from Monday, but not all months start on monday, so we need fillers)
|
||||||
|
val startOffset = firstDayOfMonth.dayOfWeek.ordinal // Gives 0 (Monday) to 6 (Sunday)
|
||||||
|
|
||||||
|
// Generate calendar days
|
||||||
|
val days = mutableListOf<CalendarDay?>()
|
||||||
|
|
||||||
|
// Add padding (placeholder items) for days of the previous month
|
||||||
|
for (i in 1..startOffset) {
|
||||||
|
days.add(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the actual days of the current month
|
||||||
|
for (i in 1..daysInMonth) {
|
||||||
|
val fullDate = yearMonth.atDay(i)
|
||||||
|
val eventsForDay = eventsByDay[i] ?: emptyList()
|
||||||
|
days.add(CalendarDay(date = fullDate, events = eventsForDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up RecyclerView
|
||||||
|
rvCalendar.layoutManager = GridLayoutManager(this@CalendarActivity, 7) // 7 columns for days of the week
|
||||||
|
rvCalendar.adapter = CalendarDayItemAdapter(days) { day -> navigateToDayActivity(day) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToMainActivity() {
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
startActivity(intent)
|
||||||
|
finish() // Close the login screen
|
||||||
|
}
|
||||||
|
|
||||||
private fun navigateToDayActivity(day: CalendarDay) {
|
private fun navigateToDayActivity(day: CalendarDay) {
|
||||||
var intent = Intent(this, DayViewActivity::class.java)
|
var intent = Intent(this, DayViewActivity::class.java)
|
||||||
|
@ -75,6 +177,6 @@ class CalendarActivity : AppCompatActivity() {
|
||||||
// Events could've been modified or even deleted, so make sure we have a fresh copy
|
// Events could've been modified or even deleted, so make sure we have a fresh copy
|
||||||
// it would probably be better to pass these around through extras, this extra lookup
|
// it would probably be better to pass these around through extras, this extra lookup
|
||||||
// isn't a huge deal, it's a potential future optimization though
|
// isn't a huge deal, it's a potential future optimization though
|
||||||
viewModel.refetch()
|
setCalendarForMonth(currentYearMonth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,28 +7,30 @@ import android.widget.ImageButton
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
|
||||||
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.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
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.CategoryAdapter
|
import com.p_vacho.neat_calendar.adapters.CategoryAdapter
|
||||||
|
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.viewmodels.CategoriesViewModel
|
|
||||||
import com.p_vacho.neat_calendar.viewmodels.CategoryEvent
|
|
||||||
import com.p_vacho.neat_calendar.viewmodels.CategoryMode
|
import com.p_vacho.neat_calendar.viewmodels.CategoryMode
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class CategoriesActivity : AppCompatActivity() {
|
class CategoriesActivity : AppCompatActivity() {
|
||||||
private val viewModel: CategoriesViewModel by viewModels()
|
|
||||||
private lateinit var adapter: CategoryAdapter
|
|
||||||
|
|
||||||
private lateinit var rvCategories: RecyclerView
|
private lateinit var rvCategories: RecyclerView
|
||||||
private lateinit var btnBack: ImageButton
|
private lateinit var btnBack: ImageButton
|
||||||
private lateinit var btnAddCategory: ImageButton
|
private lateinit var btnAddCategory: ImageButton
|
||||||
private lateinit var tvEmptyState: TextView
|
private lateinit var tvEmptyState: TextView
|
||||||
|
|
||||||
|
private lateinit var categories: MutableList<CategoryResponse>
|
||||||
|
|
||||||
private val createActivityLauncher = registerForActivityResult(
|
private val createActivityLauncher = registerForActivityResult(
|
||||||
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()
|
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()
|
||||||
) { result ->
|
) { result ->
|
||||||
|
@ -40,11 +42,8 @@ class CategoriesActivity : AppCompatActivity() {
|
||||||
val editedCategory: CategoryResponse? = result.data?.getParcelableExtra("editedCategory")
|
val editedCategory: CategoryResponse? = result.data?.getParcelableExtra("editedCategory")
|
||||||
|
|
||||||
if (newCategory != null && editedCategory != null) throw IllegalStateException("Got both edit & new response")
|
if (newCategory != null && editedCategory != null) throw IllegalStateException("Got both edit & new response")
|
||||||
|
if (newCategory != null) categoryCreateReply(newCategory, CategoryMode.CREATE)
|
||||||
when {
|
if (editedCategory != null) categoryCreateReply(editedCategory, CategoryMode.EDIT)
|
||||||
newCategory != null -> viewModel.handleCategoryAddOrEdit(newCategory, CategoryMode.CREATE)
|
|
||||||
editedCategory != null -> viewModel.handleCategoryAddOrEdit(editedCategory, CategoryMode.EDIT)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,47 +58,55 @@ class CategoriesActivity : AppCompatActivity() {
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeViews()
|
|
||||||
|
|
||||||
// Observers
|
|
||||||
viewModel.categoryEvents.observe(this) { event ->
|
|
||||||
when (event) {
|
|
||||||
is CategoryEvent.Init -> adapter.setCategories(event.categories)
|
|
||||||
is CategoryEvent.Add -> adapter.addCategory(event.category)
|
|
||||||
is CategoryEvent.Edit -> adapter.editCategory(event.category)
|
|
||||||
is CategoryEvent.Remove -> {
|
|
||||||
adapter.removeCategory(event.category)
|
|
||||||
// TODO: Localize
|
|
||||||
Toast.makeText(this, "Category deleted", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.isEmptyStateVisible.observe(this) { isVisible ->
|
|
||||||
tvEmptyState.visibility = if (isVisible) View.VISIBLE else View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listeners
|
|
||||||
btnBack.setOnClickListener { finish() }
|
|
||||||
btnAddCategory.setOnClickListener { handleCreateCategory() }
|
|
||||||
|
|
||||||
// Load initial categories
|
|
||||||
viewModel.fetchCategories()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeViews() {
|
|
||||||
rvCategories = findViewById(R.id.rvCategories)
|
rvCategories = findViewById(R.id.rvCategories)
|
||||||
btnBack = findViewById(R.id.btnBack)
|
btnBack = findViewById(R.id.btnBack)
|
||||||
btnAddCategory = findViewById(R.id.btnAddCategory)
|
btnAddCategory = findViewById(R.id.btnAddCategory)
|
||||||
tvEmptyState = findViewById(R.id.tvEmptyState)
|
tvEmptyState = findViewById(R.id.tvEmptyState)
|
||||||
|
|
||||||
adapter = CategoryAdapter(
|
btnBack.setOnClickListener { finish() }
|
||||||
mutableListOf(),
|
btnAddCategory.setOnClickListener { handleCreateCategory() }
|
||||||
{ category, _pos -> viewModel.deleteCategory(category.id) },
|
|
||||||
::handleEditCategory
|
|
||||||
)
|
|
||||||
rvCategories.layoutManager = LinearLayoutManager(this)
|
rvCategories.layoutManager = LinearLayoutManager(this)
|
||||||
rvCategories.adapter = adapter
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
categories = fetchCategories().toMutableList()
|
||||||
|
|
||||||
|
val adapter = CategoryAdapter(categories, ::handleDeleteCategory, ::handleEditCategory)
|
||||||
|
|
||||||
|
rvCategories.adapter = adapter
|
||||||
|
updateEmptyState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all categories for the current user from the backend.
|
||||||
|
*/
|
||||||
|
private suspend fun fetchCategories(): List<CategoryResponse> {
|
||||||
|
val userId = (application as MyApplication).tokenManager.userId
|
||||||
|
?: run {
|
||||||
|
finish()
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
RetrofitClient.categoryService.userCategories(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A callback function triggered by the Category Adapter, when the user
|
||||||
|
* clicks on the delete button.
|
||||||
|
*/
|
||||||
|
private fun handleDeleteCategory(category: CategoryResponse, position: Int) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
RetrofitClient.categoryService.deleteCategory(category.id)
|
||||||
|
}
|
||||||
|
// Remove category from the adapter and update the UI
|
||||||
|
(rvCategories.adapter as CategoryAdapter).removeCategoryAt(position)
|
||||||
|
Toast.makeText(this@CategoriesActivity, "Category deleted", Toast.LENGTH_SHORT).show()
|
||||||
|
updateEmptyState()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -126,4 +133,30 @@ class CategoriesActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
createActivityLauncher.launch(intent)
|
createActivityLauncher.launch(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used as a callback, triggered when the CreateCategory Activity returns a result.
|
||||||
|
*
|
||||||
|
* The returned value (the new / edited category data) is passed over as a parameter.
|
||||||
|
*/
|
||||||
|
private fun categoryCreateReply(category: CategoryResponse, mode: CategoryMode) {
|
||||||
|
val adapter = (rvCategories.adapter as CategoryAdapter)
|
||||||
|
when (mode) {
|
||||||
|
CategoryMode.CREATE -> {
|
||||||
|
adapter.addCategory(category)
|
||||||
|
updateEmptyState()
|
||||||
|
}
|
||||||
|
CategoryMode.EDIT -> { adapter.editCategory(category) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a helper function to toggle the visibility of the empty state message
|
||||||
|
* when no categories are available.
|
||||||
|
*
|
||||||
|
* This should be called whenever a category was removed or added.
|
||||||
|
*/
|
||||||
|
private fun updateEmptyState() {
|
||||||
|
tvEmptyState.visibility = if (categories.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.p_vacho.neat_calendar.R
|
import com.p_vacho.neat_calendar.R
|
||||||
import com.p_vacho.neat_calendar.models.CalendarDay
|
import com.p_vacho.neat_calendar.models.CalendarDay
|
||||||
|
|
||||||
class CalendarDayItemAdapter(private var days: List<CalendarDay?>, private val onDayClicked: (CalendarDay) -> Unit) :
|
class CalendarDayItemAdapter(private val days: List<CalendarDay?>, private val onDayClicked: (CalendarDay) -> Unit) :
|
||||||
RecyclerView.Adapter<CalendarDayItemAdapter.DayViewHolder>() {
|
RecyclerView.Adapter<CalendarDayItemAdapter.DayViewHolder>() {
|
||||||
|
|
||||||
class DayViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
class DayViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
@ -49,17 +49,4 @@ class CalendarDayItemAdapter(private var days: List<CalendarDay?>, private val o
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int = days.size
|
override fun getItemCount(): Int = days.size
|
||||||
|
|
||||||
/**
|
|
||||||
* This function allows updating the day list.
|
|
||||||
*
|
|
||||||
* This will entirely overwrite the original data.
|
|
||||||
*/
|
|
||||||
fun updateDays(newDays: List<CalendarDay?>) {
|
|
||||||
days = newDays
|
|
||||||
|
|
||||||
// We're resetting to an entirely new list, this is needed
|
|
||||||
@Suppress("NotifyDataSetChanged")
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
package com.p_vacho.neat_calendar.adapters
|
package com.p_vacho.neat_calendar.adapters
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
@ -44,10 +43,11 @@ class CategoryAdapter(
|
||||||
override fun getItemCount(): Int = categories.size
|
override fun getItemCount(): Int = categories.size
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove an existing category from the list and notify the adapter.
|
* Remove a category from the list and notify the adapter.
|
||||||
|
*
|
||||||
|
* Call this after the onDeleteClick callback deletes the category from the backend API.
|
||||||
*/
|
*/
|
||||||
fun removeCategory(category: CategoryResponse) {
|
fun removeCategoryAt(position: Int) {
|
||||||
val position = categories.indexOfFirst { it.id == category.id }
|
|
||||||
categories.removeAt(position)
|
categories.removeAt(position)
|
||||||
notifyItemRemoved(position)
|
notifyItemRemoved(position)
|
||||||
|
|
||||||
|
@ -68,24 +68,12 @@ class CategoryAdapter(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edit an existing category, updating it in the UI.
|
* 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) {
|
fun editCategory(category: CategoryResponse) {
|
||||||
val position = categories.indexOfFirst { it.id == category.id }
|
val position = categories.indexOfFirst { it.id == category.id }
|
||||||
categories[position] = category
|
categories[position] = category
|
||||||
notifyItemChanged(position)
|
notifyItemChanged(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the entire list of categories to a new one.
|
|
||||||
*
|
|
||||||
* This is mainly useful for delayed initialization (after data is fetched).
|
|
||||||
*/
|
|
||||||
fun setCategories(newCategories: List<CategoryResponse>) {
|
|
||||||
categories.clear()
|
|
||||||
categories.addAll(newCategories)
|
|
||||||
|
|
||||||
// We can't be more efficient here, we're changing the whole data set
|
|
||||||
@Suppress("NotifyDataSetChanged")
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,141 +0,0 @@
|
||||||
package com.p_vacho.neat_calendar.viewmodels
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.p_vacho.neat_calendar.MyApplication
|
|
||||||
import com.p_vacho.neat_calendar.api.RetrofitClient
|
|
||||||
import com.p_vacho.neat_calendar.api.models.EventResponse
|
|
||||||
import com.p_vacho.neat_calendar.models.CalendarDay
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.time.YearMonth
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.TextStyle
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class CalendarViewModel(application: Application) : AndroidViewModel(application) {
|
|
||||||
private var currentYearMonth: YearMonth? = null
|
|
||||||
|
|
||||||
private val _calendarDays = MutableLiveData<List<CalendarDay?>>()
|
|
||||||
val calendarDays: LiveData<List<CalendarDay?>> = _calendarDays
|
|
||||||
|
|
||||||
private val _monthYearText = MutableLiveData<String>()
|
|
||||||
val monthYearText: LiveData<String> = _monthYearText
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move to the next month.
|
|
||||||
*
|
|
||||||
* Can only be used after the initial setCalendarMonth.
|
|
||||||
*/
|
|
||||||
fun prevMonth() {
|
|
||||||
setCalendarMonth(currentYearMonth!!.minusMonths(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move to the next month.
|
|
||||||
*
|
|
||||||
* Can only be used after the initial setCalendarMonth.
|
|
||||||
*/
|
|
||||||
fun nextMonth() {
|
|
||||||
setCalendarMonth(currentYearMonth!!.plusMonths(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refetch the data for the currently selected month.
|
|
||||||
*
|
|
||||||
* Can only be used after the initial setCalendarMonth.
|
|
||||||
*/
|
|
||||||
fun refetch() {
|
|
||||||
setCalendarMonth(currentYearMonth!!)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up the view model for a specific month.
|
|
||||||
*/
|
|
||||||
fun setCalendarMonth(yearMonth: YearMonth) {
|
|
||||||
currentYearMonth = yearMonth
|
|
||||||
|
|
||||||
val firstDayOfMonth = yearMonth.atDay(1)
|
|
||||||
val daysInMonth = yearMonth.lengthOfMonth()
|
|
||||||
|
|
||||||
// Get localized month name and year name
|
|
||||||
val monthYearText = "${
|
|
||||||
yearMonth.month.getDisplayName(
|
|
||||||
TextStyle.FULL,
|
|
||||||
Locale.getDefault()
|
|
||||||
)
|
|
||||||
} ${yearMonth.year}"
|
|
||||||
_monthYearText.value = monthYearText
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
val eventsByDay = withContext(Dispatchers.IO) { fetchEventsForMonthInternal(yearMonth) }
|
|
||||||
|
|
||||||
val startOffset = firstDayOfMonth.dayOfWeek.ordinal
|
|
||||||
val days = mutableListOf<CalendarDay?>()
|
|
||||||
|
|
||||||
for (i in 1..startOffset) {
|
|
||||||
days.add(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in 1..daysInMonth) {
|
|
||||||
val fullDate = yearMonth.atDay(i)
|
|
||||||
val eventsForDay = eventsByDay[i] ?: emptyList()
|
|
||||||
days.add(CalendarDay(date = fullDate, events = eventsForDay))
|
|
||||||
}
|
|
||||||
|
|
||||||
_calendarDays.value = days
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtain all of the events that occur for each day in the given month from the API.
|
|
||||||
*
|
|
||||||
* The returned structure is a hashmap with int keys, being the day numbers in the month.
|
|
||||||
* If an event spans multiple days, it will be added for each of those days.
|
|
||||||
*
|
|
||||||
* Note that this also includes the events this user was invited into.
|
|
||||||
*/
|
|
||||||
private suspend fun fetchEventsForMonthInternal(yearMonth: YearMonth): Map<Int, List<EventResponse>> {
|
|
||||||
// Use the system's default time zone
|
|
||||||
val zoneId = ZoneId.systemDefault()
|
|
||||||
|
|
||||||
// Get the start and end of the month in the local time zone
|
|
||||||
val startFrom = yearMonth.atDay(1).atStartOfDay(zoneId).toOffsetDateTime()
|
|
||||||
val endTo = yearMonth.atEndOfMonth().atTime(23, 59, 59).atZone(zoneId).toOffsetDateTime()
|
|
||||||
|
|
||||||
// Obtain current (logged in) user ID
|
|
||||||
val userId = (getApplication<MyApplication>()).tokenManager.userId ?: throw IllegalStateException("User ID is not set")
|
|
||||||
|
|
||||||
// BUG: Events that last longer than a month will not show
|
|
||||||
// up in the following month due to the startFrom filter here.
|
|
||||||
val userEvents = RetrofitClient.eventsService.userEvents(
|
|
||||||
userId = userId,
|
|
||||||
startFrom = startFrom,
|
|
||||||
startTo = endTo
|
|
||||||
)
|
|
||||||
val invitedEvents = RetrofitClient.eventsService.userEventsInvited(
|
|
||||||
userId = userId,
|
|
||||||
startFrom = startFrom,
|
|
||||||
startTo = endTo,
|
|
||||||
inviteStatus = "accepted"
|
|
||||||
)
|
|
||||||
|
|
||||||
val allEvents = userEvents + invitedEvents
|
|
||||||
|
|
||||||
// Create a mapping of days to events
|
|
||||||
val eventsByDay = mutableMapOf<Int, MutableList<EventResponse>>()
|
|
||||||
for (event in allEvents) {
|
|
||||||
val startDay = maxOf(event.start_time.dayOfMonth, 1)
|
|
||||||
val endDay = minOf(event.end_time.dayOfMonth, yearMonth.lengthOfMonth())
|
|
||||||
for (day in startDay..endDay) {
|
|
||||||
eventsByDay.computeIfAbsent(day) { mutableListOf() }.add(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return eventsByDay
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
package com.p_vacho.neat_calendar.viewmodels
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.p_vacho.neat_calendar.MyApplication
|
|
||||||
import com.p_vacho.neat_calendar.api.RetrofitClient
|
|
||||||
import com.p_vacho.neat_calendar.api.models.CategoryResponse
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
sealed class CategoryEvent {
|
|
||||||
data class Init(val categories: List<CategoryResponse>) : CategoryEvent()
|
|
||||||
data class Add(val category: CategoryResponse) : CategoryEvent()
|
|
||||||
data class Edit(val position: Int, val category: CategoryResponse) : CategoryEvent()
|
|
||||||
data class Remove(val position: Int, val category: CategoryResponse) : CategoryEvent()
|
|
||||||
}
|
|
||||||
|
|
||||||
class CategoriesViewModel(application: Application) : AndroidViewModel(application) {
|
|
||||||
private val categories = mutableListOf<CategoryResponse>()
|
|
||||||
|
|
||||||
private val _isEmptyStateVisible = MutableLiveData<Boolean>()
|
|
||||||
val isEmptyStateVisible: LiveData<Boolean> = _isEmptyStateVisible
|
|
||||||
|
|
||||||
private val _categoryEvents = MutableLiveData<CategoryEvent>()
|
|
||||||
val categoryEvents: LiveData<CategoryEvent> = _categoryEvents
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Initialize empty state visibility to true as the default
|
|
||||||
updateEmptyStateVisibility()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches all categories for the current user from the backend.
|
|
||||||
*
|
|
||||||
* This function should always be called after initialization.
|
|
||||||
*/
|
|
||||||
fun fetchCategories() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val userId = (getApplication<MyApplication>()).tokenManager.userId ?: return@launch
|
|
||||||
val fetchedCategories = withContext(Dispatchers.IO) {
|
|
||||||
RetrofitClient.categoryService.userCategories(userId)
|
|
||||||
}
|
|
||||||
categories.clear()
|
|
||||||
categories.addAll(fetchedCategories)
|
|
||||||
_categoryEvents.value = CategoryEvent.Init(fetchedCategories)
|
|
||||||
updateEmptyStateVisibility()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a specific category.
|
|
||||||
*
|
|
||||||
* This will perform an API call, removing the category from the backend.
|
|
||||||
*/
|
|
||||||
fun deleteCategory(categoryId: String) {
|
|
||||||
val index = categories.indexOfFirst { it.id == categoryId }
|
|
||||||
if (index == -1) throw IllegalStateException("Attempted to delete an unknown category; id: $categoryId")
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
RetrofitClient.categoryService.deleteCategory(categoryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
val removedCategory = categories.removeAt(index)
|
|
||||||
_categoryEvents.value = CategoryEvent.Remove(index, removedCategory)
|
|
||||||
updateEmptyStateVisibility()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the categories list with a new/updated category data.
|
|
||||||
*/
|
|
||||||
fun handleCategoryAddOrEdit(category: CategoryResponse, mode: CategoryMode) {
|
|
||||||
when (mode) {
|
|
||||||
CategoryMode.CREATE -> {
|
|
||||||
categories.add(category)
|
|
||||||
_categoryEvents.value = CategoryEvent.Add(category)
|
|
||||||
updateEmptyStateVisibility()
|
|
||||||
}
|
|
||||||
|
|
||||||
CategoryMode.EDIT -> {
|
|
||||||
val index = categories.indexOfFirst { it.id == category.id }
|
|
||||||
if (index != -1) {
|
|
||||||
categories[index] = category
|
|
||||||
_categoryEvents.value = CategoryEvent.Edit(index, category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the empty state visibility based on the categories list.
|
|
||||||
*/
|
|
||||||
private fun updateEmptyStateVisibility() {
|
|
||||||
_isEmptyStateVisible.value = categories.isEmpty()
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue