From f74572e7c543d058df9536cf9406ce0e00fe578d Mon Sep 17 00:00:00 2001 From: conniecliu Date: Sun, 26 Apr 2026 23:45:47 -0400 Subject: [PATCH] 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 @@ + + + + +