From f74572e7c543d058df9536cf9406ce0e00fe578d Mon Sep 17 00:00:00 2001 From: conniecliu Date: Sun, 26 Apr 2026 23:45:47 -0400 Subject: [PATCH 01/11] Implement user availability UI and networking stubs. --- .../model/api/AvailabilityApiService.kt | 36 +++ .../android/model/api/RetrofitInstance.kt | 9 + .../model/profile/AvailabilityRepository.kt | 38 +++ .../helper/AvailabilityFilters.kt | 114 +++++++++ .../availability/helper/MonthCalendar.kt | 116 +++++++++ .../ui/components/global/ResellCheckbox.kt | 86 +++++++ .../ui/components/global/ResellSwitchRow.kt | 67 ++++++ .../ui/screens/main/AvailabilityScreen.kt | 220 ++++++++++++++++++ .../screens/settings/NotificationSettings.kt | 54 +---- .../viewmodel/main/AvailabilityViewModel.kt | 109 +++++++++ app/src/main/res/drawable/ic_hamburger.xml | 15 ++ 11 files changed, 821 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/resell/android/model/api/AvailabilityApiService.kt create mode 100644 app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt create mode 100644 app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt create mode 100644 app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt create mode 100644 app/src/main/java/com/cornellappdev/resell/android/ui/components/global/ResellCheckbox.kt create mode 100644 app/src/main/java/com/cornellappdev/resell/android/ui/components/global/ResellSwitchRow.kt create mode 100644 app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt create mode 100644 app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt create mode 100644 app/src/main/res/drawable/ic_hamburger.xml diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/api/AvailabilityApiService.kt b/app/src/main/java/com/cornellappdev/resell/android/model/api/AvailabilityApiService.kt new file mode 100644 index 00000000..632d9cb3 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/model/api/AvailabilityApiService.kt @@ -0,0 +1,36 @@ +package com.cornellappdev.resell.android.model.api + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface AvailabilityApiService { + + @GET("availability/") + suspend fun getMyAvailability(): AvailabilityResponse + + @POST("availability/update/") + suspend fun updateAvailability( + @Body request: UpdateAvailabilityRequest + ): AvailabilityResponse +} + +data class AvailabilityResponse( + val availability: UserAvailability +) + +data class UserAvailability( + val id: String, + val userId: String, + val schedule: Map>, + val updatedAt: String +) + +data class AvailabilitySlot( + val startDate: String, + val endDate: String +) + +data class UpdateAvailabilityRequest( + val schedule: Map> +) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/api/RetrofitInstance.kt b/app/src/main/java/com/cornellappdev/resell/android/model/api/RetrofitInstance.kt index c1f977bb..4c5c0fa4 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/api/RetrofitInstance.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/api/RetrofitInstance.kt @@ -158,6 +158,15 @@ class RetrofitInstance @Inject constructor( .create(SettingsApiService::class.java) } + val availabilityApi: AvailabilityApiService by lazy { + Retrofit.Builder() + .baseUrl(BuildConfig.BASE_API_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(AvailabilityApiService::class.java) + } + val transactionApi: TransactionApiService by lazy { Retrofit.Builder() .baseUrl(BuildConfig.BASE_API_URL) diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt new file mode 100644 index 00000000..967642bd --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt @@ -0,0 +1,38 @@ +package com.cornellappdev.resell.android.model.profile + +import com.cornellappdev.resell.android.model.api.AvailabilitySlot +import com.cornellappdev.resell.android.model.api.RetrofitInstance +import com.cornellappdev.resell.android.model.api.UpdateAvailabilityRequest +import com.cornellappdev.resell.android.model.api.UserAvailability +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AvailabilityRepository @Inject constructor( + private val retrofitInstance: RetrofitInstance +) { + suspend fun getMyAvailability(): UserAvailability { + return retrofitInstance.availabilityApi.getMyAvailability().availability + } + + suspend fun updateAvailability(slots: List): UserAvailability { + // Convert List to Map<"yyyy-MM-dd", List> + val schedule = slots + .groupBy { it.toLocalDate().toString() } + .mapValues { (_, daySlots) -> + daySlots.sortedBy { it }.map { start -> + AvailabilitySlot( + startDate = start.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + endDate = start.plusMinutes(30L) + .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + ) + } + } + + return retrofitInstance.availabilityApi.updateAvailability( + UpdateAvailabilityRequest(schedule = schedule) + ).availability + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt new file mode 100644 index 00000000..5bab5f22 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt @@ -0,0 +1,114 @@ +package com.cornellappdev.resell.android.ui.components.availability.helper +import androidx.compose.foundation.background + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.resell.android.ui.components.global.ResellCheckboxRow +import com.cornellappdev.resell.android.ui.components.global.ResellSwitchRow +import com.cornellappdev.resell.android.ui.theme.Style + +// TODO: very hard coded right now, should integrate networking here + implement viewmodel +@Composable +fun AvailabilityFilters( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = Color(0xFFF7F3F9)) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.Start + ) { + + HorizontalDivider() + + ResellSwitchRow( + title = "Google Calendar Access", + checked = true, + enabled = true, + onCheckedChange = { + // TODO + } + ) + + HorizontalDivider() + + ResellSwitchRow( + title = "Availability Sharing", + checked = true, + enabled = true, + onCheckedChange = { + // TODO + } + ) + + HorizontalDivider() + + Column( + modifier = Modifier.padding(22.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + Text( + text = "Sub-Calendars", + style = Style.body1, + fontWeight = FontWeight.SemiBold + ) + + // TODO: replace dummy here with actual sub-calendars from the user + val subCalendars = listOf("Personal", "Leetcode", "Youtube", "Capra") + // TODO: this is just hard coded, make it not hard coded + val checkedStates = remember { mutableStateMapOf().apply { + subCalendars.forEach { put(it, it != "Personal" && it != "Capra") } + }} + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + subCalendars.chunked(2).forEach { pair -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + pair.forEach { name -> + ResellCheckboxRow( + title = name, + checked = checkedStates[name] ?: false, + enabled = true, + onCheckedChange = { checkedStates[name] = it }, + modifier = Modifier.weight(1f) + ) + } + if (pair.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + } +} + +@Preview +@Composable +fun AvailabilityFiltersPreview() { + AvailabilityFilters() +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt new file mode 100644 index 00000000..b6fe2f71 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt @@ -0,0 +1,116 @@ +package com.cornellappdev.resell.android.ui.components.availability.helper + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.resell.android.ui.theme.Style +import java.time.YearMonth +import java.time.LocalDate + + +@Composable +fun MonthCalendar( + currentMonth: YearMonth, + selectedDates: List, + onMonthChange: (YearMonth) -> Unit, + modifier: Modifier = Modifier, +) { + val firstDayOfMonth = currentMonth.atDay(1) + val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7 + val daysInMonth = currentMonth.lengthOfMonth() + val dayLabels = listOf("Su", "Mo", "Tu", "We", "Th", "Fr", "Sa") + + val sortedDates = selectedDates.sorted() + val rangeStart = sortedDates.firstOrNull() + val rangeEnd = sortedDates.lastOrNull() + + Column( + modifier + .background(color = Color(0xfff7f3f9)) + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth() + ) { + dayLabels.forEach { label -> + Text( + text = label, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = Style.body2, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF707070) + ) + } + } + + val totalCells = firstDayOfWeek + daysInMonth + val rows = (totalCells + 6) / 7 + + for (row in 0 until rows) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + for (col in 0..6) { + val cellIndex = row * 7 + col + val day = cellIndex - firstDayOfWeek + 1 + val date = if (day in 1..daysInMonth) currentMonth.atDay(day) else null + val isSelected = date != null && date in selectedDates + val isRangeStart = date == rangeStart + val isRangeEnd = date == rangeEnd + val isMiddle = isSelected && !isRangeStart && !isRangeEnd + + Box( + modifier = Modifier + .weight(1f) + .background( + color = if (isSelected) Color(0xffe4e0e8) else Color.Transparent, + shape = when { + isRangeStart && isRangeEnd -> RoundedCornerShape(4.dp) + isRangeStart -> RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp) + isRangeEnd -> RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp) + isMiddle -> RoundedCornerShape(0.dp) + else -> RoundedCornerShape(0.dp) + } + ) + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + if (date != null) { + Text(text = "$day", style = Style.body2) + } + } + } + } + } + } +} + +@Preview +@Composable +fun MonthCalendarPreview() { + MonthCalendar( + currentMonth = YearMonth.of(2026, 4), + selectedDates = listOf( + LocalDate.of(2026, 4, 16), + LocalDate.of(2026, 4, 17), + LocalDate.of(2026, 4, 18), + ), + onMonthChange = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/global/ResellCheckbox.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/global/ResellCheckbox.kt new file mode 100644 index 00000000..096c9db6 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/global/ResellCheckbox.kt @@ -0,0 +1,86 @@ +package com.cornellappdev.resell.android.ui.components.global + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.cornellappdev.resell.android.ui.theme.ResellPurple +import com.cornellappdev.resell.android.ui.theme.Style + +@Composable +fun ResellCheckboxRow( + title: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + + val fillColor = if (checked) ResellPurple else Color.Transparent + val borderColor = if (checked) ResellPurple else Color.DarkGray + val shape = RoundedCornerShape(6.dp) + + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = title, + style = Style.body2, + ) + // Custom checkbox for Resell. Checkbox in Material3 doesn't offer enough customization. + Box( + modifier = Modifier + .size(22.dp) + .clip(shape) + .background(fillColor, shape) + .border(1.5.dp, borderColor, shape) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Checkbox, + onValueChange = onCheckedChange + ), + contentAlignment = Alignment.Center + ) { + if (checked) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.fillMaxSize(0.6f) + ) + } + } + } +} + +@Preview +@Composable +private fun ResellCheckboxPreview() { + ResellCheckboxRow( + title = "Personal", + checked = true, + enabled = true, + onCheckedChange = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/global/ResellSwitchRow.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/global/ResellSwitchRow.kt new file mode 100644 index 00000000..13307fcd --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/global/ResellSwitchRow.kt @@ -0,0 +1,67 @@ +package com.cornellappdev.resell.android.ui.components.global + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.cornellappdev.resell.android.ui.theme.IconInactive +import com.cornellappdev.resell.android.ui.theme.ResellPurple +import com.cornellappdev.resell.android.ui.theme.Style +import com.cornellappdev.resell.android.util.defaultHorizontalPadding + + +@Composable +fun ResellSwitchRow( + title: String, + checked: Boolean, + enabled: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier + .fillMaxWidth() + .defaultHorizontalPadding(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = Style.body1, + fontWeight = FontWeight.SemiBold + ) + + Switch( + checked = checked && enabled, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + uncheckedThumbColor = IconInactive, + checkedTrackColor = ResellPurple, + uncheckedTrackColor = Color.White, + checkedBorderColor = ResellPurple, + uncheckedBorderColor = IconInactive + ), + enabled = enabled, + ) + } +} + +@Preview +@Composable +private fun ResellSwitchRowPreview() { + ResellSwitchRow( + title = "Turn on notifications", + checked = true, + enabled = true, + onCheckedChange = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt new file mode 100644 index 00000000..4d7e604b --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt @@ -0,0 +1,220 @@ +package com.cornellappdev.resell.android.ui.screens.main + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.cornellappdev.resell.android.R +import com.cornellappdev.resell.android.ui.components.availability.helper.AvailabilityFilters +import com.cornellappdev.resell.android.ui.components.availability.helper.GridSelectionType +import com.cornellappdev.resell.android.ui.components.availability.helper.MonthCalendar +import com.cornellappdev.resell.android.ui.components.availability.helper.SelectableAvailabilityGrid +import com.cornellappdev.resell.android.ui.components.global.ResellHeader +import com.cornellappdev.resell.android.ui.components.global.ResellTextButton +import com.cornellappdev.resell.android.ui.components.global.ResellTextButtonState +import com.cornellappdev.resell.android.ui.theme.Style +import com.cornellappdev.resell.android.viewmodel.main.AvailabilityViewModel +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +private enum class AvailabilityPanel { + NONE, + FILTERS, + CALENDAR +} + +// TODO: need to test this Screen once upstream Screen is fully implemented and networked (ProfileScreen) +@Composable +fun AvailabilityScreen( + availabilityViewModel: AvailabilityViewModel = hiltViewModel() +) { + + val availabilityUiState = availabilityViewModel.collectUiStateValue() + + val firstOfWeek = availabilityUiState.currentMonth.atDay(1) + val dates: List = (0..2).map { firstOfWeek.plusDays(it.toLong()) } + + // just some UI logic to allow for smooth transitions between panels expanding on the screen. + var activePanel by remember { mutableStateOf(AvailabilityPanel.NONE) } + val panelVisible = activePanel != AvailabilityPanel.NONE + + val panelBackgroundColor = Color(0xFFF7F3F9) + var panelHeightPx by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val gridOffsetY by animateDpAsState( + targetValue = if (panelVisible) with(density) { panelHeightPx.toDp() } else 0.dp, + animationSpec = tween(durationMillis = 250), + label = "availability grid offset" + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + ) { + Column(modifier = Modifier.fillMaxSize()) { + ResellHeader( + title = "Availability", + leftPainter = R.drawable.ic_chevron_left, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .background(if (panelVisible) panelBackgroundColor else Color.White) + ) { + HorizontalDivider() + Row( + modifier = Modifier.padding(21.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_hamburger), + contentDescription = null, + modifier = Modifier + .size(36.dp) + .clickable { + activePanel = if (activePanel == AvailabilityPanel.FILTERS) { + AvailabilityPanel.NONE + } else { + AvailabilityPanel.FILTERS + } + } + ) + Text( + text = availabilityUiState.currentMonth.format(DateTimeFormatter.ofPattern("MMMM")), + style = Style.heading3, + modifier = Modifier.clickable { + activePanel = if (activePanel == AvailabilityPanel.CALENDAR) { + AvailabilityPanel.NONE + } else { + AvailabilityPanel.CALENDAR + } + } + ) + } + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clipToBounds() + ) { + SelectableAvailabilityGrid( + dates = dates, + selectedAvailabilities = availabilityUiState.selectedAvailabilities, + setSelectedAvailabilities = { availabilityViewModel.setSelectedAvailabilities(it) }, + gridSelectionType = GridSelectionType.AVAILABILITY, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .offset(y = gridOffsetY), + onProposalSelected = {} + ) + + androidx.compose.animation.AnimatedVisibility( + visible = panelVisible, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .onSizeChanged { panelHeightPx = it.height }, + enter = slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(durationMillis = 250) + ) + fadeIn(animationSpec = tween(durationMillis = 250)), + exit = slideOutVertically( + targetOffsetY = { -it }, + animationSpec = tween(durationMillis = 250) + ) + fadeOut(animationSpec = tween(durationMillis = 250)), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + .background(panelBackgroundColor) + ) { + when (activePanel) { + AvailabilityPanel.CALENDAR -> MonthCalendar( + currentMonth = availabilityUiState.currentMonth, + selectedDates = dates, + onMonthChange = { availabilityViewModel.setCurrentMonth(it) }, + modifier = Modifier.fillMaxWidth(), + ) + AvailabilityPanel.FILTERS -> AvailabilityFilters( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp), + ) + AvailabilityPanel.NONE -> Unit + } + } + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .align(Alignment.BottomCenter) + .background( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.White) + ) + ) + ) + + ResellTextButton( + text = "Save", + onClick = { availabilityViewModel.saveAvailability() }, + state = ResellTextButtonState.ENABLED, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 24.dp) + .navigationBarsPadding() + ) + } +} + +@Preview +@Composable +fun AvailabilityScreenPreview() { + AvailabilityScreen() +} diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/settings/NotificationSettings.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/settings/NotificationSettings.kt index c45f34ad..03a8e221 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/settings/NotificationSettings.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/settings/NotificationSettings.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.resell.android.ui.components.global.ResellHeader +import com.cornellappdev.resell.android.ui.components.global.ResellSwitchRow import com.cornellappdev.resell.android.ui.theme.IconInactive import com.cornellappdev.resell.android.ui.theme.ResellPurple import com.cornellappdev.resell.android.ui.theme.Style @@ -67,61 +68,28 @@ private fun NotificationsSettingsContent( Spacer(Modifier.height(24.dp)) - SwitchRow( + ResellSwitchRow( title = "Pause All Notifications", checked = pause, enabled = true, - onCheckedChange = onPauseChange + onCheckedChange = onPauseChange, + modifier = Modifier.padding(vertical = 20.dp) ) - SwitchRow( + ResellSwitchRow( title = "Chat Notifications", checked = chat, enabled = !pause, - onCheckedChange = onChatChange + onCheckedChange = onChatChange, + modifier = Modifier.padding(vertical = 20.dp) ) - SwitchRow( + ResellSwitchRow( title = "Listings Notifications", checked = listings, enabled = !pause, - onCheckedChange = onListingsChange + onCheckedChange = onListingsChange, + modifier = Modifier.padding(vertical = 20.dp) ) } -} - -@Composable -private fun SwitchRow( - title: String, - checked: Boolean, - enabled: Boolean, - onCheckedChange: (Boolean) -> Unit, -) { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .padding(vertical = 20.dp) - .fillMaxWidth() - .defaultHorizontalPadding(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = title, - style = Style.body1, - ) - - Switch( - checked = checked && enabled, - onCheckedChange = onCheckedChange, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - uncheckedThumbColor = IconInactive, - checkedTrackColor = ResellPurple, - uncheckedTrackColor = Color.White, - checkedBorderColor = ResellPurple, - uncheckedBorderColor = IconInactive - ), - enabled = enabled, - ) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt new file mode 100644 index 00000000..dbb8817d --- /dev/null +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt @@ -0,0 +1,109 @@ +package com.cornellappdev.resell.android.viewmodel.main + +import androidx.lifecycle.viewModelScope +import com.cornellappdev.resell.android.model.profile.AvailabilityRepository +import com.cornellappdev.resell.android.model.api.UserAvailability +import com.cornellappdev.resell.android.viewmodel.ResellViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +@HiltViewModel +class AvailabilityViewModel @Inject constructor( + private val availabilityRepository: AvailabilityRepository +) : ResellViewModel( + initialUiState = AvailabilityUiState() +) { + + data class AvailabilityUiState( + val selectedAvailabilities: List = emptyList(), + val currentMonth: YearMonth = YearMonth.now(), + + // TODO: googleCalendarEnabled and availabilitySharingEnabled are not yet wired in. + // Need to check how/where it is in the backend + val googleCalendarEnabled: Boolean = false, + val availabilitySharingEnabled: Boolean = false, + + // TODO: subCalendars should come from Google Calendar API, not hardcoded. + val subCalendars: List = emptyList(), + val enabledSubCalendars: Set = emptySet(), + + val isLoading: Boolean = false, + val saveSuccess: Boolean = false, + val errorMessage: String? = null, + ) + + init { + loadAvailability() + } + + // grid interactions + + fun setSelectedAvailabilities(slots: List) { + applyMutation { copy(selectedAvailabilities = slots) } + } + + fun setCurrentMonth(month: YearMonth) { + applyMutation { copy(currentMonth = month) } + } + + fun setGoogleCalendarEnabled(enabled: Boolean) { + // TODO: may need an OAuth scope check before enabling + applyMutation { copy(googleCalendarEnabled = enabled) } + } + + fun setAvailabilitySharingEnabled(enabled: Boolean) { + applyMutation { copy(availabilitySharingEnabled = enabled) } + } + + fun setSubCalendarEnabled(calendarName: String, enabled: Boolean) { + applyMutation { + val updated = if (enabled) enabledSubCalendars + calendarName + else enabledSubCalendars - calendarName + copy(enabledSubCalendars = updated) + } + } + + private fun loadAvailability() { + viewModelScope.launch { + applyMutation { copy(isLoading = true) } + try { + val availability = availabilityRepository.getMyAvailability() + applyMutation { + copy( + selectedAvailabilities = availability.toLocalDateTimes(), + isLoading = false + ) + } + } catch (e: Exception) { + applyMutation { copy(isLoading = false, errorMessage = e.message) } + } + } + } + + fun saveAvailability() { + viewModelScope.launch { + applyMutation { copy(isLoading = true, saveSuccess = false) } + try { + availabilityRepository.updateAvailability(stateValue().selectedAvailabilities) + applyMutation { copy(isLoading = false, saveSuccess = true) } + } catch (e: Exception) { + applyMutation { copy(isLoading = false, errorMessage = e.message) } + } + } + } +} + +/** + * Converts the backend schedule (Map>) back into + * a flat list of LocalDateTimes for the grid to consume. + * Each slot's startDate is used as the representative time for a cell. + */ +private fun UserAvailability.toLocalDateTimes(): List { + return schedule.values.flatten().map { slot -> + LocalDateTime.parse(slot.startDate, DateTimeFormatter.ISO_DATE_TIME) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_hamburger.xml b/app/src/main/res/drawable/ic_hamburger.xml new file mode 100644 index 00000000..737be361 --- /dev/null +++ b/app/src/main/res/drawable/ic_hamburger.xml @@ -0,0 +1,15 @@ + + + + + From b619f04e7c80e8b1ed1fab62e15213008a731eb3 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Wed, 2 Sep 2026 18:16:53 -0400 Subject: [PATCH 02/11] fix: update gitignore and add a temp reroute for user not found error when logging in --- .gitignore | 5 +++-- .../android/viewmodel/onboarding/LandingViewModel.kt | 7 +++++++ gradlew | 0 3 files changed, 10 insertions(+), 2 deletions(-) mode change 100644 => 100755 gradlew diff --git a/.gitignore b/.gitignore index 62c68b19..56b7ebd7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ local.properties # Log/OS Files *.log +.DS_Store # Android Studio generated files and folders captures/ @@ -28,11 +29,11 @@ render.experimental.xml *.keystore # Google Services (e.g. APIs or Firebase) -app/google-services.json +google-services.json # Android Profiling *.hprof # Secrets secrets.properties -app/src/main/assets/resell-service.json +resell-service.json diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/LandingViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/LandingViewModel.kt index 4fb09394..811b0080 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/LandingViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/onboarding/LandingViewModel.kt @@ -28,6 +28,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import retrofit2.HttpException import javax.inject.Inject @HiltViewModel @@ -171,6 +172,12 @@ class LandingViewModel @Inject constructor( rootNavigationRepository.navigate(ResellRootRoute.MAIN) } } catch (e: Exception) { + if (e is HttpException && e.code() == 403) { + Log.d("LandingViewModel", "User not found on backend; routing to onboarding.") + rootNavigationRepository.navigate(ResellRootRoute.ONBOARDING) + return@launch + } + Log.e("LandingViewModel", "Error getting user: ", e) onSignInFailed(showSheet = false) rootConfirmationRepository.showError( diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 6bbb26b7ed426bbf2d724c17648e17190e13b1e5 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Tue, 15 Sep 2026 23:08:24 -0400 Subject: [PATCH 03/11] fix: minor fixes to comments --- .../resell/android/model/profile/AvailabilityRepository.kt | 2 +- .../android/ui/screens/settings/NotificationSettings.kt | 7 ++----- .../resell/android/viewmodel/main/AvailabilityViewModel.kt | 7 ++++--- app/src/main/res/drawable/ic_hamburger.xml | 4 ++-- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt index 967642bd..41d0cd7d 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt @@ -22,7 +22,7 @@ class AvailabilityRepository @Inject constructor( val schedule = slots .groupBy { it.toLocalDate().toString() } .mapValues { (_, daySlots) -> - daySlots.sortedBy { it }.map { start -> + daySlots.sorted().map { start -> AvailabilitySlot( startDate = start.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), endDate = start.plusMinutes(30L) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/settings/NotificationSettings.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/settings/NotificationSettings.kt index 03a8e221..729fea25 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/settings/NotificationSettings.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/settings/NotificationSettings.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Switch @@ -60,7 +59,8 @@ private fun NotificationsSettingsContent( .fillMaxSize() .background(Color.White) .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) ) { ResellHeader( title = "Notification Preferences", @@ -73,7 +73,6 @@ private fun NotificationsSettingsContent( checked = pause, enabled = true, onCheckedChange = onPauseChange, - modifier = Modifier.padding(vertical = 20.dp) ) ResellSwitchRow( @@ -81,7 +80,6 @@ private fun NotificationsSettingsContent( checked = chat, enabled = !pause, onCheckedChange = onChatChange, - modifier = Modifier.padding(vertical = 20.dp) ) ResellSwitchRow( @@ -89,7 +87,6 @@ private fun NotificationsSettingsContent( checked = listings, enabled = !pause, onCheckedChange = onListingsChange, - modifier = Modifier.padding(vertical = 20.dp) ) } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt index dbb8817d..ba9199cc 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt @@ -75,7 +75,8 @@ class AvailabilityViewModel @Inject constructor( applyMutation { copy( selectedAvailabilities = availability.toLocalDateTimes(), - isLoading = false + isLoading = false, + errorMessage = null ) } } catch (e: Exception) { @@ -89,7 +90,7 @@ class AvailabilityViewModel @Inject constructor( applyMutation { copy(isLoading = true, saveSuccess = false) } try { availabilityRepository.updateAvailability(stateValue().selectedAvailabilities) - applyMutation { copy(isLoading = false, saveSuccess = true) } + applyMutation { copy(isLoading = false, saveSuccess = true, errorMessage = null) } } catch (e: Exception) { applyMutation { copy(isLoading = false, errorMessage = e.message) } } @@ -104,6 +105,6 @@ class AvailabilityViewModel @Inject constructor( */ private fun UserAvailability.toLocalDateTimes(): List { return schedule.values.flatten().map { slot -> - LocalDateTime.parse(slot.startDate, DateTimeFormatter.ISO_DATE_TIME) + LocalDateTime.parse(slot.startDate, DateTimeFormatter.ISO_LOCAL_DATE_TIME) } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_hamburger.xml b/app/src/main/res/drawable/ic_hamburger.xml index 737be361..2022f5a4 100644 --- a/app/src/main/res/drawable/ic_hamburger.xml +++ b/app/src/main/res/drawable/ic_hamburger.xml @@ -1,6 +1,6 @@ Date: Wed, 16 Sep 2026 15:02:59 -0400 Subject: [PATCH 04/11] fix: add AvailabilityScreenContent composable so preview can render --- .../ui/screens/main/AvailabilityScreen.kt | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt index 4d7e604b..69ec46c1 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt @@ -51,6 +51,8 @@ import com.cornellappdev.resell.android.ui.components.global.ResellTextButtonSta import com.cornellappdev.resell.android.ui.theme.Style import com.cornellappdev.resell.android.viewmodel.main.AvailabilityViewModel import java.time.LocalDate +import java.time.LocalDateTime +import java.time.YearMonth import java.time.format.DateTimeFormatter private enum class AvailabilityPanel { @@ -64,10 +66,24 @@ private enum class AvailabilityPanel { fun AvailabilityScreen( availabilityViewModel: AvailabilityViewModel = hiltViewModel() ) { - val availabilityUiState = availabilityViewModel.collectUiStateValue() - val firstOfWeek = availabilityUiState.currentMonth.atDay(1) + AvailabilityScreenContent( + uiState = availabilityUiState, + onSetSelectedAvailabilities = { availabilityViewModel.setSelectedAvailabilities(it) }, + onSetCurrentMonth = { availabilityViewModel.setCurrentMonth(it) }, + onSave = { availabilityViewModel.saveAvailability() }, + ) +} + +@Composable +fun AvailabilityScreenContent( + uiState: AvailabilityViewModel.AvailabilityUiState, + onSetSelectedAvailabilities: (List) -> Unit, + onSetCurrentMonth: (YearMonth) -> Unit, + onSave: () -> Unit, +) { + val firstOfWeek = uiState.currentMonth.atDay(1) val dates: List = (0..2).map { firstOfWeek.plusDays(it.toLong()) } // just some UI logic to allow for smooth transitions between panels expanding on the screen. @@ -118,7 +134,7 @@ fun AvailabilityScreen( } ) Text( - text = availabilityUiState.currentMonth.format(DateTimeFormatter.ofPattern("MMMM")), + text = uiState.currentMonth.format(DateTimeFormatter.ofPattern("MMMM")), style = Style.heading3, modifier = Modifier.clickable { activePanel = if (activePanel == AvailabilityPanel.CALENDAR) { @@ -139,8 +155,8 @@ fun AvailabilityScreen( ) { SelectableAvailabilityGrid( dates = dates, - selectedAvailabilities = availabilityUiState.selectedAvailabilities, - setSelectedAvailabilities = { availabilityViewModel.setSelectedAvailabilities(it) }, + selectedAvailabilities = uiState.selectedAvailabilities, + setSelectedAvailabilities = onSetSelectedAvailabilities, gridSelectionType = GridSelectionType.AVAILABILITY, modifier = Modifier .fillMaxSize() @@ -172,9 +188,9 @@ fun AvailabilityScreen( ) { when (activePanel) { AvailabilityPanel.CALENDAR -> MonthCalendar( - currentMonth = availabilityUiState.currentMonth, + currentMonth = uiState.currentMonth, selectedDates = dates, - onMonthChange = { availabilityViewModel.setCurrentMonth(it) }, + onMonthChange = onSetCurrentMonth, modifier = Modifier.fillMaxWidth(), ) AvailabilityPanel.FILTERS -> AvailabilityFilters( @@ -203,7 +219,7 @@ fun AvailabilityScreen( ResellTextButton( text = "Save", - onClick = { availabilityViewModel.saveAvailability() }, + onClick = onSave, state = ResellTextButtonState.ENABLED, modifier = Modifier .align(Alignment.BottomCenter) @@ -216,5 +232,20 @@ fun AvailabilityScreen( @Preview @Composable fun AvailabilityScreenPreview() { - AvailabilityScreen() + AvailabilityScreenContent( + uiState = AvailabilityViewModel.AvailabilityUiState( + selectedAvailabilities = emptyList(), + currentMonth = YearMonth.of(2026, 4), + googleCalendarEnabled = false, + availabilitySharingEnabled = false, + subCalendars = listOf("Personal", "Youtube", "Leetcode", "Capra"), + enabledSubCalendars = emptySet(), + isLoading = false, + saveSuccess = false, + errorMessage = null, + ), + onSetSelectedAvailabilities = {}, + onSetCurrentMonth = {}, + onSave = {}, + ) } From d06188b752286fa2282336239f816c48281e453b Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Thu, 17 Sep 2026 16:42:22 -0400 Subject: [PATCH 05/11] fix: comment out availability filter component for now to match current state of iOS --- .../availability/helper/AvailabilityFilters.kt | 1 + .../ui/screens/main/AvailabilityScreen.kt | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt index 5bab5f22..0cdb96d5 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt @@ -25,6 +25,7 @@ import com.cornellappdev.resell.android.ui.components.global.ResellSwitchRow import com.cornellappdev.resell.android.ui.theme.Style // TODO: very hard coded right now, should integrate networking here + implement viewmodel +// TODO: Figure out if we are still implementing this functionality @Composable fun AvailabilityFilters( modifier: Modifier = Modifier diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt index 69ec46c1..e9618213 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt @@ -57,11 +57,12 @@ import java.time.format.DateTimeFormatter private enum class AvailabilityPanel { NONE, - FILTERS, +// FILTERS, CALENDAR } // TODO: need to test this Screen once upstream Screen is fully implemented and networked (ProfileScreen) +// TODO: Figure out if availability filters need to be deleted or just stashed for later @Composable fun AvailabilityScreen( availabilityViewModel: AvailabilityViewModel = hiltViewModel() @@ -126,10 +127,10 @@ fun AvailabilityScreenContent( modifier = Modifier .size(36.dp) .clickable { - activePanel = if (activePanel == AvailabilityPanel.FILTERS) { + activePanel = if (activePanel == AvailabilityPanel.CALENDAR) { AvailabilityPanel.NONE } else { - AvailabilityPanel.FILTERS + AvailabilityPanel.CALENDAR } } ) @@ -193,11 +194,11 @@ fun AvailabilityScreenContent( onMonthChange = onSetCurrentMonth, modifier = Modifier.fillMaxWidth(), ) - AvailabilityPanel.FILTERS -> AvailabilityFilters( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 320.dp), - ) +// AvailabilityPanel.FILTERS -> AvailabilityFilters( +// modifier = Modifier +// .fillMaxWidth() +// .heightIn(max = 320.dp), +// ) AvailabilityPanel.NONE -> Unit } } From 4f0b07c296fe1d15db17f3f5a7c707fe2e425595 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Fri, 18 Sep 2026 13:31:46 -0400 Subject: [PATCH 06/11] fix: adjust month calendar to show prev/future days(greyed out), hoist selected days to viewmodel, put days into fixed groups of 3s, extract colors into Color.kt --- .../helper/AvailabilityFilters.kt | 4 +- .../availability/helper/AvailabilityUtil.kt | 9 ++++ .../availability/helper/MonthCalendar.kt | 50 +++++++++++++++---- .../helper/SelectableAvailabilityGrid.kt | 16 ++++++ .../ui/screens/main/AvailabilityScreen.kt | 16 +++--- .../resell/android/ui/theme/Color.kt | 3 ++ .../viewmodel/main/AvailabilityViewModel.kt | 9 +++- 7 files changed, 88 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt index 0cdb96d5..70cefd67 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityFilters.kt @@ -16,12 +16,12 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.cornellappdev.resell.android.ui.components.global.ResellCheckboxRow import com.cornellappdev.resell.android.ui.components.global.ResellSwitchRow +import com.cornellappdev.resell.android.ui.theme.AvailabilityPanelBackground import com.cornellappdev.resell.android.ui.theme.Style // TODO: very hard coded right now, should integrate networking here + implement viewmodel @@ -33,7 +33,7 @@ fun AvailabilityFilters( Column( modifier = modifier .fillMaxWidth() - .background(color = Color(0xFFF7F3F9)) + .background(color = AvailabilityPanelBackground) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.Start diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt index b2db8279..b69930ca 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt @@ -11,6 +11,7 @@ import com.cornellappdev.resell.android.util.day import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +import java.time.YearMonth import kotlin.math.floor const val GRID_HEIGHT = 24 @@ -18,6 +19,14 @@ val gridStartTime: LocalTime = LocalTime.of(9, 0) val gridStroke = Stroke val fillColor = ResellPurple +/** Returns a fixed 3-day group containing [date] (1-3, 4-6, ...), rolling into next month if needed. */ +fun dayGroupContaining(date: LocalDate): List { + val month = YearMonth.from(date) + val groupIndex = (date.dayOfMonth - 1) / 3 + val groupStart = month.atDay(groupIndex * 3 + 1) + return (0..2).map { groupStart.plusDays(it.toLong()) } +} + fun getGridCell(offset: Offset, canvasSize: Size, width: Int, height: Int): Pair { val gridCol = floor(offset.x / (canvasSize.width / width)).toInt().coerceIn(0, width - 1) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt index b6fe2f71..3ea10f44 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt @@ -1,6 +1,7 @@ package com.cornellappdev.resell.android.ui.components.availability.helper import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -9,6 +10,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -16,6 +21,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.cornellappdev.resell.android.ui.theme.AppDev +import com.cornellappdev.resell.android.ui.theme.AvailabilityPanelBackground +import com.cornellappdev.resell.android.ui.theme.AvailabilitySelectedDate +import com.cornellappdev.resell.android.ui.theme.IconInactive import com.cornellappdev.resell.android.ui.theme.Style import java.time.YearMonth import java.time.LocalDate @@ -26,6 +35,7 @@ fun MonthCalendar( currentMonth: YearMonth, selectedDates: List, onMonthChange: (YearMonth) -> Unit, + onDaysSelected: (List) -> Unit, modifier: Modifier = Modifier, ) { val firstDayOfMonth = currentMonth.atDay(1) @@ -39,7 +49,7 @@ fun MonthCalendar( Column( modifier - .background(color = Color(0xfff7f3f9)) + .background(color = AvailabilityPanelBackground) .padding(16.dp) ) { Row( @@ -52,7 +62,7 @@ fun MonthCalendar( textAlign = TextAlign.Center, style = Style.body2, fontWeight = FontWeight.SemiBold, - color = Color(0xFF707070) + color = AppDev ) } } @@ -68,9 +78,9 @@ fun MonthCalendar( ) { for (col in 0..6) { val cellIndex = row * 7 + col - val day = cellIndex - firstDayOfWeek + 1 - val date = if (day in 1..daysInMonth) currentMonth.atDay(day) else null - val isSelected = date != null && date in selectedDates + val date = firstDayOfMonth.plusDays((cellIndex - firstDayOfWeek).toLong()) + val isInCurrentMonth = YearMonth.from(date) == currentMonth + val isSelected = date in selectedDates val isRangeStart = date == rangeStart val isRangeEnd = date == rangeEnd val isMiddle = isSelected && !isRangeStart && !isRangeEnd @@ -79,7 +89,7 @@ fun MonthCalendar( modifier = Modifier .weight(1f) .background( - color = if (isSelected) Color(0xffe4e0e8) else Color.Transparent, + color = if (isSelected) AvailabilitySelectedDate else Color.Transparent, shape = when { isRangeStart && isRangeEnd -> RoundedCornerShape(4.dp) isRangeStart -> RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp) @@ -88,12 +98,15 @@ fun MonthCalendar( else -> RoundedCornerShape(0.dp) } ) + .clickable { onDaysSelected(dayGroupContaining(date)) } .padding(6.dp), contentAlignment = Alignment.Center ) { - if (date != null) { - Text(text = "$day", style = Style.body2) - } + Text( + text = "${date.dayOfMonth}", + style = Style.body2, + color = if (isInCurrentMonth) Color.Unspecified else IconInactive + ) } } } @@ -111,6 +124,23 @@ fun MonthCalendarPreview() { LocalDate.of(2026, 4, 17), LocalDate.of(2026, 4, 18), ), - onMonthChange = {} + onMonthChange = {}, + onDaysSelected = {} + ) +} + +/** Testing display for month with 31 days. */ +@Preview +@Composable +fun MonthCalendarThirtyOneDayMonthPreview() { + val month = YearMonth.of(2026, 7) + var selectedDates by remember { + mutableStateOf(dayGroupContaining(LocalDate.of(2026, 7, 31))) + } + MonthCalendar( + currentMonth = month, + selectedDates = selectedDates, + onMonthChange = {}, + onDaysSelected = { selectedDates = it } ) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/SelectableAvailabilityGrid.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/SelectableAvailabilityGrid.kt index 4cfb661f..ca941d2a 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/SelectableAvailabilityGrid.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/SelectableAvailabilityGrid.kt @@ -266,3 +266,19 @@ private fun AvailabilityGrid_RUNME_Preview() = ResellPreview { ) } } + +/** Test if availability grid can show days from two separate months */ +@Preview +@Composable +private fun SelectableAvailabilityGridRolloverPreview() = ResellPreview { + val rolloverDates = dayGroupContaining(LocalDate.of(2026, 10, 31)) + var selectedAvailabilities by remember { mutableStateOf(testAvailabilities(rolloverDates)) } + SelectableAvailabilityGrid( + dates = rolloverDates, + selectedAvailabilities = selectedAvailabilities, + setSelectedAvailabilities = { selectedAvailabilities = it }, + modifier = Modifier.fillMaxSize(), + gridSelectionType = GridSelectionType.AVAILABILITY, + onProposalSelected = {} + ) +} diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt index e9618213..f82a7c46 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt @@ -44,10 +44,12 @@ import com.cornellappdev.resell.android.R import com.cornellappdev.resell.android.ui.components.availability.helper.AvailabilityFilters import com.cornellappdev.resell.android.ui.components.availability.helper.GridSelectionType import com.cornellappdev.resell.android.ui.components.availability.helper.MonthCalendar +import com.cornellappdev.resell.android.ui.components.availability.helper.dayGroupContaining import com.cornellappdev.resell.android.ui.components.availability.helper.SelectableAvailabilityGrid import com.cornellappdev.resell.android.ui.components.global.ResellHeader import com.cornellappdev.resell.android.ui.components.global.ResellTextButton import com.cornellappdev.resell.android.ui.components.global.ResellTextButtonState +import com.cornellappdev.resell.android.ui.theme.AvailabilityPanelBackground import com.cornellappdev.resell.android.ui.theme.Style import com.cornellappdev.resell.android.viewmodel.main.AvailabilityViewModel import java.time.LocalDate @@ -73,6 +75,7 @@ fun AvailabilityScreen( uiState = availabilityUiState, onSetSelectedAvailabilities = { availabilityViewModel.setSelectedAvailabilities(it) }, onSetCurrentMonth = { availabilityViewModel.setCurrentMonth(it) }, + onSetVisibleDates = { availabilityViewModel.setVisibleDates(it) }, onSave = { availabilityViewModel.saveAvailability() }, ) } @@ -82,16 +85,14 @@ fun AvailabilityScreenContent( uiState: AvailabilityViewModel.AvailabilityUiState, onSetSelectedAvailabilities: (List) -> Unit, onSetCurrentMonth: (YearMonth) -> Unit, + onSetVisibleDates: (List) -> Unit, onSave: () -> Unit, ) { - val firstOfWeek = uiState.currentMonth.atDay(1) - val dates: List = (0..2).map { firstOfWeek.plusDays(it.toLong()) } - // just some UI logic to allow for smooth transitions between panels expanding on the screen. var activePanel by remember { mutableStateOf(AvailabilityPanel.NONE) } val panelVisible = activePanel != AvailabilityPanel.NONE - val panelBackgroundColor = Color(0xFFF7F3F9) + val panelBackgroundColor = AvailabilityPanelBackground var panelHeightPx by remember { mutableIntStateOf(0) } val density = LocalDensity.current val gridOffsetY by animateDpAsState( @@ -155,7 +156,7 @@ fun AvailabilityScreenContent( .clipToBounds() ) { SelectableAvailabilityGrid( - dates = dates, + dates = uiState.visibleDates, selectedAvailabilities = uiState.selectedAvailabilities, setSelectedAvailabilities = onSetSelectedAvailabilities, gridSelectionType = GridSelectionType.AVAILABILITY, @@ -190,8 +191,9 @@ fun AvailabilityScreenContent( when (activePanel) { AvailabilityPanel.CALENDAR -> MonthCalendar( currentMonth = uiState.currentMonth, - selectedDates = dates, + selectedDates = uiState.visibleDates, onMonthChange = onSetCurrentMonth, + onDaysSelected = onSetVisibleDates, modifier = Modifier.fillMaxWidth(), ) // AvailabilityPanel.FILTERS -> AvailabilityFilters( @@ -237,6 +239,7 @@ fun AvailabilityScreenPreview() { uiState = AvailabilityViewModel.AvailabilityUiState( selectedAvailabilities = emptyList(), currentMonth = YearMonth.of(2026, 4), + visibleDates = dayGroupContaining(LocalDate.of(2026, 4, 1)), googleCalendarEnabled = false, availabilitySharingEnabled = false, subCalendars = listOf("Personal", "Youtube", "Leetcode", "Capra"), @@ -247,6 +250,7 @@ fun AvailabilityScreenPreview() { ), onSetSelectedAvailabilities = {}, onSetCurrentMonth = {}, + onSetVisibleDates = {}, onSave = {}, ) } diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/theme/Color.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/theme/Color.kt index 5a8e4f9b..f1837185 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/theme/Color.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/theme/Color.kt @@ -33,6 +33,9 @@ val Warning = Color(0xFFF20000) val Venmo = Color(0xFF3D95CE) val Overlay = Color(0xEEEDEDED) +val AvailabilityPanelBackground = Color(0xFFF7F3F9) +val AvailabilitySelectedDate = Color(0xFFE4E0E8) + val LoginBlurBrushStart = Brush.radialGradient( colors = listOf( Color(0x338F00FF), diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt index ba9199cc..9cd708b8 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt @@ -3,9 +3,11 @@ package com.cornellappdev.resell.android.viewmodel.main import androidx.lifecycle.viewModelScope import com.cornellappdev.resell.android.model.profile.AvailabilityRepository import com.cornellappdev.resell.android.model.api.UserAvailability +import com.cornellappdev.resell.android.ui.components.availability.helper.dayGroupContaining import com.cornellappdev.resell.android.viewmodel.ResellViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import java.time.LocalDate import java.time.LocalDateTime import java.time.YearMonth import java.time.format.DateTimeFormatter @@ -21,6 +23,7 @@ class AvailabilityViewModel @Inject constructor( data class AvailabilityUiState( val selectedAvailabilities: List = emptyList(), val currentMonth: YearMonth = YearMonth.now(), + val visibleDates: List = dayGroupContaining(YearMonth.now().atDay(1)), // TODO: googleCalendarEnabled and availabilitySharingEnabled are not yet wired in. // Need to check how/where it is in the backend @@ -47,7 +50,11 @@ class AvailabilityViewModel @Inject constructor( } fun setCurrentMonth(month: YearMonth) { - applyMutation { copy(currentMonth = month) } + applyMutation { copy(currentMonth = month, visibleDates = dayGroupContaining(month.atDay(1))) } + } + + fun setVisibleDates(dates: List) { + applyMutation { copy(visibleDates = dates) } } fun setGoogleCalendarEnabled(enabled: Boolean) { From 1c3db003a4047b854778c8f97a7ce114325e0b0d Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Fri, 18 Sep 2026 15:30:14 -0400 Subject: [PATCH 07/11] fix: merge information rather than deleting availability information in AvailabilityViewModel.kt, and parse times correctly, making sure converting to/from UTC and local are not lost --- .../model/profile/AvailabilityRepository.kt | 14 ++++++++----- .../viewmodel/main/AvailabilityViewModel.kt | 21 +++++++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt index 41d0cd7d..d766acc9 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt @@ -5,7 +5,7 @@ import com.cornellappdev.resell.android.model.api.RetrofitInstance import com.cornellappdev.resell.android.model.api.UpdateAvailabilityRequest import com.cornellappdev.resell.android.model.api.UserAvailability import java.time.LocalDateTime -import java.time.format.DateTimeFormatter +import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -24,9 +24,8 @@ class AvailabilityRepository @Inject constructor( .mapValues { (_, daySlots) -> daySlots.sorted().map { start -> AvailabilitySlot( - startDate = start.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), - endDate = start.plusMinutes(30L) - .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) + startDate = start.toUtcInstantString(), + endDate = start.plusMinutes(30L).toUtcInstantString() ) } } @@ -35,4 +34,9 @@ class AvailabilityRepository @Inject constructor( UpdateAvailabilityRequest(schedule = schedule) ).availability } -} \ No newline at end of file +} + +// The backend stores/returns dates as UTC instants (e.g. "2026-01-23T16:00:00.000Z"), so +// device-local wall-clock times must be converted to an instant before sending. +private fun LocalDateTime.toUtcInstantString(): String = + atZone(ZoneId.systemDefault()).toInstant().toString() \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt index 9cd708b8..e95d8af7 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt @@ -7,10 +7,11 @@ import com.cornellappdev.resell.android.ui.components.availability.helper.dayGro import com.cornellappdev.resell.android.viewmodel.ResellViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime import java.time.YearMonth -import java.time.format.DateTimeFormatter +import java.time.ZoneId import javax.inject.Inject @HiltViewModel @@ -45,8 +46,17 @@ class AvailabilityViewModel @Inject constructor( // grid interactions - fun setSelectedAvailabilities(slots: List) { - applyMutation { copy(selectedAvailabilities = slots) } + /** + * The grid only shows and edits 3 days at a time, so [windowSlots] only contains slots for + * those days. Merge it in rather than overwriting [AvailabilityUiState.selectedAvailabilities] + * outright, or every other day's saved availability would be lost. + */ + fun setSelectedAvailabilities(windowSlots: List) { + applyMutation { + val visibleDateSet = visibleDates.toSet() + val outsideWindow = selectedAvailabilities.filterNot { it.toLocalDate() in visibleDateSet } + copy(selectedAvailabilities = outsideWindow + windowSlots) + } } fun setCurrentMonth(month: YearMonth) { @@ -109,9 +119,12 @@ class AvailabilityViewModel @Inject constructor( * Converts the backend schedule (Map>) back into * a flat list of LocalDateTimes for the grid to consume. * Each slot's startDate is used as the representative time for a cell. + * + * The backend sends startDate as a UTC instant (e.g. "2026-01-23T16:00:00.000Z"), so it's + * parsed as an [Instant] and converted to the device's local wall-clock time. */ private fun UserAvailability.toLocalDateTimes(): List { return schedule.values.flatten().map { slot -> - LocalDateTime.parse(slot.startDate, DateTimeFormatter.ISO_LOCAL_DATE_TIME) + Instant.parse(slot.startDate).atZone(ZoneId.systemDefault()).toLocalDateTime() } } \ No newline at end of file From 384a0ff59236e84e849c72975447a453c38a8ae6 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Fri, 18 Sep 2026 15:34:51 -0400 Subject: [PATCH 08/11] refactor: 30L is now in AvailabilityUtil.kt instead of appearing as magic number --- .../resell/android/model/profile/AvailabilityRepository.kt | 3 ++- .../ui/components/availability/helper/AvailabilityUtil.kt | 5 +++-- .../components/availability/helper/AvailibilityTestData.kt | 2 +- .../availability/helper/ViewOnlyAvailabilityGrid.kt | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt index d766acc9..1ccf827b 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/profile/AvailabilityRepository.kt @@ -4,6 +4,7 @@ import com.cornellappdev.resell.android.model.api.AvailabilitySlot import com.cornellappdev.resell.android.model.api.RetrofitInstance import com.cornellappdev.resell.android.model.api.UpdateAvailabilityRequest import com.cornellappdev.resell.android.model.api.UserAvailability +import com.cornellappdev.resell.android.ui.components.availability.helper.SLOT_DURATION_MINUTES import java.time.LocalDateTime import java.time.ZoneId import javax.inject.Inject @@ -25,7 +26,7 @@ class AvailabilityRepository @Inject constructor( daySlots.sorted().map { start -> AvailabilitySlot( startDate = start.toUtcInstantString(), - endDate = start.plusMinutes(30L).toUtcInstantString() + endDate = start.plusMinutes(SLOT_DURATION_MINUTES.toLong()).toUtcInstantString() ) } } diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt index b69930ca..b23be270 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt @@ -15,6 +15,7 @@ import java.time.YearMonth import kotlin.math.floor const val GRID_HEIGHT = 24 +const val SLOT_DURATION_MINUTES = 30 val gridStartTime: LocalTime = LocalTime.of(9, 0) val gridStroke = Stroke val fillColor = ResellPurple @@ -52,7 +53,7 @@ fun rowColToLocalDateTime(row: Int, col: Int, dates: List): LocalDate .withMinute(gridStartTime.minute) .withSecond(gridStartTime.second) .withNano(gridStartTime.nano) - .plusMinutes(30L * row) + .plusMinutes(SLOT_DURATION_MINUTES.toLong() * row) } fun getTimeForRow(row: Int): LocalTime { @@ -68,7 +69,7 @@ fun List.mapToGrid(dates: List): List { forEach { date -> val column = dates.indexOfFirst { it.day == date.day } if (column == -1) return@forEach - val row = (date.hour * 60 + date.minute - gridStartTime.hour * 60) / 30 + val row = (date.hour * 60 + date.minute - gridStartTime.hour * 60) / SLOT_DURATION_MINUTES if (row !in 0 until GRID_HEIGHT) return@forEach grid[row][column] = true } diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailibilityTestData.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailibilityTestData.kt index 4577877b..742b2246 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailibilityTestData.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailibilityTestData.kt @@ -16,7 +16,7 @@ fun testAvailabilities(dates: List): List = dates.flat val startDate = LocalDateTime.of(date, gridStartTime) repeat(GRID_HEIGHT - 1) { i -> if (Math.random() < 0.25) { - add(startDate.plusMinutes(i * 30L)) + add(startDate.plusMinutes(i * SLOT_DURATION_MINUTES.toLong())) } } } diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/ViewOnlyAvailabilityGrid.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/ViewOnlyAvailabilityGrid.kt index eccdc9ef..066e5dd8 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/ViewOnlyAvailabilityGrid.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/ViewOnlyAvailabilityGrid.kt @@ -65,7 +65,7 @@ fun ViewOnlyAvailabilityGrid( onSelectAvailability( LocalDateTime.of( dates[col], - gridStartTime.plusMinutes(30L * row) + gridStartTime.plusMinutes(SLOT_DURATION_MINUTES.toLong() * row) ) ) }) From e4a94fe4d5705443446413c0e982fc90b9195ad8 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Fri, 18 Sep 2026 15:49:19 -0400 Subject: [PATCH 09/11] feat: add swiping to change between months (mirrors iOS) --- .../availability/helper/AvailabilityUtil.kt | 5 ++++ .../availability/helper/MonthCalendar.kt | 27 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt index b23be270..e7279345 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt @@ -5,6 +5,8 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.cornellappdev.resell.android.ui.theme.ResellPurple import com.cornellappdev.resell.android.ui.theme.Stroke import com.cornellappdev.resell.android.util.day @@ -20,6 +22,9 @@ val gridStartTime: LocalTime = LocalTime.of(9, 0) val gridStroke = Stroke val fillColor = ResellPurple +/** Minimum horizontal drag distance before a swipe on [MonthCalendar] changes the month. */ +val MonthSwipeThreshold: Dp = 56.dp + /** Returns a fixed 3-day group containing [date] (1-3, 4-6, ...), rolling into next month if needed. */ fun dayGroupContaining(date: LocalDate): List { val month = YearMonth.from(date) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt index 3ea10f44..6e564dea 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt @@ -2,6 +2,7 @@ package com.cornellappdev.resell.android.ui.components.availability.helper import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,6 +18,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -47,9 +50,27 @@ fun MonthCalendar( val rangeStart = sortedDates.firstOrNull() val rangeEnd = sortedDates.lastOrNull() + val swipeThresholdPx = with(LocalDensity.current) { MonthSwipeThreshold.toPx() } + Column( modifier .background(color = AvailabilityPanelBackground) + .pointerInput(currentMonth) { + var totalDrag = 0f + detectHorizontalDragGestures( + onDragEnd = { + when { + totalDrag <= -swipeThresholdPx -> onMonthChange(currentMonth.plusMonths(1)) + totalDrag >= swipeThresholdPx -> onMonthChange(currentMonth.minusMonths(1)) + } + totalDrag = 0f + }, + onDragCancel = { totalDrag = 0f } + ) { change, dragAmount -> + change.consume() + totalDrag += dragAmount + } + } .padding(16.dp) ) { Row( @@ -114,17 +135,19 @@ fun MonthCalendar( } } +/** Interactive so left/right swipes to change month can be tested by hand. */ @Preview @Composable fun MonthCalendarPreview() { + var currentMonth by remember { mutableStateOf(YearMonth.of(2026, 4)) } MonthCalendar( - currentMonth = YearMonth.of(2026, 4), + currentMonth = currentMonth, selectedDates = listOf( LocalDate.of(2026, 4, 16), LocalDate.of(2026, 4, 17), LocalDate.of(2026, 4, 18), ), - onMonthChange = {}, + onMonthChange = { currentMonth = it }, onDaysSelected = {} ) } From 27b791a75115fcb10299be4b7d859b045f08ca72 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Fri, 18 Sep 2026 16:10:02 -0400 Subject: [PATCH 10/11] fix: cap the height of month calendar to be 5 rows, covering most cases. In the case of 6 rows, the calendar will be scrollable. --- .../availability/helper/AvailabilityUtil.kt | 8 ++ .../availability/helper/MonthCalendar.kt | 99 ++++++++++++------- 2 files changed, 69 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt index e7279345..def49133 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityUtil.kt @@ -25,6 +25,14 @@ val fillColor = ResellPurple /** Minimum horizontal drag distance before a swipe on [MonthCalendar] changes the month. */ val MonthSwipeThreshold: Dp = 56.dp +/** + * Caps [MonthCalendar]'s day-grid at the height of 5 rows (most common case). + * A 6-row month only occurs when the 1st falls on a Fri/Sat in a 30/31-day month, + * so in this case it scrolls internally instead of growing past this, so the + * header stays pinned and the surrounding panel never has to resize. + */ +val MonthCalendarGridMaxHeight: Dp = 248.dp + /** Returns a fixed 3-day group containing [date] (1-3, 4-6, ...), rolling into next month if needed. */ fun dayGroupContaining(date: LocalDate): List { val month = YearMonth.from(date) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt index 6e564dea..de8da60c 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt @@ -7,8 +7,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -46,9 +49,8 @@ fun MonthCalendar( val daysInMonth = currentMonth.lengthOfMonth() val dayLabels = listOf("Su", "Mo", "Tu", "We", "Th", "Fr", "Sa") - val sortedDates = selectedDates.sorted() - val rangeStart = sortedDates.firstOrNull() - val rangeEnd = sortedDates.lastOrNull() + val rangeStart = selectedDates.minOrNull() + val rangeEnd = selectedDates.maxOrNull() val swipeThresholdPx = with(LocalDensity.current) { MonthSwipeThreshold.toPx() } @@ -91,43 +93,52 @@ fun MonthCalendar( val totalCells = firstDayOfWeek + daysInMonth val rows = (totalCells + 6) / 7 - for (row in 0 until rows) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - for (col in 0..6) { - val cellIndex = row * 7 + col - val date = firstDayOfMonth.plusDays((cellIndex - firstDayOfWeek).toLong()) - val isInCurrentMonth = YearMonth.from(date) == currentMonth - val isSelected = date in selectedDates - val isRangeStart = date == rangeStart - val isRangeEnd = date == rangeEnd - val isMiddle = isSelected && !isRangeStart && !isRangeEnd + // Capped to the dominant 5-row case; a rare 6-row month scrolls here instead of + // growing past it, so the day-of-week header above stays pinned and the panel + // never has to resize. + Column( + modifier = Modifier + .heightIn(max = MonthCalendarGridMaxHeight) + .verticalScroll(rememberScrollState()) + ) { + for (row in 0 until rows) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + for (col in 0..6) { + val cellIndex = row * 7 + col + val date = firstDayOfMonth.plusDays((cellIndex - firstDayOfWeek).toLong()) + val isInCurrentMonth = YearMonth.from(date) == currentMonth + val isSelected = date in selectedDates + val isRangeStart = date == rangeStart + val isRangeEnd = date == rangeEnd + val isMiddle = isSelected && !isRangeStart && !isRangeEnd - Box( - modifier = Modifier - .weight(1f) - .background( - color = if (isSelected) AvailabilitySelectedDate else Color.Transparent, - shape = when { - isRangeStart && isRangeEnd -> RoundedCornerShape(4.dp) - isRangeStart -> RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp) - isRangeEnd -> RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp) - isMiddle -> RoundedCornerShape(0.dp) - else -> RoundedCornerShape(0.dp) - } + Box( + modifier = Modifier + .weight(1f) + .background( + color = if (isSelected) AvailabilitySelectedDate else Color.Transparent, + shape = when { + isRangeStart && isRangeEnd -> RoundedCornerShape(4.dp) + isRangeStart -> RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp) + isRangeEnd -> RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp) + isMiddle -> RoundedCornerShape(0.dp) + else -> RoundedCornerShape(0.dp) + } + ) + .clickable { onDaysSelected(dayGroupContaining(date)) } + .padding(6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "${date.dayOfMonth}", + style = Style.body2, + color = if (isInCurrentMonth) Color.Unspecified else IconInactive ) - .clickable { onDaysSelected(dayGroupContaining(date)) } - .padding(6.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "${date.dayOfMonth}", - style = Style.body2, - color = if (isInCurrentMonth) Color.Unspecified else IconInactive - ) + } } } } @@ -166,4 +177,16 @@ fun MonthCalendarThirtyOneDayMonthPreview() { onMonthChange = {}, onDaysSelected = { selectedDates = it } ) +} + +/** Showcase the 6-row edge case: should be scrollable. */ +@Preview +@Composable +fun MonthCalendarSixRowMonthPreview() { + MonthCalendar( + currentMonth = YearMonth.of(2026, 5), + selectedDates = emptyList(), + onMonthChange = {}, + onDaysSelected = {} + ) } \ No newline at end of file From efea299325304abe16368649abb8b60b21631397 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Fri, 18 Sep 2026 16:30:38 -0400 Subject: [PATCH 11/11] fix: address misc comments --- .../availability/helper/MonthCalendar.kt | 37 ++++++++++++++----- .../ui/screens/main/AvailabilityScreen.kt | 9 +++-- .../viewmodel/main/AvailabilityViewModel.kt | 16 ++++---- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt index de8da60c..74d8df74 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/MonthCalendar.kt @@ -49,9 +49,6 @@ fun MonthCalendar( val daysInMonth = currentMonth.lengthOfMonth() val dayLabels = listOf("Su", "Mo", "Tu", "We", "Th", "Fr", "Sa") - val rangeStart = selectedDates.minOrNull() - val rangeEnd = selectedDates.maxOrNull() - val swipeThresholdPx = with(LocalDensity.current) { MonthSwipeThreshold.toPx() } Column( @@ -112,9 +109,13 @@ fun MonthCalendar( val date = firstDayOfMonth.plusDays((cellIndex - firstDayOfWeek).toLong()) val isInCurrentMonth = YearMonth.from(date) == currentMonth val isSelected = date in selectedDates - val isRangeStart = date == rangeStart - val isRangeEnd = date == rangeEnd - val isMiddle = isSelected && !isRangeStart && !isRangeEnd + // Rounding is per-row: a selected run can span multiple weeks or have + // gaps, so "start"/"end" must mean the edges of the run *in this row*, + // not the overall min/max of the whole selection. + val hasSelectedLeftNeighbor = col > 0 && date.minusDays(1) in selectedDates + val hasSelectedRightNeighbor = col < 6 && date.plusDays(1) in selectedDates + val isRowStart = isSelected && !hasSelectedLeftNeighbor + val isRowEnd = isSelected && !hasSelectedRightNeighbor Box( modifier = Modifier @@ -122,10 +123,9 @@ fun MonthCalendar( .background( color = if (isSelected) AvailabilitySelectedDate else Color.Transparent, shape = when { - isRangeStart && isRangeEnd -> RoundedCornerShape(4.dp) - isRangeStart -> RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp) - isRangeEnd -> RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp) - isMiddle -> RoundedCornerShape(0.dp) + isRowStart && isRowEnd -> RoundedCornerShape(4.dp) + isRowStart -> RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp) + isRowEnd -> RoundedCornerShape(topEnd = 4.dp, bottomEnd = 4.dp) else -> RoundedCornerShape(0.dp) } ) @@ -189,4 +189,21 @@ fun MonthCalendarSixRowMonthPreview() { onMonthChange = {}, onDaysSelected = {} ) +} + +/** + * A 3-day group of Fri/Sat/Sun spans two calendar rows. + * Friday and Sunday should each round on the outer edge of their own row (not backwards), + * and Saturday, last in its row, with nothing selected after it, should round its right + * edge too, instead of being treated as a seamless "middle" cell. + */ +@Preview +@Composable +fun MonthCalendarRowSpanningSelectionPreview() { + MonthCalendar( + currentMonth = YearMonth.of(2026, 5), + selectedDates = dayGroupContaining(LocalDate.of(2026, 5, 1)), + onMonthChange = {}, + onDaysSelected = {} + ) } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt index f82a7c46..d6481e17 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/main/AvailabilityScreen.kt @@ -73,7 +73,7 @@ fun AvailabilityScreen( AvailabilityScreenContent( uiState = availabilityUiState, - onSetSelectedAvailabilities = { availabilityViewModel.setSelectedAvailabilities(it) }, + onSetSelectedAvailabilities = { availabilityViewModel.setSelectedAvailabilities(it.toSet()) }, onSetCurrentMonth = { availabilityViewModel.setCurrentMonth(it) }, onSetVisibleDates = { availabilityViewModel.setVisibleDates(it) }, onSave = { availabilityViewModel.saveAvailability() }, @@ -157,7 +157,7 @@ fun AvailabilityScreenContent( ) { SelectableAvailabilityGrid( dates = uiState.visibleDates, - selectedAvailabilities = uiState.selectedAvailabilities, + selectedAvailabilities = uiState.selectedAvailabilities.toList(), setSelectedAvailabilities = onSetSelectedAvailabilities, gridSelectionType = GridSelectionType.AVAILABILITY, modifier = Modifier @@ -167,6 +167,7 @@ fun AvailabilityScreenContent( onProposalSelected = {} ) + // This explicit call is necessary because of strange scope issues androidx.compose.animation.AnimatedVisibility( visible = panelVisible, modifier = Modifier @@ -223,7 +224,7 @@ fun AvailabilityScreenContent( ResellTextButton( text = "Save", onClick = onSave, - state = ResellTextButtonState.ENABLED, + state = if (uiState.isLoading) ResellTextButtonState.SPINNING else ResellTextButtonState.ENABLED, modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = 24.dp) @@ -237,7 +238,7 @@ fun AvailabilityScreenContent( fun AvailabilityScreenPreview() { AvailabilityScreenContent( uiState = AvailabilityViewModel.AvailabilityUiState( - selectedAvailabilities = emptyList(), + selectedAvailabilities = emptySet(), currentMonth = YearMonth.of(2026, 4), visibleDates = dayGroupContaining(LocalDate.of(2026, 4, 1)), googleCalendarEnabled = false, diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt index e95d8af7..49962439 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/AvailabilityViewModel.kt @@ -22,7 +22,7 @@ class AvailabilityViewModel @Inject constructor( ) { data class AvailabilityUiState( - val selectedAvailabilities: List = emptyList(), + val selectedAvailabilities: Set = emptySet(), val currentMonth: YearMonth = YearMonth.now(), val visibleDates: List = dayGroupContaining(YearMonth.now().atDay(1)), @@ -51,11 +51,11 @@ class AvailabilityViewModel @Inject constructor( * those days. Merge it in rather than overwriting [AvailabilityUiState.selectedAvailabilities] * outright, or every other day's saved availability would be lost. */ - fun setSelectedAvailabilities(windowSlots: List) { + fun setSelectedAvailabilities(windowSlots: Set) { applyMutation { val visibleDateSet = visibleDates.toSet() val outsideWindow = selectedAvailabilities.filterNot { it.toLocalDate() in visibleDateSet } - copy(selectedAvailabilities = outsideWindow + windowSlots) + copy(selectedAvailabilities = outsideWindow.toSet() + windowSlots) } } @@ -86,7 +86,7 @@ class AvailabilityViewModel @Inject constructor( private fun loadAvailability() { viewModelScope.launch { - applyMutation { copy(isLoading = true) } + applyMutation { copy(isLoading = true, errorMessage = null) } try { val availability = availabilityRepository.getMyAvailability() applyMutation { @@ -104,9 +104,9 @@ class AvailabilityViewModel @Inject constructor( fun saveAvailability() { viewModelScope.launch { - applyMutation { copy(isLoading = true, saveSuccess = false) } + applyMutation { copy(isLoading = true, saveSuccess = false, errorMessage = null) } try { - availabilityRepository.updateAvailability(stateValue().selectedAvailabilities) + availabilityRepository.updateAvailability(stateValue().selectedAvailabilities.toList()) applyMutation { copy(isLoading = false, saveSuccess = true, errorMessage = null) } } catch (e: Exception) { applyMutation { copy(isLoading = false, errorMessage = e.message) } @@ -123,8 +123,8 @@ class AvailabilityViewModel @Inject constructor( * The backend sends startDate as a UTC instant (e.g. "2026-01-23T16:00:00.000Z"), so it's * parsed as an [Instant] and converted to the device's local wall-clock time. */ -private fun UserAvailability.toLocalDateTimes(): List { +private fun UserAvailability.toLocalDateTimes(): Set { return schedule.values.flatten().map { slot -> Instant.parse(slot.startDate).atZone(ZoneId.systemDefault()).toLocalDateTime() - } + }.toSet() } \ No newline at end of file