From 851b3c1f387672d818817fd25a181293d5e5131d Mon Sep 17 00:00:00 2001 From: Peter Vacho Date: Wed, 22 Jan 2025 21:59:54 +0100 Subject: [PATCH] chore(mvvm): CalendarActivity ViewModel --- .../activities/CalendarActivity.kt | 150 +++--------------- .../adapters/CalendarDayItemAdapter.kt | 15 +- .../viewmodels/CalendarViewModel.kt | 141 ++++++++++++++++ 3 files changed, 179 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CalendarViewModel.kt diff --git a/app/src/main/java/com/p_vacho/neat_calendar/activities/CalendarActivity.kt b/app/src/main/java/com/p_vacho/neat_calendar/activities/CalendarActivity.kt index 847b843..dd4d1e5 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/activities/CalendarActivity.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/activities/CalendarActivity.kt @@ -5,35 +5,27 @@ import android.os.Bundle import android.widget.ImageButton import android.widget.TextView import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.recyclerview.widget.GridLayoutManager 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.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 kotlinx.coroutines.launch +import com.p_vacho.neat_calendar.viewmodels.CalendarViewModel import java.time.YearMonth -import java.time.ZoneId -import java.time.format.TextStyle -import java.util.Locale - class CalendarActivity : AppCompatActivity() { + private val viewModel: CalendarViewModel by viewModels() + private lateinit var adapter: CalendarDayItemAdapter + private lateinit var tvMonthYear: TextView private lateinit var rvCalendar: RecyclerView private lateinit var btnPreviousMonth: 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?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -44,125 +36,31 @@ class CalendarActivity : AppCompatActivity() { 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) rvCalendar = findViewById(R.id.rvCalendar) btnPreviousMonth = findViewById(R.id.btnPreviousMonth) btnNextMonth = findViewById(R.id.btnNextMonth) - setCalendarForMonth(currentYearMonth) - - btnPreviousMonth.setOnClickListener { - currentYearMonth = currentYearMonth.minusMonths(1) - setCalendarForMonth(currentYearMonth) - } - - btnNextMonth.setOnClickListener { - currentYearMonth = currentYearMonth.plusMonths(1) - setCalendarForMonth(currentYearMonth) - } + adapter = CalendarDayItemAdapter(listOf(), ::navigateToDayActivity) + rvCalendar.layoutManager = GridLayoutManager(this@CalendarActivity, 7) // 7 columns for days of the week + rvCalendar.adapter = adapter } - /** - * 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> { - // 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>() - - 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() - - // 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) { var intent = Intent(this, DayViewActivity::class.java) @@ -177,6 +75,6 @@ class CalendarActivity : AppCompatActivity() { // 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 // isn't a huge deal, it's a potential future optimization though - setCalendarForMonth(currentYearMonth) + viewModel.refetch() } } diff --git a/app/src/main/java/com/p_vacho/neat_calendar/adapters/CalendarDayItemAdapter.kt b/app/src/main/java/com/p_vacho/neat_calendar/adapters/CalendarDayItemAdapter.kt index 726d3cb..c999ded 100644 --- a/app/src/main/java/com/p_vacho/neat_calendar/adapters/CalendarDayItemAdapter.kt +++ b/app/src/main/java/com/p_vacho/neat_calendar/adapters/CalendarDayItemAdapter.kt @@ -9,7 +9,7 @@ import androidx.recyclerview.widget.RecyclerView import com.p_vacho.neat_calendar.R import com.p_vacho.neat_calendar.models.CalendarDay -class CalendarDayItemAdapter(private val days: List, private val onDayClicked: (CalendarDay) -> Unit) : +class CalendarDayItemAdapter(private var days: List, private val onDayClicked: (CalendarDay) -> Unit) : RecyclerView.Adapter() { class DayViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { @@ -49,4 +49,17 @@ class CalendarDayItemAdapter(private val days: List, private val o } override fun getItemCount(): Int = days.size + + /** + * This function allows updating the day list. + * + * This will entirely overwrite the original data. + */ + fun updateDays(newDays: List) { + days = newDays + + // We're resetting to an entirely new list, this is needed + @Suppress("NotifyDataSetChanged") + notifyDataSetChanged() + } } \ No newline at end of file diff --git a/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CalendarViewModel.kt b/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CalendarViewModel.kt new file mode 100644 index 0000000..c625721 --- /dev/null +++ b/app/src/main/java/com/p_vacho/neat_calendar/viewmodels/CalendarViewModel.kt @@ -0,0 +1,141 @@ +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>() + val calendarDays: LiveData> = _calendarDays + + private val _monthYearText = MutableLiveData() + val monthYearText: LiveData = _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() + + 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> { + // 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()).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>() + 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 + } +} \ No newline at end of file