diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9e8b7bd6..5892eb4ce 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -126,8 +126,9 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.material) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.navigation.runtime.ktx) - // Jetpack Compose BOM + // Jetpack Compose BOM val composeBom = platform(libs.compose.bom) implementation(composeBom) androidTestImplementation(composeBom) @@ -190,6 +191,12 @@ dependencies { // Networking with OkHttp implementation(libs.okhttp) + // Calendar + implementation(libs.ical4j) + implementation(libs.compose) + implementation("com.prolificinteractive:material-calendarview:1.4.3") { + exclude(group = "com.android.support", module = "support-v4") + } } tasks.withType { diff --git a/app/src/androidTest/java/com/github/lookupgroup27/lookup/screen/CalendarScreenTest.kt b/app/src/androidTest/java/com/github/lookupgroup27/lookup/screen/CalendarScreenTest.kt new file mode 100644 index 000000000..eb6ab722b --- /dev/null +++ b/app/src/androidTest/java/com/github/lookupgroup27/lookup/screen/CalendarScreenTest.kt @@ -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() + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/model/calendar/CalendarViewModel.kt b/app/src/main/java/com/github/lookupgroup27/lookup/model/calendar/CalendarViewModel.kt new file mode 100644 index 000000000..59a109735 --- /dev/null +++ b/app/src/main/java/com/github/lookupgroup27/lookup/model/calendar/CalendarViewModel.kt @@ -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() { + + private val client = OkHttpClient() + private val _icalEvents = mutableStateListOf() + val icalEvents: List = _icalEvents + + 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") + 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() + + for (component in calendar.getComponents(VEvent.VEVENT)) { + val dtStart = component.getProperty(Property.DTSTART) + val dtEnd = component.getProperty(Property.DTEND) + + dtStart?.let { startDate -> + val endDate = dtEnd?.date ?: startDate.date + val rrule = component.getProperty(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) { + Log.e("CalendarViewModel", "Error parsing iCal data: ${e.localizedMessage}", e) + } + } + + private fun handleRecurringEvents( + component: VEvent, + period: Period, + eventStartDate: Date, + eventEndDate: Date, + allEvents: MutableList + ) { + val recurrenceSet = component.calculateRecurrenceSet(period) + for (recurrence in recurrenceSet) { + val eventInstance = component.copy() as VEvent + eventInstance.getProperty(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(Property.DTSTART)?.date = + DateTime(current.time) + 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( + component: VEvent, + eventStartDate: Date, + eventEndDate: Date, + allEvents: MutableList + ) { + if (eventEndDate.after(eventStartDate)) { + var current = eventStartDate + while (current.before(eventEndDate)) { + val eventInstance = component.copy() as VEvent + eventInstance.getProperty(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) + } + } +} diff --git a/app/src/main/java/com/github/lookupgroup27/lookup/ui/overview/Calendar.kt b/app/src/main/java/com/github/lookupgroup27/lookup/ui/overview/Calendar.kt index b25b626ec..773213bbf 100644 --- a/app/src/main/java/com/github/lookupgroup27/lookup/ui/overview/Calendar.kt +++ b/app/src/main/java/com/github/lookupgroup27/lookup/ui/overview/Calendar.kt @@ -1,5 +1,302 @@ package com.github.lookupgroup27.lookup.ui.overview -import androidx.compose.runtime.Composable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.github.lookupgroup27.lookup.model.calendar.CalendarViewModel +import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions +import java.text.SimpleDateFormat +import java.util.* +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.component.VEvent +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Summary -@Composable fun CalendarScreen() {} +@Composable +fun CalendarScreen( + calendarViewModel: CalendarViewModel = viewModel(), + navigationActions: NavigationActions +) { + + var selectedDate by remember { mutableStateOf(Date()) } + var searchQuery by remember { mutableStateOf("") } + var showDialog by remember { mutableStateOf(false) } + var searchResults by remember { mutableStateOf>(emptyList()) } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + CalendarHeader( + selectedDate = selectedDate, + onPreviousMonth = { selectedDate = updateMonth(selectedDate, -1) }, + onNextMonth = { selectedDate = updateMonth(selectedDate, 1) }) + + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = { showDialog = true }) { Text("Look Up Event") } + + Spacer(modifier = Modifier.height(16.dp)) + + CalendarGrid( + selectedDate = selectedDate, + onDateSelected = { newDate -> + searchQuery = "" + selectedDate = newDate + }, + calendarViewModel = calendarViewModel) + + Spacer(modifier = Modifier.height(16.dp)) + + if (searchQuery.isNotEmpty()) { + EventListWithResults( + searchResults = searchResults, + onEventClick = { eventDate -> + selectedDate = eventDate + searchQuery = "" + }) + } else { + EventList(calendarViewModel = calendarViewModel, selectedDate = selectedDate) + } + } + + if (showDialog) { + SearchDialog( + searchQuery = searchQuery, + onQueryChange = { searchQuery = it }, + onSearch = { + searchResults = + calendarViewModel.icalEvents + .filter { event -> + val eventTitle = event.getProperty(Property.SUMMARY)?.value ?: "" + eventTitle.contains(searchQuery, ignoreCase = true) + } + .sortedBy { event -> + (event.getProperty(Property.DTSTART)?.date as? Date)?.time + ?: Long.MAX_VALUE + } + showDialog = false + }, + onDismiss = { showDialog = false }) + } +} + +@Composable +fun SearchDialog( + searchQuery: String, + onQueryChange: (String) -> Unit, + onSearch: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Look Up Event") }, + text = { + Column { + OutlinedTextField( + value = searchQuery, + onValueChange = onQueryChange, + label = { Text("Enter event name") }, + modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { Button(onClick = onSearch) { Text("Search") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }) +} + +@Composable +fun EventListWithResults(searchResults: List, onEventClick: (Date) -> Unit) { + if (searchResults.isNotEmpty()) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Search Results:", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp)) + + LazyColumn(modifier = Modifier.fillMaxHeight().padding(bottom = 8.dp)) { + items(searchResults) { event -> + val eventDate = event.getProperty(Property.DTSTART)?.date as? Date + if (eventDate != null) { + EventItem(event = event, onClick = { onEventClick(eventDate) }) + } + Spacer(modifier = Modifier.height(10.dp)) + } + } + } + } else { + Text( + text = "No matching events found.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +fun CalendarHeader(selectedDate: Date, onPreviousMonth: () -> Unit, onNextMonth: () -> Unit) { + val monthFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault()) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically) { + BasicText( + text = "<", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.clickable { onPreviousMonth() }) + + Text( + text = monthFormat.format(selectedDate), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineLarge, + modifier = Modifier.weight(1f)) + + BasicText( + text = ">", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.clickable { onNextMonth() }) + } +} + +@Composable +fun CalendarGrid( + selectedDate: Date, + onDateSelected: (Date) -> Unit, + calendarViewModel: CalendarViewModel +) { + val calendar = Calendar.getInstance().apply { time = selectedDate } + calendar.set(Calendar.DAY_OF_MONTH, 1) + val daysInMonth = calendar.getActualMaximum(Calendar.DAY_OF_MONTH) + + LazyVerticalGrid( + columns = GridCells.Fixed(7), + contentPadding = PaddingValues(4.dp), + modifier = Modifier.fillMaxWidth()) { + items(daysInMonth) { day -> + val date = + Calendar.getInstance() + .apply { + time = selectedDate + set(Calendar.DAY_OF_MONTH, day + 1) + } + .time + + val hasEvents = + calendarViewModel.icalEvents.any { event -> + val dtStart = event.getProperty(Property.DTSTART)?.date + dtStart != null && isSameDay(dtStart, date) + } + + val isSelected = isSameDay(date, selectedDate) + + Box( + modifier = + Modifier.size(48.dp) + .background( + if (isSelected) MaterialTheme.colorScheme.secondary.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.surface) + .clickable { onDateSelected(date) }, + contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = (day + 1).toString(), + textAlign = TextAlign.Center, + ) + + if (hasEvents) { + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = + Modifier.size(8.dp) + .background(MaterialTheme.colorScheme.primary, shape = CircleShape)) + } + } + } + } + } +} + +@Composable +fun EventList(calendarViewModel: CalendarViewModel, selectedDate: Date) { + val dateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) + val formattedDate = dateFormat.format(selectedDate) + + val eventsForDay = + calendarViewModel.icalEvents.filter { event -> + val dtStart = event.getProperty(Property.DTSTART)?.date + dtStart != null && isSameDay(dtStart, selectedDate) + } + + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = "Events ($formattedDate):", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp)) + + if (eventsForDay.isNotEmpty()) { + eventsForDay.forEach { event -> + EventItem(event = event, onClick = {}) + Spacer(modifier = Modifier.height(8.dp)) + } + } else { + Text( + text = "No events for today.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +fun EventItem(event: VEvent, onClick: () -> Unit) { + val eventTitle = event.getProperty(Property.SUMMARY)?.value ?: "Unnamed Event" + val eventDate = event.getProperty(Property.DTSTART)?.date as? Date + val formattedDate = + eventDate?.let { SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()).format(it) } + ?: "Unknown date" + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = eventTitle, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Date: $formattedDate", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +fun isSameDay(date1: Date, date2: Date): Boolean { + val cal1 = Calendar.getInstance().apply { time = date1 } + val cal2 = Calendar.getInstance().apply { time = date2 } + return cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) && + cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR) +} + +fun updateMonth(date: Date, months: Int): Date { + return Calendar.getInstance() + .apply { + time = date + add(Calendar.MONTH, months) + } + .time +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e2e6685e..9343b6502 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,11 @@ firebaseAuthKtx = "23.0.0" firebaseDatabaseKtx = "21.0.0" firebaseFirestore = "25.1.0" firebaseUiAuth = "8.0.0" +navigationRuntimeKtx = "2.8.2" + +# Calendar Libraries +ical4j = "3.0.21" +compose = "1.5.1" [libraries] @@ -137,6 +142,11 @@ test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "and testng = { group = "org.testng", name = "testng", version.ref = "testng" } androidx-ui-test-junit4-android = { group = "androidx.compose.ui", name = "ui-test-junit4-android", version.ref = "uiTestJunit4Android" } androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } +androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" } + +#For Calendar +ical4j = { module = "org.mnode.ical4j:ical4j", version.ref = "ical4j" } +compose = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "compose" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }