From 6fc17603205f8e3ae31ef20a1b7e072caf802f2a Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Wed, 9 Sep 2026 01:44:56 -0400 Subject: [PATCH 1/6] fix: address coderabbit comment, reenable button on login screen for some edge cases --- .../resell/android/viewmodel/onboarding/LandingViewModel.kt | 3 +++ 1 file changed, 3 insertions(+) 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 811b008..0ab2722 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 @@ -174,6 +174,9 @@ class LandingViewModel @Inject constructor( } catch (e: Exception) { if (e is HttpException && e.code() == 403) { Log.d("LandingViewModel", "User not found on backend; routing to onboarding.") + applyMutation { + copy(buttonState = ResellTextButtonState.ENABLED) + } rootNavigationRepository.navigate(ResellRootRoute.ONBOARDING) return@launch } From bf661943314567075f83a1276961dd60ff22c6e7 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Fri, 18 Sep 2026 22:52:00 -0400 Subject: [PATCH 2/6] fix: match iOS with how proposals work, start at the very beginning with the proposal timing. --- .../ui/screens/main/AvailabilityScreen.kt | 4 ++ .../android/ui/screens/root/RootNavigation.kt | 8 +++ .../viewmodel/main/AvailabilityViewModel.kt | 8 ++- .../android/viewmodel/main/ChatViewModel.kt | 54 +++++-------------- .../viewmodel/main/ProfileViewModel.kt | 15 +----- 5 files changed, 34 insertions(+), 55 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 d6481e1..1900eb5 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 @@ -77,6 +77,7 @@ fun AvailabilityScreen( onSetCurrentMonth = { availabilityViewModel.setCurrentMonth(it) }, onSetVisibleDates = { availabilityViewModel.setVisibleDates(it) }, onSave = { availabilityViewModel.saveAvailability() }, + onBackPressed = { availabilityViewModel.onBackPressed() }, ) } @@ -87,6 +88,7 @@ fun AvailabilityScreenContent( onSetCurrentMonth: (YearMonth) -> Unit, onSetVisibleDates: (List) -> Unit, onSave: () -> Unit, + onBackPressed: () -> Unit = {}, ) { // just some UI logic to allow for smooth transitions between panels expanding on the screen. var activePanel by remember { mutableStateOf(AvailabilityPanel.NONE) } @@ -110,6 +112,7 @@ fun AvailabilityScreenContent( ResellHeader( title = "Availability", leftPainter = R.drawable.ic_chevron_left, + onLeftClick = onBackPressed, ) Column( modifier = Modifier @@ -253,5 +256,6 @@ fun AvailabilityScreenPreview() { onSetCurrentMonth = {}, onSetVisibleDates = {}, onSave = {}, + onBackPressed = {}, ) } diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/root/RootNavigation.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/root/RootNavigation.kt index 6907644..72c9850 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/screens/root/RootNavigation.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/screens/root/RootNavigation.kt @@ -25,6 +25,7 @@ import com.cornellappdev.resell.android.ui.components.submitted.ConfettiOverlay import com.cornellappdev.resell.android.ui.screens.externalprofile.ExternalProfileNavigation import com.cornellappdev.resell.android.ui.screens.feedback.FeedbackNavigation import com.cornellappdev.resell.android.ui.screens.main.AllSearchScreen +import com.cornellappdev.resell.android.ui.screens.main.AvailabilityScreen import com.cornellappdev.resell.android.ui.screens.main.ChatScreen import com.cornellappdev.resell.android.ui.screens.main.MainTabNavigation import com.cornellappdev.resell.android.ui.screens.main.NotificationsHubScreen @@ -179,6 +180,10 @@ fun RootNavigation( composable { NotificationsHubScreen() } + + composable { + AvailabilityScreen() + } } RootConfirmationOverlay() @@ -281,4 +286,7 @@ sealed class ResellRootRoute { @Serializable data object NOTIFS : ResellRootRoute() + + @Serializable + data object AVAILABILITY : ResellRootRoute() } \ 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 4996243..b3c1c44 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 @@ -5,6 +5,7 @@ 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 com.cornellappdev.resell.android.viewmodel.navigation.RootNavigationRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import java.time.Instant @@ -16,7 +17,8 @@ import javax.inject.Inject @HiltViewModel class AvailabilityViewModel @Inject constructor( - private val availabilityRepository: AvailabilityRepository + private val availabilityRepository: AvailabilityRepository, + private val rootNavigationRepository: RootNavigationRepository ) : ResellViewModel( initialUiState = AvailabilityUiState() ) { @@ -44,6 +46,10 @@ class AvailabilityViewModel @Inject constructor( loadAvailability() } + fun onBackPressed() { + rootNavigationRepository.popBackStack() + } + // grid interactions /** diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt index 71fb8ae..4f3d4a1 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt @@ -23,7 +23,6 @@ import com.cornellappdev.resell.android.model.Chat import com.cornellappdev.resell.android.model.ChatMessageData import com.cornellappdev.resell.android.model.api.ChatRepository import com.cornellappdev.resell.android.model.api.Post -import com.cornellappdev.resell.android.model.chats.AvailabilityBlock import com.cornellappdev.resell.android.model.chats.AvailabilityDocument import com.cornellappdev.resell.android.model.chats.MeetingInfo import com.cornellappdev.resell.android.model.chats.TransactionInfo @@ -41,7 +40,6 @@ import com.cornellappdev.resell.android.ui.screens.root.ResellRootRoute import com.cornellappdev.resell.android.ui.theme.Style import com.cornellappdev.resell.android.ui.theme.Style.heading3 import com.cornellappdev.resell.android.util.UIEvent -import com.cornellappdev.resell.android.util.convertToFirestoreTimestamp import com.cornellappdev.resell.android.util.loadBitmapFromUri import com.cornellappdev.resell.android.util.toNetworkingString import com.cornellappdev.resell.android.viewmodel.ResellViewModel @@ -247,51 +245,27 @@ class ChatViewModel @Inject constructor( } fun onSendAvailabilityPressed() { + val canPropose = mostRecentMeetingStateIs("confirmed") == null + rootNavigationSheetRepository.showBottomSheet( sheet = RootSheet.Availability( title = "When are you free to meet?", - buttonString = "Continue", - description = "Drag across the grid to add/remove availability", - callback = ::availabilityCallback, - gridSelectionType = GridSelectionType.AVAILABILITY + buttonString = "Propose", + description = "Select a 30-minute block to propose a meeting", + callback = { + if (canPropose && it.isNotEmpty()) { + onMeetingProposal(it.first()) + } else { + rootConfirmationRepository.showError( + "Please select a 30-minute block to propose a meeting, and ensure there is no current meeting." + ) + } + }, + gridSelectionType = if (canPropose) GridSelectionType.PROPOSAL else GridSelectionType.NONE ) ) } - private fun availabilityCallback(availability: List) { - viewModelScope.launch { - try { - val myInfo = userInfoRepository.getUserInfo() - - val asTimeStamp = availability.map { - it.convertToFirestoreTimestamp() - } - - chatRepository.sendAvailability( - selfIsBuyer = navArgs.isBuyer, - listingId = listing.id, - myId = myInfo.id, - otherId = navArgs.otherUserId, - availability = AvailabilityDocument( - asTimeStamp.mapIndexed { index, it -> - AvailabilityBlock( - startDate = it, - id = index - ) - } - ), - chatId = navArgs.chatId - ) - rootNavigationSheetRepository.hideSheet() - } catch (e: Exception) { - Log.e("ChatViewModel", "Error sending availability: ", e) - rootConfirmationRepository.showError( - "Something went wrong while sending your availability. Please try again later." - ) - } - } - } - fun payWithVenmoPressed() = viewModelScope.launch { try { val theirVenmo = navArgs.otherVenmo diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ProfileViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ProfileViewModel.kt index ce0ebf1..e8da8d6 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ProfileViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ProfileViewModel.kt @@ -10,7 +10,6 @@ import com.cornellappdev.resell.android.model.core.UserInfoRepository import com.cornellappdev.resell.android.model.login.GoogleAuthRepository import com.cornellappdev.resell.android.model.profile.ProfileRepository import com.cornellappdev.resell.android.model.settings.BlockedUsersRepository -import com.cornellappdev.resell.android.ui.components.availability.helper.GridSelectionType import com.cornellappdev.resell.android.ui.components.global.ResellTextButtonContainer import com.cornellappdev.resell.android.ui.components.global.ResellTextButtonState import com.cornellappdev.resell.android.ui.screens.root.ResellRootRoute @@ -19,9 +18,7 @@ import com.cornellappdev.resell.android.viewmodel.navigation.RootNavigationRepos import com.cornellappdev.resell.android.viewmodel.root.RootConfirmationRepository import com.cornellappdev.resell.android.viewmodel.root.RootDialogContent import com.cornellappdev.resell.android.viewmodel.root.RootDialogRepository -import com.cornellappdev.resell.android.viewmodel.root.RootNavigationSheetRepository import com.cornellappdev.resell.android.viewmodel.root.RootOptionsMenuRepository -import com.cornellappdev.resell.android.viewmodel.root.RootSheet import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString @@ -35,7 +32,6 @@ class ProfileViewModel @Inject constructor( private val rootConfirmationRepository: RootConfirmationRepository, private val profileRepository: ProfileRepository, private val userInfoRepository: UserInfoRepository, - private val rootNavigationSheetRepository: RootNavigationSheetRepository // add this ): ResellViewModel( initialUiState = ProfileUiState( profileTab = ProfileTab.SHOP, @@ -91,16 +87,7 @@ class ProfileViewModel @Inject constructor( rootNavigationRepository.navigate(ResellRootRoute.SETTINGS) } fun onCalendarPressed() { - rootNavigationSheetRepository.showBottomSheet( - RootSheet.Availability( - buttonString = "Propose", - title = "Availability", - description = "Propose a time to meet", - initialTimes = listOf(), - gridSelectionType = GridSelectionType.PROPOSAL, - callback = { /* TODO: handle selected times */ } - ) - ) + rootNavigationRepository.navigate(ResellRootRoute.AVAILABILITY) } fun onRequestPressed(request: RequestListing) { From 04da618d8e8606247a1fef70291a6475132e981e Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Sat, 19 Sep 2026 00:20:37 -0400 Subject: [PATCH 3/6] fix: match iOS with greyed out availability proposal bottom sheet for mutually busy schedules, and start at the current day instead of at the first of the month for availability screen --- .../model/api/AvailabilityApiService.kt | 4 ++ .../model/profile/AvailabilityRepository.kt | 19 ++++++- .../availability/AvailabilitySheet.kt | 2 + .../AvailabilitySheetViewModel.kt | 8 ++- .../SelectableAvailabilityPager.kt | 4 ++ .../helper/AvailabilityPagerContainer.kt | 27 +++++++-- .../availability/helper/AvailabilityUtil.kt | 22 ++++++++ .../helper/SelectableAvailabilityGrid.kt | 14 +++++ .../viewmodel/main/AvailabilityViewModel.kt | 21 +------ .../android/viewmodel/main/ChatViewModel.kt | 56 ++++++++++++++----- .../viewmodel/root/RootSheetRepository.kt | 10 +++- 11 files changed, 142 insertions(+), 45 deletions(-) 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 index 632d9cb..75f7d18 100644 --- 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 @@ -3,12 +3,16 @@ package com.cornellappdev.resell.android.model.api import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST +import retrofit2.http.Path interface AvailabilityApiService { @GET("availability/") suspend fun getMyAvailability(): AvailabilityResponse + @GET("availability/user/{userId}") + suspend fun getUserAvailability(@Path("userId") userId: String): AvailabilityResponse + @POST("availability/update/") suspend fun updateAvailability( @Body request: UpdateAvailabilityRequest 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 1ccf827..11602df 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,6 +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 com.cornellappdev.resell.android.ui.components.availability.helper.SLOT_DURATION_MINUTES +import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import javax.inject.Inject @@ -14,8 +15,12 @@ import javax.inject.Singleton class AvailabilityRepository @Inject constructor( private val retrofitInstance: RetrofitInstance ) { - suspend fun getMyAvailability(): UserAvailability { - return retrofitInstance.availabilityApi.getMyAvailability().availability + suspend fun getMyAvailability(): Set { + return retrofitInstance.availabilityApi.getMyAvailability().availability.toLocalDateTimes() + } + + suspend fun getUserAvailability(userId: String): Set { + return retrofitInstance.availabilityApi.getUserAvailability(userId).availability.toLocalDateTimes() } suspend fun updateAvailability(slots: List): UserAvailability { @@ -40,4 +45,12 @@ class AvailabilityRepository @Inject constructor( // 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 + atZone(ZoneId.systemDefault()).toInstant().toString() + +// 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(): Set { + return schedule.values.flatten().map { slot -> + Instant.parse(slot.startDate).atZone(ZoneId.systemDefault()).toLocalDateTime() + }.toSet() +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/AvailabilitySheet.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/AvailabilitySheet.kt index ec634d3..a07174c 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/AvailabilitySheet.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/AvailabilitySheet.kt @@ -28,6 +28,8 @@ fun AvailabilitySheet( title = uiState.title, subtitle = uiState.subtitle, gridSelectionType = uiState.gridSelectionType, + availableAvailabilities = uiState.overlapTimes, + onEditAvailabilityClicked = uiState.onEditAvailability, setProposalTime = availabilitySheetViewModel::setProposalTime ) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/AvailabilitySheetViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/AvailabilitySheetViewModel.kt index d05bb76..ccd071a 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/AvailabilitySheetViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/AvailabilitySheetViewModel.kt @@ -37,7 +37,9 @@ class AvailabilitySheetViewModel @Inject constructor( val initialAvailabilities: List, val textButtonState: ResellTextButtonState = ResellTextButtonState.ENABLED, val gridSelectionType: GridSelectionType, - val proposedTime: LocalDateTime? = null + val proposedTime: LocalDateTime? = null, + val overlapTimes: List? = null, + val onEditAvailability: (() -> Unit)? = null, ) fun onAvailabilityChanged(availability: List) { @@ -80,7 +82,9 @@ class AvailabilitySheetViewModel @Inject constructor( callback = uiEvent.payload.callback, initialAvailabilities = uiEvent.payload.initialTimes, textButtonState = uiEvent.payload.initialButtonState, - gridSelectionType = uiEvent.payload.gridSelectionType + gridSelectionType = uiEvent.payload.gridSelectionType, + overlapTimes = uiEvent.payload.overlapTimes, + onEditAvailability = uiEvent.payload.onEditAvailability ) } } diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/SelectableAvailabilityPager.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/SelectableAvailabilityPager.kt index 8c338ba..115cce1 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/SelectableAvailabilityPager.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/SelectableAvailabilityPager.kt @@ -23,9 +23,11 @@ fun SelectableAvailabilityPager( title: String, subtitle: String, initialSelectedAvailabilities: List = emptyList(), + availableAvailabilities: List? = null, scrollRange: Pair = 0 to 6, modifier: Modifier = Modifier, gridSelectionType: GridSelectionType, + onEditAvailabilityClicked: (() -> Unit)? = null, setProposalTime: (LocalDateTime) -> Unit, setSelectedAvailabilities: (List) -> Unit, ) { @@ -65,6 +67,7 @@ fun SelectableAvailabilityPager( modifier = modifier, title = title, subtitle = subtitle, + onEditAvailabilityClicked = onEditAvailabilityClicked, ) { dates, page -> SelectableAvailabilityGrid( dates = dates, @@ -81,6 +84,7 @@ fun SelectableAvailabilityPager( setSelectedAvailabilities(updatedDates.values.flatten()) }, gridSelectionType = gridSelectionType, + availableAvailabilities = availableAvailabilities, onProposalSelected = setProposalTime ) } diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityPagerContainer.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityPagerContainer.kt index 5bc0175..205c376 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityPagerContainer.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityPagerContainer.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.cornellappdev.resell.android.R +import com.cornellappdev.resell.android.ui.theme.ResellPurple import com.cornellappdev.resell.android.ui.theme.Secondary import com.cornellappdev.resell.android.ui.theme.Style import com.cornellappdev.resell.android.util.clickableNoIndication @@ -45,6 +46,7 @@ fun AvailabilityPagerContainer( startDate: LocalDate, scrollRange: Pair, modifier: Modifier = Modifier, + onEditAvailabilityClicked: (() -> Unit)? = null, availabilityGrid: @Composable (dates: List, page: Int) -> Unit, ) { val state = @@ -85,11 +87,26 @@ fun AvailabilityPagerContainer( horizontalAlignment = Alignment.CenterHorizontally ) { Text(text = title, style = Style.heading3) - Text( - text = subtitle, - style = Style.body2, - color = Secondary - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = subtitle, + style = Style.body2, + color = Secondary + ) + if (onEditAvailabilityClicked != null) { + Text( + text = " | ", + style = Style.body2, + color = Secondary + ) + Text( + text = "Edit Availability", + style = Style.body2, + color = ResellPurple, + modifier = Modifier.clickableNoIndication { onEditAvailabilityClicked() } + ) + } + } } Icon( painter = painterResource(R.drawable.ic_chevron_right), 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 def4913..5edf94c 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 @@ -9,6 +9,7 @@ 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.ui.theme.Wash import com.cornellappdev.resell.android.util.day import java.time.LocalDate import java.time.LocalDateTime @@ -89,6 +90,27 @@ fun List.mapToGrid(dates: List): List { return grid } +/** + * Greys out every cell where [unavailableGrid] is true, so only cells left white/normal + * represent times available to both parties. + */ +fun DrawScope.drawUnavailableCells(unavailableGrid: List, rectWidth: Float, rectHeight: Float) { + for (row in unavailableGrid.indices) { + for (col in unavailableGrid[row].indices) { + if (unavailableGrid[row][col]) { + val position = Offset(rectWidth * col, rectHeight * row) + + drawRect( + size = Size(rectWidth, rectHeight), + topLeft = position, + color = Wash, + style = Fill + ) + } + } + } +} + fun DrawScope.drawBorder(grid: List, rectWidth: Float, rectHeight: Float) { for (row in grid.indices.filter { it % 2 == 0 }) { for (col in grid[row].indices) { 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 ca941d2..603cd29 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 @@ -44,6 +44,7 @@ private fun SelectableGrid( updateGrid: ((List) -> List) -> Unit, gridSelectionType: GridSelectionType, modifier: Modifier = Modifier, + unavailableGrid: List? = null, onProposalSelected: (Pair) -> Unit ) { var isRemoving by remember { mutableStateOf(false) } @@ -160,6 +161,9 @@ private fun SelectableGrid( * in. */ + // Grey out cells not available to both parties, before the border/selection layers. + unavailableGrid?.let { drawUnavailableCells(it, rectWidth, rectHeight) } + // Draw border drawBorder(grid, rectWidth, rectHeight) @@ -216,9 +220,18 @@ fun SelectableAvailabilityGrid( setSelectedAvailabilities: (List) -> Unit, gridSelectionType: GridSelectionType, modifier: Modifier = Modifier, + /** + * When non-null, cells NOT in this list are greyed out — e.g. the intersection of two + * people's saved availability, so only times that work for both are shown as normal/white. + * Null means the greying feature isn't used for this grid. + */ + availableAvailabilities: List? = null, onProposalSelected: (LocalDateTime) -> Unit, ) { val grid = selectedAvailabilities.mapToGrid(dates) + val unavailableGrid = availableAvailabilities?.mapToGrid(dates)?.map { row -> + BooleanArray(row.size) { col -> !row[col] } + } AvailabilityGridContainer(dates, modifier) { SelectableGrid( @@ -228,6 +241,7 @@ fun SelectableAvailabilityGrid( setSelectedAvailabilities(newGrid.toAvailabilities(dates)) }, gridSelectionType = gridSelectionType, + unavailableGrid = unavailableGrid, onProposalSelected = { val (row, col) = it onProposalSelected(rowColToLocalDateTime(row, col, dates)) 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 b3c1c44..00b7b47 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 @@ -2,17 +2,14 @@ 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 com.cornellappdev.resell.android.viewmodel.navigation.RootNavigationRepository 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.ZoneId import javax.inject.Inject @HiltViewModel @@ -26,7 +23,7 @@ class AvailabilityViewModel @Inject constructor( data class AvailabilityUiState( val selectedAvailabilities: Set = emptySet(), val currentMonth: YearMonth = YearMonth.now(), - val visibleDates: List = dayGroupContaining(YearMonth.now().atDay(1)), + val visibleDates: List = dayGroupContaining(LocalDate.now()), // TODO: googleCalendarEnabled and availabilitySharingEnabled are not yet wired in. // Need to check how/where it is in the backend @@ -97,7 +94,7 @@ class AvailabilityViewModel @Inject constructor( val availability = availabilityRepository.getMyAvailability() applyMutation { copy( - selectedAvailabilities = availability.toLocalDateTimes(), + selectedAvailabilities = availability, isLoading = false, errorMessage = null ) @@ -119,18 +116,4 @@ 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(): Set { - return schedule.values.flatten().map { slot -> - Instant.parse(slot.startDate).atZone(ZoneId.systemDefault()).toLocalDateTime() - }.toSet() } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt index 4f3d4a1..2cc8cda 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt @@ -32,6 +32,7 @@ import com.cornellappdev.resell.android.model.classes.ResellApiResponse import com.cornellappdev.resell.android.model.core.UserInfoRepository import com.cornellappdev.resell.android.model.login.FireStoreRepository import com.cornellappdev.resell.android.model.login.FirebaseMessagingRepository +import com.cornellappdev.resell.android.model.profile.AvailabilityRepository import com.cornellappdev.resell.android.model.ptf.PostTransactionRatingRepository import com.cornellappdev.resell.android.ui.components.availability.helper.GridSelectionType import com.cornellappdev.resell.android.ui.components.global.ResellTextButtonContainer @@ -72,6 +73,7 @@ class ChatViewModel @Inject constructor( private val rootDialogRepository: RootDialogRepository, private val rootNavigationRepository: RootNavigationRepository, private val postTransactionRatingRepository: PostTransactionRatingRepository, + private val availabilityRepository: AvailabilityRepository, @ApplicationContext private val context: Context ) : ResellViewModel( @@ -247,23 +249,47 @@ class ChatViewModel @Inject constructor( fun onSendAvailabilityPressed() { val canPropose = mostRecentMeetingStateIs("confirmed") == null - rootNavigationSheetRepository.showBottomSheet( - sheet = RootSheet.Availability( - title = "When are you free to meet?", - buttonString = "Propose", - description = "Select a 30-minute block to propose a meeting", - callback = { - if (canPropose && it.isNotEmpty()) { - onMeetingProposal(it.first()) - } else { - rootConfirmationRepository.showError( - "Please select a 30-minute block to propose a meeting, and ensure there is no current meeting." - ) + viewModelScope.launch { + // Grey out everything but the overlap between both people's saved availability, so + // the proposer only sees times that could actually work for both of them. If either + // side hasn't saved availability (or the fetch fails), fall back to an ungreyed grid. + val overlapTimes = try { + val myId = userInfoRepository.getUserId() + if (myId == null) { + null + } else { + val mine = availabilityRepository.getMyAvailability() + val theirs = availabilityRepository.getUserAvailability(navArgs.otherUserId) + mine.intersect(theirs).toList() + } + } catch (e: Exception) { + Log.e("ChatViewModel", "Error loading combined availability: ", e) + null + } + + rootNavigationSheetRepository.showBottomSheet( + sheet = RootSheet.Availability( + title = "When are you free to meet?", + buttonString = "Propose", + description = "Select a 30 minute block", + callback = { + if (canPropose && it.isNotEmpty()) { + onMeetingProposal(it.first()) + } else { + rootConfirmationRepository.showError( + "Please select a 30-minute block to propose a meeting, and ensure there is no current meeting." + ) + } + }, + gridSelectionType = if (canPropose) GridSelectionType.PROPOSAL else GridSelectionType.NONE, + overlapTimes = overlapTimes, + onEditAvailability = { + rootNavigationSheetRepository.hideSheet() + rootNavigationRepository.navigate(ResellRootRoute.AVAILABILITY) } - }, - gridSelectionType = if (canPropose) GridSelectionType.PROPOSAL else GridSelectionType.NONE + ) ) - ) + } } fun payWithVenmoPressed() = viewModelScope.launch { diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/root/RootSheetRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/root/RootSheetRepository.kt index b9b591d..75529b8 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/root/RootSheetRepository.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/root/RootSheetRepository.kt @@ -72,7 +72,15 @@ sealed class RootSheet { val title: String, val description: String, val callback: (List) -> Unit, - val gridSelectionType: GridSelectionType + val gridSelectionType: GridSelectionType, + /** + * When non-null, cells NOT in this list are greyed out on the grid — e.g. the + * intersection of both chat participants' saved availability, so only times that + * work for both show as normal/white. Null means this feature isn't used. + */ + val overlapTimes: List? = null, + /** When non-null, shows an "Edit Availability" link next to the description. */ + val onEditAvailability: (() -> Unit)? = null, ) : RootSheet() data class MeetingCancel( From e0a5e2ff63936a178c657401cdce6fbf2f6912ea Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Sat, 19 Sep 2026 22:09:53 -0400 Subject: [PATCH 4/6] fix: disable propose button until a time in the grid is actually selected --- .../cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt index 2cc8cda..6d7add7 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt @@ -272,6 +272,7 @@ class ChatViewModel @Inject constructor( title = "When are you free to meet?", buttonString = "Propose", description = "Select a 30 minute block", + initialButtonState = ResellTextButtonState.DISABLED, callback = { if (canPropose && it.isNotEmpty()) { onMeetingProposal(it.first()) From 99be4023ca56fdafc620dc9d27006aedb8d3ecbb Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Fri, 25 Sep 2026 16:30:35 -0400 Subject: [PATCH 5/6] fix: edit availability starts out with the left-most column being the current day, instead of keeping hard capped 3-day windows that were pre-filled. --- .../SelectableAvailabilityPager.kt | 15 +++--- .../helper/AvailabilityPagerContainer.kt | 9 +--- .../availability/helper/AvailabilityUtil.kt | 18 ++++--- .../availability/helper/MonthCalendar.kt | 54 +++++++++++-------- .../helper/SelectableAvailabilityGrid.kt | 2 +- .../ui/screens/main/AvailabilityScreen.kt | 12 ++--- .../viewmodel/main/AvailabilityViewModel.kt | 23 ++++++-- 7 files changed, 79 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/SelectableAvailabilityPager.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/SelectableAvailabilityPager.kt index 115cce1..dc53df1 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/SelectableAvailabilityPager.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/SelectableAvailabilityPager.kt @@ -1,6 +1,5 @@ package com.cornellappdev.resell.android.ui.components.availability -import android.util.Log import androidx.compose.foundation.layout.Column import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -12,11 +11,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import com.cornellappdev.resell.android.ui.components.availability.helper.AvailabilityPagerContainer +import com.cornellappdev.resell.android.ui.components.availability.helper.DAY_WINDOW_SIZE import com.cornellappdev.resell.android.ui.components.availability.helper.GridSelectionType import com.cornellappdev.resell.android.ui.components.availability.helper.SelectableAvailabilityGrid import com.cornellappdev.resell.android.ui.theme.ResellPreview import java.time.LocalDate import java.time.LocalDateTime +import java.time.temporal.ChronoUnit @Composable fun SelectableAvailabilityPager( @@ -43,13 +44,15 @@ fun SelectableAvailabilityPager( // Initialize the selected dates by page. LaunchedEffect(initialSelectedAvailabilities) { // Add each availability to the correct page based on its date. - // Each page corresponds to an increment of 3 days, and each - // inner list corresponds the availabilities for that 3-day period. - // Thus, we must add to the correct 3 day period. + // Each page corresponds to an increment of DAY_WINDOW_SIZE days, and each + // inner list corresponds the availabilities for that window. + // Thus, we must add to the correct window. initialSelectedAvailabilities.forEach { availability -> val today = LocalDate.now() - val dayDifference = today.until(availability.toLocalDate()).days - val pageIndex = Math.floorDiv(dayDifference, 3) + // Period.days is the day-of-month remainder, not elapsed days, so it puts an + // availability on the wrong page as soon as it crosses a month boundary. + val dayDifference = ChronoUnit.DAYS.between(today, availability.toLocalDate()).toInt() + val pageIndex = Math.floorDiv(dayDifference, DAY_WINDOW_SIZE) if (selectedDatesByPage[pageIndex] != null) { val list = selectedDatesByPage[pageIndex]!!.toMutableList() diff --git a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityPagerContainer.kt b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityPagerContainer.kt index 205c376..1c70064 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityPagerContainer.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/ui/components/availability/helper/AvailabilityPagerContainer.kt @@ -127,14 +127,9 @@ fun AvailabilityPagerContainer( state, userScrollEnabled = false ) { page -> Box(modifier = Modifier.padding(horizontal = 32.dp)) { + val offset = page - scrollRange.first availabilityGrid( - buildList { - val offset = page - scrollRange.first - val displayedStartDate = startDate.plusDays(offset * 3L) - add(displayedStartDate) - add(displayedStartDate.plusDays(1)) - add(displayedStartDate.plusDays(2)) - }, + dayWindowStartingAt(startDate.plusDays(offset * DAY_WINDOW_SIZE.toLong())), page ) } 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 5edf94c..e55bbb1 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 @@ -14,7 +14,6 @@ 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 @@ -34,13 +33,16 @@ val MonthSwipeThreshold: Dp = 56.dp */ 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) - val groupIndex = (date.dayOfMonth - 1) / 3 - val groupStart = month.atDay(groupIndex * 3 + 1) - return (0..2).map { groupStart.plusDays(it.toLong()) } -} +/** Number of day columns an availability grid shows at once. */ +const val DAY_WINDOW_SIZE = 3 + +/** + * Returns the [DAY_WINDOW_SIZE]-day window whose first column is [startDate], rolling into the + * next month as needed. Deliberately does not snap to calendar buckets — the caller's date is + * always the leftmost column, so a window anchored at today never shows a past day. + */ +fun dayWindowStartingAt(startDate: LocalDate): List = + (0 until DAY_WINDOW_SIZE).map { startDate.plusDays(it.toLong()) } fun getGridCell(offset: Offset, canvasSize: Size, width: Int, height: Int): Pair { 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 74d8df7..0a0701d 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 @@ -41,9 +41,11 @@ fun MonthCalendar( currentMonth: YearMonth, selectedDates: List, onMonthChange: (YearMonth) -> Unit, - onDaysSelected: (List) -> Unit, + onDayStartSelected: (LocalDate) -> Unit, modifier: Modifier = Modifier, ) { + val today = remember { LocalDate.now() } + val thisMonth = remember(today) { YearMonth.from(today) } val firstDayOfMonth = currentMonth.atDay(1) val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7 val daysInMonth = currentMonth.lengthOfMonth() @@ -60,7 +62,8 @@ fun MonthCalendar( onDragEnd = { when { totalDrag <= -swipeThresholdPx -> onMonthChange(currentMonth.plusMonths(1)) - totalDrag >= swipeThresholdPx -> onMonthChange(currentMonth.minusMonths(1)) + totalDrag >= swipeThresholdPx && currentMonth.isAfter(thisMonth) -> + onMonthChange(currentMonth.minusMonths(1)) } totalDrag = 0f }, @@ -108,6 +111,8 @@ fun MonthCalendar( val cellIndex = row * 7 + col val date = firstDayOfMonth.plusDays((cellIndex - firstDayOfWeek).toLong()) val isInCurrentMonth = YearMonth.from(date) == currentMonth + // Availability in the past can never be proposed, so past days are inert. + val isPast = date.isBefore(today) val isSelected = date in selectedDates // 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*, @@ -129,14 +134,18 @@ fun MonthCalendar( else -> RoundedCornerShape(0.dp) } ) - .clickable { onDaysSelected(dayGroupContaining(date)) } + .then( + if (isPast) Modifier + else Modifier.clickable { onDayStartSelected(date) } + ) .padding(6.dp), contentAlignment = Alignment.Center ) { Text( text = "${date.dayOfMonth}", style = Style.body2, - color = if (isInCurrentMonth) Color.Unspecified else IconInactive + color = if (isPast || !isInCurrentMonth) IconInactive + else Color.Unspecified ) } } @@ -146,36 +155,39 @@ fun MonthCalendar( } } -/** Interactive so left/right swipes to change month can be tested by hand. */ +/** + * Interactive so left/right swipes to change month can be tested by hand. Past days grey out and + * stop responding to taps, and the back-swipe dies once the current month is reached, so anchor + * the preview on a month far enough ahead that the whole grid is live. + */ @Preview @Composable fun MonthCalendarPreview() { - var currentMonth by remember { mutableStateOf(YearMonth.of(2026, 4)) } + var currentMonth by remember { mutableStateOf(YearMonth.now().plusMonths(1)) } + var selectedDates by remember { + mutableStateOf(dayWindowStartingAt(YearMonth.now().plusMonths(1).atDay(16))) + } MonthCalendar( currentMonth = currentMonth, - selectedDates = listOf( - LocalDate.of(2026, 4, 16), - LocalDate.of(2026, 4, 17), - LocalDate.of(2026, 4, 18), - ), + selectedDates = selectedDates, onMonthChange = { currentMonth = it }, - onDaysSelected = {} + onDayStartSelected = { selectedDates = dayWindowStartingAt(it) } ) } -/** Testing display for month with 31 days. */ +/** Testing display for month with 31 days, and a window rolling off its end into the next. */ @Preview @Composable fun MonthCalendarThirtyOneDayMonthPreview() { - val month = YearMonth.of(2026, 7) + val month = YearMonth.of(2027, 7) var selectedDates by remember { - mutableStateOf(dayGroupContaining(LocalDate.of(2026, 7, 31))) + mutableStateOf(dayWindowStartingAt(LocalDate.of(2027, 7, 31))) } MonthCalendar( currentMonth = month, selectedDates = selectedDates, onMonthChange = {}, - onDaysSelected = { selectedDates = it } + onDayStartSelected = { selectedDates = dayWindowStartingAt(it) } ) } @@ -184,10 +196,10 @@ fun MonthCalendarThirtyOneDayMonthPreview() { @Composable fun MonthCalendarSixRowMonthPreview() { MonthCalendar( - currentMonth = YearMonth.of(2026, 5), + currentMonth = YearMonth.of(2027, 5), selectedDates = emptyList(), onMonthChange = {}, - onDaysSelected = {} + onDayStartSelected = {} ) } @@ -201,9 +213,9 @@ fun MonthCalendarSixRowMonthPreview() { @Composable fun MonthCalendarRowSpanningSelectionPreview() { MonthCalendar( - currentMonth = YearMonth.of(2026, 5), - selectedDates = dayGroupContaining(LocalDate.of(2026, 5, 1)), + currentMonth = YearMonth.of(2027, 5), + selectedDates = dayWindowStartingAt(LocalDate.of(2027, 4, 30)), onMonthChange = {}, - onDaysSelected = {} + onDayStartSelected = {} ) } \ 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 603cd29..fd56322 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 @@ -285,7 +285,7 @@ private fun AvailabilityGrid_RUNME_Preview() = ResellPreview { @Preview @Composable private fun SelectableAvailabilityGridRolloverPreview() = ResellPreview { - val rolloverDates = dayGroupContaining(LocalDate.of(2026, 10, 31)) + val rolloverDates = dayWindowStartingAt(LocalDate.of(2026, 10, 30)) var selectedAvailabilities by remember { mutableStateOf(testAvailabilities(rolloverDates)) } SelectableAvailabilityGrid( dates = rolloverDates, 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 1900eb5..700405f 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,7 +44,7 @@ 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.dayWindowStartingAt 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 @@ -75,7 +75,7 @@ fun AvailabilityScreen( uiState = availabilityUiState, onSetSelectedAvailabilities = { availabilityViewModel.setSelectedAvailabilities(it.toSet()) }, onSetCurrentMonth = { availabilityViewModel.setCurrentMonth(it) }, - onSetVisibleDates = { availabilityViewModel.setVisibleDates(it) }, + onSetWindowStart = { availabilityViewModel.setWindowStart(it) }, onSave = { availabilityViewModel.saveAvailability() }, onBackPressed = { availabilityViewModel.onBackPressed() }, ) @@ -86,7 +86,7 @@ fun AvailabilityScreenContent( uiState: AvailabilityViewModel.AvailabilityUiState, onSetSelectedAvailabilities: (List) -> Unit, onSetCurrentMonth: (YearMonth) -> Unit, - onSetVisibleDates: (List) -> Unit, + onSetWindowStart: (LocalDate) -> Unit, onSave: () -> Unit, onBackPressed: () -> Unit = {}, ) { @@ -197,7 +197,7 @@ fun AvailabilityScreenContent( currentMonth = uiState.currentMonth, selectedDates = uiState.visibleDates, onMonthChange = onSetCurrentMonth, - onDaysSelected = onSetVisibleDates, + onDayStartSelected = onSetWindowStart, modifier = Modifier.fillMaxWidth(), ) // AvailabilityPanel.FILTERS -> AvailabilityFilters( @@ -243,7 +243,7 @@ fun AvailabilityScreenPreview() { uiState = AvailabilityViewModel.AvailabilityUiState( selectedAvailabilities = emptySet(), currentMonth = YearMonth.of(2026, 4), - visibleDates = dayGroupContaining(LocalDate.of(2026, 4, 1)), + visibleDates = dayWindowStartingAt(LocalDate.of(2026, 4, 1)), googleCalendarEnabled = false, availabilitySharingEnabled = false, subCalendars = listOf("Personal", "Youtube", "Leetcode", "Capra"), @@ -254,7 +254,7 @@ fun AvailabilityScreenPreview() { ), onSetSelectedAvailabilities = {}, onSetCurrentMonth = {}, - onSetVisibleDates = {}, + onSetWindowStart = {}, onSave = {}, onBackPressed = {}, ) 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 00b7b47..cda0e9a 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 @@ -2,7 +2,7 @@ package com.cornellappdev.resell.android.viewmodel.main import androidx.lifecycle.viewModelScope import com.cornellappdev.resell.android.model.profile.AvailabilityRepository -import com.cornellappdev.resell.android.ui.components.availability.helper.dayGroupContaining +import com.cornellappdev.resell.android.ui.components.availability.helper.dayWindowStartingAt import com.cornellappdev.resell.android.viewmodel.ResellViewModel import com.cornellappdev.resell.android.viewmodel.navigation.RootNavigationRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -23,7 +23,7 @@ class AvailabilityViewModel @Inject constructor( data class AvailabilityUiState( val selectedAvailabilities: Set = emptySet(), val currentMonth: YearMonth = YearMonth.now(), - val visibleDates: List = dayGroupContaining(LocalDate.now()), + val visibleDates: List = dayWindowStartingAt(LocalDate.now()), // TODO: googleCalendarEnabled and availabilitySharingEnabled are not yet wired in. // Need to check how/where it is in the backend @@ -62,12 +62,25 @@ class AvailabilityViewModel @Inject constructor( } } + /** + * Past availability can never be proposed, so a month before the current one is rejected + * outright and the current month anchors on today rather than on the 1st. + */ fun setCurrentMonth(month: YearMonth) { - applyMutation { copy(currentMonth = month, visibleDates = dayGroupContaining(month.atDay(1))) } + val today = LocalDate.now() + if (month.isBefore(YearMonth.from(today))) return + val anchor = maxOf(month.atDay(1), today) + applyMutation { copy(currentMonth = month, visibleDates = dayWindowStartingAt(anchor)) } } - fun setVisibleDates(dates: List) { - applyMutation { copy(visibleDates = dates) } + /** + * [date] becomes the leftmost column of the grid, clamped forward to today so the window + * never backfills. [AvailabilityUiState.currentMonth] is deliberately left alone: tapping a + * trailing day of an adjacent month shouldn't reshuffle the calendar panel under the user. + */ + fun setWindowStart(date: LocalDate) { + val anchor = maxOf(date, LocalDate.now()) + applyMutation { copy(visibleDates = dayWindowStartingAt(anchor)) } } fun setGoogleCalendarEnabled(enabled: Boolean) { From 73ecd633a6f9dd695073dae038672997e7fb5784 Mon Sep 17 00:00:00 2001 From: Ryan Cheung Date: Sat, 26 Sep 2026 01:00:17 -0400 Subject: [PATCH 6/6] fix: address issue where otherID pointed to own ID, resulting in a malformed request when sending a proposal due to essentially sending a proposal to oneself and failing without knowing why. --- .../android/model/api/ChatRepository.kt | 72 +++++++------------ .../android/model/chats/ChatDocument.kt | 33 ++++++--- .../android/viewmodel/ResellViewModel.kt | 3 +- .../android/viewmodel/main/ChatViewModel.kt | 55 +++++++------- 4 files changed, 80 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/api/ChatRepository.kt b/app/src/main/java/com/cornellappdev/resell/android/model/api/ChatRepository.kt index e4ffe16..5d844a1 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/api/ChatRepository.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/api/ChatRepository.kt @@ -13,6 +13,7 @@ import com.cornellappdev.resell.android.model.chats.AvailabilityDocument import com.cornellappdev.resell.android.model.chats.ChatDocument import com.cornellappdev.resell.android.model.chats.ChatHeaderData import com.cornellappdev.resell.android.model.chats.MeetingInfo +import com.cornellappdev.resell.android.model.chats.MeetingState import com.cornellappdev.resell.android.model.chats.RawChatHeaderData import com.cornellappdev.resell.android.model.classes.ResellApiResponse import com.cornellappdev.resell.android.model.core.UserInfoRepository @@ -212,14 +213,16 @@ class ChatRepository @Inject constructor( val meetingInfo = document.startDate?.let { MeetingInfo( proposeTime = it, - state = if (document.accepted == true) { - "confirmed" + // Cancellation is checked first so a cancel document that carried over + // `accepted = true` from the confirmation is correctly canceled instead. + state = if (document.cancellation == true) { + MeetingState.CANCELED + } else if (document.accepted == true) { + MeetingState.CONFIRMED } else if (document.accepted == false) { - "declined" - } else if (document.cancellation == true) { - "canceled" + MeetingState.DECLINED } else { - "proposed" + MeetingState.PROPOSED }, mostRecent = false ) @@ -379,41 +382,13 @@ class ChatRepository @Inject constructor( document: ChatDocument, myId: String, otherName: String - ) = when (meetingInfo.state) { - "proposed" -> { - if (document.senderId == myId) { - "You proposed a new meeting" - } else { - "$otherName proposed a new meeting" - } - } - - "confirmed" -> { - if (document.senderId == myId) { - "You accepted a new meeting" - } else { - "$otherName accepted a new meeting" - } - } - - "declined" -> { - if (document.senderId == myId) { - "You declined the meeting proposal" - } else { - "$otherName declined the meeting proposal" - } - } - - "canceled" -> { - if (document.senderId == myId) { - "You canceled the meeting" - } else { - "$otherName canceled the meeting" - } - } - - else -> { - "" + ): String { + val actor = if (document.senderId == myId) "You" else otherName + return when (meetingInfo.state) { + MeetingState.PROPOSED -> "$actor proposed a new meeting" + MeetingState.CONFIRMED -> "$actor accepted a new meeting" + MeetingState.DECLINED -> "$actor declined the meeting proposal" + MeetingState.CANCELED -> "$actor canceled the meeting" } } @@ -428,6 +403,13 @@ class ChatRepository @Inject constructor( imageUrls: List, chatId: String, ) { + // A chat has two distinct participants. If the two ids are the same, + // fail here where the message names the cause, rather than + // shipping a malformed request. + require(myId.isNotBlank() && otherId.isNotBlank() && myId != otherId) { + "Malformed chat participants: myId='$myId' otherId='$otherId'" + } + val buyerId = if (selfIsBuyer) myId else otherId val sellerId = if (selfIsBuyer) otherId else myId @@ -462,7 +444,7 @@ class ChatRepository @Inject constructor( } else if (meetingInfo != null) { when (meetingInfo.state) { - "proposed" -> { + MeetingState.PROPOSED -> { retrofitInstance.chatApi.sendProposal( proposalBody = ProposalBody( buyerId = buyerId, @@ -476,7 +458,7 @@ class ChatRepository @Inject constructor( ) } - "confirmed", "declined" -> { + MeetingState.CONFIRMED, MeetingState.DECLINED -> { retrofitInstance.chatApi.sendProposalResponse( proposalResponseBody = ProposalResponseBody( buyerId = buyerId, @@ -485,13 +467,13 @@ class ChatRepository @Inject constructor( senderId = myId, startDate = meetingInfo.proposeTime, endDate = meetingInfo.endTime, - accepted = meetingInfo.state == "confirmed" + accepted = meetingInfo.state == MeetingState.CONFIRMED ), chatId = chatId ) } - "canceled" -> { + MeetingState.CANCELED -> { retrofitInstance.chatApi.sendProposalCancel( proposalCancelBody = ProposalCancelBody( buyerId = buyerId, diff --git a/app/src/main/java/com/cornellappdev/resell/android/model/chats/ChatDocument.kt b/app/src/main/java/com/cornellappdev/resell/android/model/chats/ChatDocument.kt index 4a06271..42159ce 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/model/chats/ChatDocument.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/model/chats/ChatDocument.kt @@ -64,29 +64,40 @@ data class UserDocument( val name: String ) +/** + * The lifecycle of a meeting proposal. + */ +enum class MeetingState(val value: String) { + PROPOSED("proposed"), + CONFIRMED("confirmed"), + DECLINED("declined"), + CANCELED("canceled"); + + companion object { + fun fromWire(value: String?): MeetingState? = entries.firstOrNull { it.value == value } + } +} + /** * An optional block included in the [ChatDocument] to represent a meeting proposal. - * - * @property state Either "confirmed" or "declined" or "proposed" or "canceled". */ data class MeetingInfo( val proposeTime: Timestamp, - val state: String, + val state: MeetingState, var mostRecent: Boolean ) { val actionText get() = when (state) { - "proposed" -> "View Proposal" - "declined" -> "Send Another Proposal" - "confirmed" -> "View Details" - "canceled" -> null - else -> "" + MeetingState.PROPOSED -> "View Proposal" + MeetingState.DECLINED -> "Send Another Proposal" + MeetingState.CONFIRMED -> "View Details" + MeetingState.CANCELED -> null } val icon get() = when (state) { - "declined", "canceled" -> R.drawable.ic_slash - else -> R.drawable.ic_calendar + MeetingState.DECLINED, MeetingState.CANCELED -> R.drawable.ic_slash + MeetingState.PROPOSED, MeetingState.CONFIRMED -> R.drawable.ic_calendar } val endTime: Timestamp @@ -100,7 +111,7 @@ data class MeetingInfo( fun toFirebaseMap(): Map { val map = mutableMapOf() map["proposeTime"] = proposeTime - map["state"] = state + map["state"] = state.value return map } diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/ResellViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/ResellViewModel.kt index 90abfb2..f1ce169 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/ResellViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/ResellViewModel.kt @@ -150,6 +150,7 @@ abstract class ResellViewModel(initialUiState: UiState) : ViewModel() { * @param name The name of the OTHER user. * @param email The email of the OTHER user. * @param pfp The profile picture of the OTHER user. + * @param otherId The id of the OTHER chat participant. * @param id The id of the post. */ protected suspend fun contactSeller( @@ -176,7 +177,7 @@ abstract class ResellViewModel(initialUiState: UiState) : ViewModel() { name = name, pfp = pfp, listingJson = Json.encodeToString(listing), - otherUserId = listing.user.id, + otherUserId = otherId, otherVenmo = listing.user.venmoHandle, chatId = chatId ) diff --git a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt index 6d7add7..5ee1c1f 100644 --- a/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt +++ b/app/src/main/java/com/cornellappdev/resell/android/viewmodel/main/ChatViewModel.kt @@ -25,6 +25,7 @@ import com.cornellappdev.resell.android.model.api.ChatRepository import com.cornellappdev.resell.android.model.api.Post import com.cornellappdev.resell.android.model.chats.AvailabilityDocument import com.cornellappdev.resell.android.model.chats.MeetingInfo +import com.cornellappdev.resell.android.model.chats.MeetingState import com.cornellappdev.resell.android.model.chats.TransactionInfo import com.cornellappdev.resell.android.model.chats.TransactionState import com.cornellappdev.resell.android.model.classes.Listing @@ -112,7 +113,7 @@ class ChatViewModel @Inject constructor( it.meetingInfo != null } - if (mostRecentState != null && mostRecentState.meetingInfo!!.state == "confirmed") { + if (mostRecentState != null && mostRecentState.meetingInfo!!.state == MeetingState.CONFIRMED) { mostRecentState.meetingInfo } else { null @@ -247,7 +248,7 @@ class ChatViewModel @Inject constructor( } fun onSendAvailabilityPressed() { - val canPropose = mostRecentMeetingStateIs("confirmed") == null + val canPropose = mostRecentMeetingStateIs(MeetingState.CONFIRMED) == null viewModelScope.launch { // Grey out everything but the overlap between both people's saved availability, so @@ -372,7 +373,7 @@ class ChatViewModel @Inject constructor( return mostRecentState } - private fun mostRecentMeetingStateIs(state: String): MeetingInfo? { + private fun mostRecentMeetingStateIs(state: MeetingState): MeetingInfo? { val mostRecentState = getFirstChatOrNull { it.meetingInfo != null } @@ -388,7 +389,7 @@ class ChatViewModel @Inject constructor( availability: AvailabilityDocument, isSelf: Boolean, ) { - val canPropose = mostRecentMeetingStateIs("confirmed") == null + val canPropose = mostRecentMeetingStateIs(MeetingState.CONFIRMED) == null rootNavigationSheetRepository.showBottomSheet( sheet = RootSheet.Availability( @@ -430,7 +431,7 @@ class ChatViewModel @Inject constructor( val otherName = savedStateHandle.toRoute().name viewModelScope.launch { when (meetingInfo.state) { - "proposed" -> { + MeetingState.PROPOSED -> { rootNavigationSheetRepository.showBottomSheet( RootSheet.TwoButtonSheet( title = "Proposal Details", @@ -462,7 +463,7 @@ class ChatViewModel @Inject constructor( ) } - "confirmed" -> { + MeetingState.CONFIRMED -> { rootNavigationSheetRepository.showBottomSheet( RootSheet.TwoButtonSheet( title = "Meeting Details", @@ -487,7 +488,7 @@ class ChatViewModel @Inject constructor( ) } - "declined" -> { + MeetingState.DECLINED -> { val myEmail = userInfoRepository.getUserInfo().email val chat = chatRepository.subscribedChatFlow.value.asSuccessOrNull()?.data @@ -506,9 +507,7 @@ class ChatViewModel @Inject constructor( } } - "canceled" -> {} - - else -> {} + MeetingState.CANCELED -> {} } } } @@ -520,10 +519,11 @@ class ChatViewModel @Inject constructor( chatRepository.sendProposalUpdate( selfIsBuyer = navArgs.isBuyer, listingId = listing.id, - myId = userInfoRepository.getUserId() ?: "", + myId = userInfoRepository.getUserId() + ?: error("No signed-in user id; cannot send a meeting update"), otherId = navArgs.otherUserId, meetingInfo = MeetingInfo( - state = "proposed", + state = MeetingState.PROPOSED, proposeTime = availability.let { val zoneId = ZoneId.systemDefault() val instant = it.atZone(zoneId).toInstant() @@ -535,70 +535,73 @@ class ChatViewModel @Inject constructor( chatId = navArgs.chatId ) } catch (e: Exception) { - rootConfirmationRepository.showError() + rootConfirmationRepository.showError("Couldn't send your proposal. Please try again.") Log.e("ChatViewModel", "onMeetingProposal: ", e) } } } private fun onMeetingConfirmed(meetingInfo: MeetingInfo) { - rootNavigationSheetRepository.hideSheet() viewModelScope.launch { try { chatRepository.sendProposalUpdate( selfIsBuyer = navArgs.isBuyer, listingId = listing.id, - myId = userInfoRepository.getUserId() ?: "", + myId = userInfoRepository.getUserId() + ?: error("No signed-in user id; cannot send a meeting update"), otherId = navArgs.otherUserId, meetingInfo = meetingInfo.copy( - state = "confirmed", + state = MeetingState.CONFIRMED, ), chatId = navArgs.chatId ) + rootNavigationSheetRepository.hideSheet() } catch (e: Exception) { - rootConfirmationRepository.showError() + rootConfirmationRepository.showError("Couldn't confirm the meeting. Please try again.") Log.e("ChatViewModel", "onMeetingConfirmed: ", e) } } } private fun onMeetingDeclined(meetingInfo: MeetingInfo) { - rootNavigationSheetRepository.hideSheet() viewModelScope.launch { try { chatRepository.sendProposalUpdate( selfIsBuyer = navArgs.isBuyer, listingId = listing.id, - myId = userInfoRepository.getUserId() ?: "", + myId = userInfoRepository.getUserId() + ?: error("No signed-in user id; cannot send a meeting update"), otherId = navArgs.otherUserId, meetingInfo = meetingInfo.copy( - state = "declined", + state = MeetingState.DECLINED, ), chatId = navArgs.chatId ) + rootNavigationSheetRepository.hideSheet() } catch (e: Exception) { - rootConfirmationRepository.showError() + rootConfirmationRepository.showError("Couldn't decline the proposal. Please try again.") Log.e("ChatViewModel", "onMeetingDeclined: ", e) } } } private fun onMeetingCancelled(meetingInfo: MeetingInfo) { - rootNavigationSheetRepository.hideSheet() viewModelScope.launch { try { chatRepository.sendProposalUpdate( selfIsBuyer = navArgs.isBuyer, listingId = listing.id, - myId = userInfoRepository.getUserId() ?: "", + myId = userInfoRepository.getUserId() + ?: error("No signed-in user id; cannot send a meeting update"), otherId = navArgs.otherUserId, meetingInfo = meetingInfo.copy( - state = "canceled", + state = MeetingState.CANCELED, ), chatId = navArgs.chatId ) + rootNavigationSheetRepository.hideSheet() } catch (e: Exception) { - rootConfirmationRepository.showError() + rootConfirmationRepository.showError("Couldn't cancel the meeting. Please try again.") Log.e("ChatViewModel", "onMeetingCancelled: ", e) } } @@ -658,7 +661,7 @@ class ChatViewModel @Inject constructor( } viewModelScope.launch { - val confirmedMeetingInfo = mostRecentMeetingStateIs("confirmed") + val confirmedMeetingInfo = mostRecentMeetingStateIs(MeetingState.CONFIRMED) if (response is ResellApiResponse.Success && confirmedMeetingInfo != null && chatRepository.shouldShowGCalSync(