-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Implement Calendar Screen with event handling, tests, and updated dependencies #25
Changes from all commits
d2a745b
d35d87c
e9bb9d9
ebc2112
1962587
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
package com.github.lookupgroup27.lookup.screen | ||
|
||
import androidx.compose.ui.test.assertIsDisplayed | ||
import androidx.compose.ui.test.junit4.createComposeRule | ||
import androidx.compose.ui.test.onNodeWithText | ||
import androidx.compose.ui.test.performClick | ||
import com.github.lookupgroup27.lookup.model.calendar.CalendarViewModel | ||
import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions | ||
import com.github.lookupgroup27.lookup.ui.overview.CalendarScreen | ||
import io.mockk.mockk | ||
import java.text.SimpleDateFormat | ||
import java.util.* | ||
import org.junit.Before | ||
import org.junit.Rule | ||
import org.junit.Test | ||
|
||
class CalendarScreenTest { | ||
|
||
private lateinit var calendarViewModel: CalendarViewModel | ||
private lateinit var navigationActions: NavigationActions | ||
private val dateFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault()) | ||
|
||
@get:Rule val composeTestRule = createComposeRule() | ||
|
||
@Before | ||
fun setUp() { | ||
|
||
calendarViewModel = mockk(relaxed = true) | ||
navigationActions = mockk(relaxed = true) | ||
|
||
composeTestRule.setContent { | ||
CalendarScreen(calendarViewModel = calendarViewModel, navigationActions = navigationActions) | ||
} | ||
} | ||
|
||
@Test | ||
fun testCalendarHeaderDisplaysCurrentMonth() { | ||
val currentDate = Calendar.getInstance().time | ||
val formattedMonth = dateFormat.format(currentDate) | ||
|
||
composeTestRule.onNodeWithText(formattedMonth).assertIsDisplayed() | ||
} | ||
|
||
@Test | ||
fun testPreviousMonthButton() { | ||
val currentDate = Calendar.getInstance().time | ||
val previousMonthDate = | ||
Calendar.getInstance() | ||
.apply { | ||
time = currentDate | ||
add(Calendar.MONTH, -1) | ||
} | ||
.time | ||
val formattedPreviousMonth = dateFormat.format(previousMonthDate) | ||
|
||
composeTestRule.onNodeWithText("<").performClick() | ||
composeTestRule.onNodeWithText(formattedPreviousMonth).assertIsDisplayed() | ||
} | ||
|
||
@Test | ||
fun testNextMonthButton() { | ||
val currentDate = Calendar.getInstance().time | ||
val nextMonthDate = | ||
Calendar.getInstance() | ||
.apply { | ||
time = currentDate | ||
add(Calendar.MONTH, 1) | ||
} | ||
.time | ||
val formattedNextMonth = dateFormat.format(nextMonthDate) | ||
|
||
composeTestRule.onNodeWithText(">").performClick() | ||
composeTestRule.onNodeWithText(formattedNextMonth).assertIsDisplayed() | ||
} | ||
|
||
@Test | ||
fun testLookUpEventButtonExists() { | ||
composeTestRule.onNodeWithText("Look Up Event").assertIsDisplayed() | ||
} | ||
|
||
// Add more tests when figma is updated with all the components and testTags | ||
|
||
@Test | ||
fun testSelectingDayUpdatesSelectedDate() { | ||
val calendar = Calendar.getInstance() | ||
val dayToSelect = calendar.get(Calendar.DAY_OF_MONTH) + 1 | ||
|
||
composeTestRule.onNodeWithText(dayToSelect.toString()).performClick() | ||
|
||
composeTestRule.onNodeWithText(dayToSelect.toString()).assertIsDisplayed() | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
package com.github.lookupgroup27.lookup.model.calendar | ||
|
||
import android.util.Log | ||
import androidx.compose.runtime.mutableStateListOf | ||
import androidx.lifecycle.ViewModel | ||
import androidx.lifecycle.viewModelScope | ||
import java.io.StringReader | ||
import java.util.Calendar as JavaCalendar | ||
import kotlinx.coroutines.Dispatchers | ||
import kotlinx.coroutines.launch | ||
import kotlinx.coroutines.withContext | ||
import net.fortuna.ical4j.data.CalendarBuilder | ||
import net.fortuna.ical4j.model.Calendar | ||
import net.fortuna.ical4j.model.Date | ||
import net.fortuna.ical4j.model.DateTime | ||
import net.fortuna.ical4j.model.Period | ||
import net.fortuna.ical4j.model.Property | ||
import net.fortuna.ical4j.model.component.VEvent | ||
import net.fortuna.ical4j.model.property.DtEnd | ||
import net.fortuna.ical4j.model.property.DtStart | ||
import net.fortuna.ical4j.model.property.RRule | ||
import net.fortuna.ical4j.util.MapTimeZoneCache | ||
import okhttp3.OkHttpClient | ||
import okhttp3.Request | ||
|
||
class CalendarViewModel : ViewModel() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You may want to create a |
||
|
||
private val client = OkHttpClient() | ||
private val _icalEvents = mutableStateListOf<VEvent>() | ||
val icalEvents: List<VEvent> = _icalEvents | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should use private val _icalEvents = MutableStateFlow<List<VEvent>>(listOf())
val icalEvents: StateFlow<VEvent> = query_ Sidenote when using stateflows: always use immutable structures as it only reacts to reference changes, for example: // good
_icalEvents.value = newList
// bad
_icalEvents.value.addAll(newList) |
||
|
||
init { | ||
System.setProperty("net.fortuna.ical4j.timezone.cache.impl", MapTimeZoneCache::class.java.name) | ||
fetchICalData() | ||
} | ||
|
||
fun fetchICalData() { | ||
viewModelScope.launch { | ||
val icalData = | ||
fetchIcalFromUrl( | ||
"https://p127-caldav.icloud.com/published/2/MTE0OTM4OTk2MTExNDkzOIzDRaBjEGa9_1mlmgjzcdlka5HK6EzMiIdOswU-0rZBZMDBibtH_M7CDyMpDQRQJPdGOSM0hTsS2qFNGOObsTc") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid magical values, especially when they are not trivial ( fetchIcalFromUrl(ICAL_URL) |
||
icalData?.let { parseICalData(it) } | ||
} | ||
} | ||
|
||
private suspend fun fetchIcalFromUrl(url: String): String? = | ||
withContext(Dispatchers.IO) { | ||
return@withContext try { | ||
val request = Request.Builder().url(url).build() | ||
val response = client.newCall(request).execute() | ||
response.body?.string() | ||
} catch (e: Exception) { | ||
Log.e("CalendarViewModel", "Error fetching iCal data: ${e.localizedMessage}", e) | ||
null | ||
} | ||
} | ||
|
||
private fun parseICalData(icalData: String) { | ||
try { | ||
val reader = StringReader(icalData) | ||
val calendar: Calendar = CalendarBuilder().build(reader) | ||
val start = DateTime(System.currentTimeMillis()) | ||
val end = DateTime(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000) | ||
val period = Period(start, end) | ||
|
||
val allEvents = mutableListOf<VEvent>() | ||
|
||
for (component in calendar.getComponents<VEvent>(VEvent.VEVENT)) { | ||
val dtStart = component.getProperty<DtStart>(Property.DTSTART) | ||
val dtEnd = component.getProperty<DtEnd>(Property.DTEND) | ||
|
||
dtStart?.let { startDate -> | ||
val endDate = dtEnd?.date ?: startDate.date | ||
val rrule = component.getProperty<RRule>(Property.RRULE) | ||
|
||
if (rrule != null) { | ||
handleRecurringEvents(component, period, startDate.date, endDate, allEvents) | ||
} else if (startDate.date.before(end) && endDate.after(start)) { | ||
handleNonRecurringEvents(component, startDate.date, endDate, allEvents) | ||
} | ||
} | ||
} | ||
|
||
_icalEvents.clear() | ||
_icalEvents.addAll(allEvents) | ||
Log.d("CalendarViewModel", "Total events parsed: ${_icalEvents.size}") | ||
} catch (e: Exception) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This try-catch block is very long and it catches all exceptions subtype of |
||
Log.e("CalendarViewModel", "Error parsing iCal data: ${e.localizedMessage}", e) | ||
} | ||
} | ||
|
||
private fun handleRecurringEvents( | ||
component: VEvent, | ||
period: Period, | ||
eventStartDate: Date, | ||
eventEndDate: Date, | ||
allEvents: MutableList<VEvent> | ||
) { | ||
val recurrenceSet = component.calculateRecurrenceSet(period) | ||
for (recurrence in recurrenceSet) { | ||
val eventInstance = component.copy() as VEvent | ||
eventInstance.getProperty<DtStart>(Property.DTSTART)?.date = recurrence.start | ||
if (eventEndDate.after(recurrence.start)) { | ||
var current = recurrence.start | ||
while (current.before(eventEndDate)) { | ||
val multiDayEventInstance = eventInstance.copy() as VEvent | ||
multiDayEventInstance.getProperty<DtStart>(Property.DTSTART)?.date = | ||
DateTime(current.time) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The methods Here is an example of how /**
* General doc about the function
*/
private fun handleRecurringEvents(
component: VEvent,
period: Period,
eventStartDate: Date,
eventEndDate: Date,
): List<VEvent> {
val events = mutableListOf<VEvent>()
val recurrenceSet = component.calculateRecurrenceSet(period)
for (recurrence in recurrenceSet) {
val eventInstance = component.copy() as VEvent
eventInstance.getProperty<DtStart>(Property.DTSTART)?.date = recurrence.start
if (!eventEndDate.after(recurrence.start)) {
// comment on why no while loop
events.add(eventInstance)
continue
}
var current = recurrence.start
while (current.before(eventEndDate)) {
// comment on why this
val multiDayEventInstance = eventInstance.copy() as VEvent
multiDayEventInstance.getProperty<DtStart>(Property.DTSTART)?.date =
DateTime(current.time)
events.add(multiDayEventInstance)
// comment add a day, or better use an helper function
val calendarInstance =
JavaCalendar.getInstance().apply {
time = current
add(JavaCalendar.DATE, 1)
}
current = DateTime(calendarInstance.time)
}
}
return events
} |
||
allEvents.add(multiDayEventInstance) | ||
|
||
val calendarInstance = | ||
JavaCalendar.getInstance().apply { | ||
time = current | ||
add(JavaCalendar.DATE, 1) | ||
} | ||
current = DateTime(calendarInstance.time) | ||
} | ||
} else { | ||
allEvents.add(eventInstance) | ||
} | ||
} | ||
} | ||
|
||
private fun handleNonRecurringEvents( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same comment as for |
||
component: VEvent, | ||
eventStartDate: Date, | ||
eventEndDate: Date, | ||
allEvents: MutableList<VEvent> | ||
) { | ||
if (eventEndDate.after(eventStartDate)) { | ||
var current = eventStartDate | ||
while (current.before(eventEndDate)) { | ||
val eventInstance = component.copy() as VEvent | ||
eventInstance.getProperty<DtStart>(Property.DTSTART)?.date = DateTime(current.time) | ||
allEvents.add(eventInstance) | ||
|
||
val calendarInstance = | ||
JavaCalendar.getInstance().apply { | ||
time = current | ||
add(JavaCalendar.DATE, 1) | ||
} | ||
current = DateTime(calendarInstance.time) | ||
} | ||
} else { | ||
allEvents.add(component) | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's nothing more permanent than a temporary solution - Oswald Maskens (I must give credits to the guy)
Either create the tests now or don't basically.