diff --git a/app/src/main/java/com/cornellappdev/transit/models/LocationRepository.kt b/app/src/main/java/com/cornellappdev/transit/models/LocationRepository.kt index d73d474..89ce16b 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/LocationRepository.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/LocationRepository.kt @@ -24,7 +24,7 @@ import javax.inject.Singleton @Singleton class LocationRepository @Inject constructor(private val routesNetworkApi: RoutesNetworkApi) { - //Source: Uplift + // Source: Uplift Android private lateinit var fusedLocationClient: FusedLocationProviderClient private val _currentLocation: MutableStateFlow = MutableStateFlow(null) diff --git a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/DetailedEcosystemPlace.kt b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/DetailedEcosystemPlace.kt deleted file mode 100644 index 356ed07..0000000 --- a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/DetailedEcosystemPlace.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.cornellappdev.transit.models.ecosystem - -/** - * Interface for working with places in ecosystem with special details, i.e. hours or capacity - */ -sealed interface DetailedEcosystemPlace \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/Eatery.kt b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/Eatery.kt index bb1bb0e..c83c4ae 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/Eatery.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/Eatery.kt @@ -1,5 +1,8 @@ package com.cornellappdev.transit.models.ecosystem +import com.cornellappdev.transit.models.Place +import com.cornellappdev.transit.models.PlaceType +import com.cornellappdev.transit.util.TimeUtils.dayOrder import com.cornellappdev.transit.util.TimeUtils.toPascalCaseString import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -27,27 +30,8 @@ data class Eatery( @Json(name = "events") val events: List? ) : DetailedEcosystemPlace { - /** - * Value to represent the custom order of days in a week (with Sunday as - * the first day due to a particular design choice). Used for sorting purposes - */ - private val dayOrder = mapOf( - "Sunday" to 1, - "Monday" to 2, - "Tuesday" to 3, - "Wednesday" to 4, - "Thursday" to 5, - "Friday" to 6, - "Saturday" to 7 - ) - - /** - * @Return a list of associated dayOfWeek and hours pairs in [DayOperatingHours] representing - * each day of the week and the corresponding times that an eatery is open. The list is sorted - * by day with the custom dayOrder (Sunday first). - */ - fun formatOperatingHours(): List { - val dailyHours = operatingHours() + override fun operatingHours(): List { + val dailyHours = getOperatingHours() // Convert map to list and sort by custom day order return dailyHours.entries @@ -66,7 +50,7 @@ data class Eatery( /** * @Return a map of each day of the week to its list of operating hours */ - private fun operatingHours(): Map> { + private fun getOperatingHours(): Map> { val dailyHours = mutableMapOf>() events?.forEach { event -> @@ -87,6 +71,14 @@ data class Eatery( return dailyHours } + + override fun toPlace(): Place = Place( + latitude = this.latitude ?: 0.0, + longitude = this.longitude ?: 0.0, + name = this.name, + detail = this.location, + type = PlaceType.APPLE_PLACE + ) } diff --git a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/EcosystemPlace.kt b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/EcosystemPlace.kt new file mode 100644 index 0000000..36e61e8 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/EcosystemPlace.kt @@ -0,0 +1,28 @@ +package com.cornellappdev.transit.models.ecosystem + +import com.cornellappdev.transit.models.Place + +/** + * Specific places such as eateries or gyms + */ +interface EcosystemPlace { + + /** + * Convert from a specific ecosystem place to the generic [Place] class + */ + fun toPlace(): Place +} + + +/** + * Interface for working with places in ecosystem with special details, i.e. hours or capacity + */ +sealed interface DetailedEcosystemPlace: EcosystemPlace { + + /** + * @Return a list of associated dayOfWeek and hours pairs in [DayOperatingHours] representing + * each day of the week and the corresponding times that an eatery is open. The list is sorted + * by day with the custom dayOrder (Sunday first). + */ + fun operatingHours(): List +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/StaticPlaces.kt b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/StaticPlaces.kt index f5074a6..0600cd1 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/StaticPlaces.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/StaticPlaces.kt @@ -1,5 +1,7 @@ package com.cornellappdev.transit.models.ecosystem +import com.cornellappdev.transit.models.Place +import com.cornellappdev.transit.models.PlaceType import com.cornellappdev.transit.networking.ApiResponse import com.squareup.moshi.Json @@ -22,7 +24,15 @@ data class Printer( @Json(name = "description") var description: String, @Json(name = "latitude") var latitude: Double, @Json(name = "longitude") var longitude: Double -) +) : EcosystemPlace { + override fun toPlace(): Place = Place( + latitude = this.latitude, + longitude = this.longitude, + name = this.location, + detail = this.description, + type = PlaceType.APPLE_PLACE + ) +} /** * Class representing a Cornell library @@ -33,4 +43,18 @@ data class Library( @Json(name = "address") var address: String, @Json(name = "latitude") var latitude: Double, @Json(name = "longitude") var longitude: Double -) : DetailedEcosystemPlace +) : DetailedEcosystemPlace { + + override fun operatingHours(): List { + //TODO: Implement + return emptyList() + } + + override fun toPlace(): Place = Place( + latitude = this.latitude, + longitude = this.longitude, + name = this.location, + detail = this.address, + type = PlaceType.APPLE_PLACE + ) +} diff --git a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/UpliftGym.kt b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/UpliftGym.kt index 4bc95e2..e45aa9c 100644 --- a/app/src/main/java/com/cornellappdev/transit/models/ecosystem/UpliftGym.kt +++ b/app/src/main/java/com/cornellappdev/transit/models/ecosystem/UpliftGym.kt @@ -1,6 +1,10 @@ package com.cornellappdev.transit.models.ecosystem import android.icu.util.Calendar +import com.cornellappdev.transit.models.Place +import com.cornellappdev.transit.models.PlaceType +import com.cornellappdev.transit.util.TimeUtils.dayString +import com.cornellappdev.transit.util.getGymLocationString import kotlin.math.roundToInt /** @@ -38,7 +42,37 @@ data class UpliftGym( val upliftCapacity: UpliftCapacity?, val latitude: Double, val longitude: Double, -) : DetailedEcosystemPlace +) : DetailedEcosystemPlace { + + override fun operatingHours(): List { + + // Sunday is enforced to be first indexed day + val indexedHours = hours.takeLast(1) + hours.dropLast(1) + + val dayOperatingHours = indexedHours.mapIndexed { index, dayHours -> + dayHours?.let { + DayOperatingHours( + dayOfWeek = dayString[index] ?: "", + hours = dayHours.map { timeInterval -> + timeInterval.toString() + + }) + } ?: DayOperatingHours(dayOfWeek = dayString[index] ?: "", hours = listOf("Closed")) + } + + + return dayOperatingHours + + } + + override fun toPlace(): Place = Place( + latitude = this.latitude, + longitude = this.longitude, + name = this.name, + detail = getGymLocationString(this.name), + type = PlaceType.APPLE_PLACE + ) +} /** * A gym's capacity. diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/DetailedPlaceHeaderSection.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/DetailedPlaceHeaderSection.kt index 2232832..bd2a5cd 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/DetailedPlaceHeaderSection.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/DetailedPlaceHeaderSection.kt @@ -1,14 +1,18 @@ package com.cornellappdev.transit.ui.components.home import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope 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.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.BottomEnd +import androidx.compose.ui.Alignment.Companion.TopEnd import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow @@ -82,6 +86,67 @@ fun DetailedPlaceHeaderSection( } } +/** + * Text area of detailed place header with favorites star on the top right and widget on the bottom right + */ +@Composable +fun DetailedPlaceHeaderSectionWithWidget( + title: String, + subtitle: String?, + leftAnnotatedString: AnnotatedString? = null, + widget: @Composable BoxScope.() -> Unit, + onFavoriteClick: () -> Unit, + isFavorite: Boolean +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + Column( + modifier = Modifier + .align(Alignment.CenterStart), + ) { + Text( + text = title, + style = Style.detailHeading, + color = PrimaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(end = 32.dp, bottom = 12.dp) + ) + subtitle?.let { + Text( + text = subtitle, + style = Style.cardSubtitle, + color = SecondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(end = 32.dp, bottom = 8.dp) + + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + leftAnnotatedString?.let { + Text( + text = leftAnnotatedString, + style = Style.cardSubtitle + ) + } + Spacer(modifier = Modifier.weight(1f)) + } + } + + Box(Modifier.align(BottomEnd)) { + widget() + } + FavoritesStar(onFavoriteClick, isFavorite) + } +} + @Preview(showBackground = true) @Composable private fun DetailedPlaceHeaderSectionPreview() { diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/DetailedPlaceSheetContent.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/DetailedPlaceSheetContent.kt index 6fca4bc..6aef567 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/DetailedPlaceSheetContent.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/DetailedPlaceSheetContent.kt @@ -38,7 +38,6 @@ import com.cornellappdev.transit.ui.theme.SecondaryText import com.cornellappdev.transit.ui.theme.Style import com.cornellappdev.transit.ui.theme.TransitBlue import com.cornellappdev.transit.util.BOTTOM_SHEET_MAX_HEIGHT_PERCENT -import com.cornellappdev.transit.util.ecosystem.toPlace @Composable fun DetailedPlaceSheetContent( @@ -110,8 +109,13 @@ fun DetailedPlaceSheetContent( } is UpliftGym -> { - //TODO - Text(ecosystemPlace.name) + GymDetailsContent( + gym = ecosystemPlace, + isFavorite = ecosystemPlace.toPlace() in favorites, + onFavoriteClick = { + onFavoriteStarClick(ecosystemPlace.toPlace()) + } + ) } } } @@ -148,7 +152,9 @@ fun DetailedPlaceSheetContent( } is UpliftGym -> { - //TODO + navigateToPlace( + ecosystemPlace.toPlace() + ) } } } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EateryDetailsContent.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EateryDetailsContent.kt index 08c23de..323d170 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EateryDetailsContent.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EateryDetailsContent.kt @@ -53,7 +53,7 @@ fun EateryDetailsContent( eatery.name, eatery.campusArea, leftAnnotatedString = homeViewModel.isOpenAnnotatedStringFromOperatingHours( - eatery.formatOperatingHours() + eatery.operatingHours() ), onFavoriteClick = onFavoriteClick, isFavorite = isFavorite @@ -113,8 +113,8 @@ fun EateryDetailsContent( HorizontalDivider(thickness = 1.dp, color = DividerGray) ExpandableOperatingHoursList( - homeViewModel.isOpenAnnotatedStringFromOperatingHours(eatery.formatOperatingHours()), - homeViewModel.rotateOperatingHours(eatery.formatOperatingHours()) + homeViewModel.isOpenAnnotatedStringFromOperatingHours(eatery.operatingHours()), + homeViewModel.rotateOperatingHours(eatery.operatingHours()) ) } diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt index 316db37..141f7ff 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/EcosystemBottomSheetContent.kt @@ -28,11 +28,13 @@ import com.cornellappdev.transit.models.ecosystem.DayOperatingHours import com.cornellappdev.transit.models.ecosystem.DetailedEcosystemPlace import com.cornellappdev.transit.models.ecosystem.Eatery import com.cornellappdev.transit.models.ecosystem.StaticPlaces +import com.cornellappdev.transit.models.ecosystem.UpliftCapacity +import com.cornellappdev.transit.models.ecosystem.UpliftGym import com.cornellappdev.transit.networking.ApiResponse import com.cornellappdev.transit.ui.theme.robotoFamily import com.cornellappdev.transit.ui.viewmodels.FilterState import com.cornellappdev.transit.ui.viewmodels.HomeViewModel -import com.cornellappdev.transit.util.ecosystem.toPlace +import com.cornellappdev.transit.util.getGymLocationString /** @@ -125,7 +127,14 @@ private fun BottomSheetFilteredContent( } FilterState.GYMS -> { - gymList(staticPlaces, navigateToPlace) + gymList( + gymsApiResponse = staticPlaces.gyms, + onDetailsClick = onDetailsClick, + favorites = favorites, + onFavoriteStarClick = onFavoriteStarClick, + operatingHoursToString = homeViewModel::isOpenAnnotatedStringFromOperatingHours, + capacityToString = homeViewModel::capacityPercentAnnotatedString + ) } FilterState.EATERIES -> { @@ -178,23 +187,42 @@ private fun LazyListScope.favoriteList( * LazyList scoped enumeration of gyms for bottom sheet */ private fun LazyListScope.gymList( - staticPlaces: StaticPlaces, - navigateToPlace: (Place) -> Unit + gymsApiResponse: ApiResponse>, + onDetailsClick: (DetailedEcosystemPlace) -> Unit, + favorites: Set, + onFavoriteStarClick: (Place) -> Unit, + operatingHoursToString: (List) -> AnnotatedString, + capacityToString: (UpliftCapacity?) -> AnnotatedString, ) { - when (staticPlaces.gyms) { + when (gymsApiResponse) { is ApiResponse.Error -> { } is ApiResponse.Pending -> { + item { + CenteredSpinningIndicator() + } } is ApiResponse.Success -> { - items(staticPlaces.gyms.data) { - BottomSheetLocationCard( + items(gymsApiResponse.data) { + RoundedImagePlaceCard( + imageUrl = it.imageUrl, title = it.name, - subtitle1 = it.id + subtitle = getGymLocationString(it.name), + isFavorite = it.toPlace() in favorites, + onFavoriteClick = { + onFavoriteStarClick(it.toPlace()) + }, + leftAnnotatedString = operatingHoursToString( + it.operatingHours() + ), + rightAnnotatedString = capacityToString( + it.upliftCapacity + ), + placeholderRes = R.drawable.olin_library, ) { - //TODO: Eatery + onDetailsClick(it) } Spacer(Modifier.height(10.dp)) } @@ -266,7 +294,7 @@ private fun LazyListScope.eateryList( }, placeholderRes = R.drawable.olin_library, leftAnnotatedString = operatingHoursToString( - it.formatOperatingHours() + it.operatingHours() ) ) { onDetailsClick(it) diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/GymCapacityIndicator.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/GymCapacityIndicator.kt new file mode 100644 index 0000000..3fccd29 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/GymCapacityIndicator.kt @@ -0,0 +1,129 @@ +package com.cornellappdev.transit.ui.components.home + +import android.icu.util.Calendar +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +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 androidx.compose.ui.unit.sp +import com.cornellappdev.transit.models.ecosystem.UpliftCapacity +import com.cornellappdev.transit.ui.theme.AccentClosed +import com.cornellappdev.transit.ui.theme.AccentOpen +import com.cornellappdev.transit.ui.theme.AccentOrange +import com.cornellappdev.transit.ui.theme.Gray02 +import com.cornellappdev.transit.ui.theme.PrimaryText +import com.cornellappdev.transit.ui.theme.robotoFamily +import com.cornellappdev.transit.util.HIGH_CAPACITY_THRESHOLD +import com.cornellappdev.transit.util.colorInterp + +// Source: Uplift Android + +/** + * A circular indicator for the capacity at a given gym. + * + * @param capacity A tuple whose first element is the current number of people at the gym + * and whose second element is the max capacity. + * @param label The name of the gym placed under this indicator. Can be null to indicate no + * label. + */ +@Composable +fun GymCapacityIndicator( + capacity: UpliftCapacity, + label: String?, +) { + + val fraction = capacity.percent.toFloat() + val animatedFraction = remember { Animatable(0f) } + + // When the composable launches, animate the fraction to the capacity fraction. + LaunchedEffect(animatedFraction) { + animatedFraction.animateTo( + fraction, + animationSpec = tween(durationMillis = 750) + ) + } + + // Choose a color. If between 0 & 0.5, tween between open and orange. If between 0.5 and 1, + // tween between orange and closed. + val color = + if (fraction > HIGH_CAPACITY_THRESHOLD) + colorInterp( + (fraction - HIGH_CAPACITY_THRESHOLD) / (1 - HIGH_CAPACITY_THRESHOLD), + AccentOrange, + AccentClosed + ) + else + colorInterp( + fraction / HIGH_CAPACITY_THRESHOLD, + AccentOpen, + AccentClosed + ) + + val size = 54.dp + val percentFontSize = 12.sp + val labelColor = PrimaryText + val labelFontWeight = FontWeight(600) + val labelPadding = 12.dp + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box { + CircularProgressIndicator( + color = Gray02, + strokeWidth = size / 9, + modifier = Modifier.size(size), + progress = 1f + ) + CircularProgressIndicator( + color = color, + strokeWidth = size / 9, + modifier = Modifier.size(size), + progress = animatedFraction.value, + strokeCap = StrokeCap.Round + ) + Text( + text = capacity.percentString(), + fontFamily = robotoFamily, + fontSize = percentFontSize, + fontWeight = FontWeight(700), + color = PrimaryText, + modifier = Modifier.align(Alignment.Center), + textAlign = TextAlign.Center, + ) + } + if (label != null) { + Spacer(modifier = Modifier.height(labelPadding)) + Text( + text = label, + fontFamily = robotoFamily, + fontSize = 14.sp, + fontWeight = labelFontWeight, + color = labelColor, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +@Preview(showBackground = true) +private fun GymCapacityIndicatorPreview() { + GymCapacityIndicator( + UpliftCapacity(0.40, Calendar.getInstance()), + label = null + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/GymDetailsContent.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/GymDetailsContent.kt new file mode 100644 index 0000000..0d0fbc8 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/GymDetailsContent.kt @@ -0,0 +1,135 @@ +package com.cornellappdev.transit.ui.components.home + +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.height +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.cornellappdev.transit.R +import com.cornellappdev.transit.models.ecosystem.UpliftGym +import com.cornellappdev.transit.ui.theme.DividerGray +import com.cornellappdev.transit.ui.theme.Gray05 +import com.cornellappdev.transit.ui.theme.PrimaryText +import com.cornellappdev.transit.ui.theme.SecondaryText +import com.cornellappdev.transit.ui.theme.Style +import com.cornellappdev.transit.ui.theme.TransitBlue +import com.cornellappdev.transit.ui.viewmodels.HomeViewModel +import com.cornellappdev.transit.util.getAboutContent +import com.cornellappdev.transit.util.getGymLocationString + +/** + * Displays the full detail view for an individual gym within the ecosystem bottom sheet. + */ +@Composable +fun GymDetailsContent( + homeViewModel: HomeViewModel = hiltViewModel(), + gym: UpliftGym, + isFavorite: Boolean, + onFavoriteClick: () -> Unit, +) { + val isOpen = homeViewModel.getOpenStatus(gym.operatingHours()).isOpen + + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 20.dp, + end = 20.dp, + ) + ) { + PlaceCardImage( + imageUrl = gym.imageUrl, + placeholderRes = R.drawable.olin_library, + shouldClipBottom = true + ) + + DetailedPlaceHeaderSectionWithWidget( + gym.name, + getGymLocationString(gym.name), + onFavoriteClick = onFavoriteClick, + isFavorite = isFavorite, + leftAnnotatedString = homeViewModel.isOpenAnnotatedStringFromOperatingHours( + gym.operatingHours() + ), + widget = { + if (gym.upliftCapacity != null && isOpen) { + GymCapacityIndicator( + capacity = gym.upliftCapacity, + label = null, + ) + } + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + HorizontalDivider(thickness = 1.dp, color = DividerGray) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "About", + style = Style.detailSubtitle, + color = PrimaryText, + modifier = Modifier.padding(bottom = 12.dp) + ) + + Text( + text = getAboutContent(gym.name), + style = Style.detailBody, + color = SecondaryText, + modifier = Modifier.padding(bottom = 15.dp) + ) + + Text( + text = stringResource(R.string.view_gym), + style = Style.heading2, + color = TransitBlue + ) + + Spacer(modifier = Modifier.height(24.dp)) + + HorizontalDivider(thickness = 1.dp, color = DividerGray) + + Spacer(modifier = Modifier.height(24.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painterResource(R.drawable.location_pin_gray), + contentDescription = null, + modifier = Modifier + .size(20.dp), + tint = Gray05 + ) + Text( + text = getGymLocationString(gym.name) ?: "", + style = Style.detailBody, + color = SecondaryText, + modifier = Modifier.padding(start = 15.dp) + ) + + } + + Spacer(modifier = Modifier.height(24.dp)) + + HorizontalDivider(thickness = 1.dp, color = DividerGray) + + ExpandableOperatingHoursList( + homeViewModel.isOpenAnnotatedStringFromOperatingHours(gym.operatingHours()), + homeViewModel.rotateOperatingHours(gym.operatingHours()) + ) + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/OperatingHoursList.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/OperatingHoursList.kt index 13ca05b..e0608ea 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/OperatingHoursList.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/OperatingHoursList.kt @@ -67,7 +67,7 @@ private fun OperatingHoursRow( ) { Row( horizontalArrangement = Arrangement.Start, - modifier = Modifier.weight(0.6f) + modifier = Modifier.weight(0.58f) ) { if (isHighlighted) { VerticalDivider( @@ -89,7 +89,7 @@ private fun OperatingHoursRow( Column( horizontalAlignment = Alignment.Start, - modifier = Modifier.weight(0.4f) + modifier = Modifier.weight(0.42f) ) { hours.forEach { timeRange -> Text( diff --git a/app/src/main/java/com/cornellappdev/transit/ui/components/home/RoundedImagePlaceCard.kt b/app/src/main/java/com/cornellappdev/transit/ui/components/home/RoundedImagePlaceCard.kt index 9fa4ab0..07e8156 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/components/home/RoundedImagePlaceCard.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/components/home/RoundedImagePlaceCard.kt @@ -42,9 +42,7 @@ fun RoundedImagePlaceCard( onClick: () -> Unit, ) { Column( - modifier = Modifier - .padding(horizontal = 24.dp, vertical = 10.dp) - .clickable { onClick() } + modifier = Modifier.clickable { onClick() } ) { Column( modifier = Modifier diff --git a/app/src/main/java/com/cornellappdev/transit/ui/theme/Color.kt b/app/src/main/java/com/cornellappdev/transit/ui/theme/Color.kt index 7230a12..7636a30 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/theme/Color.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/theme/Color.kt @@ -25,4 +25,12 @@ val DetailsDividerGray = Color(0xffc6c6c8) val FavoritesYellow = Color(0xFFFEC50E) -val Gray05 = Color(0xFF586069) \ No newline at end of file +val Gray02 = Color(0xFFA5A5A5) +val Gray04 = Color(0xFF707070) +val Gray05 = Color(0xFF586069) + +// Gyms + +val AccentOpen = Color(0xFF64C270) +val AccentClosed = Color(0xFFF07D7D) +val AccentOrange = Color(0xFFFE8F13) \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/FavoritesViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/FavoritesViewModel.kt index 5d980e9..5503132 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/FavoritesViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/FavoritesViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import com.cornellappdev.transit.models.RouteRepository import com.cornellappdev.transit.models.Place import com.cornellappdev.transit.models.UserPreferenceRepository -import com.cornellappdev.transit.util.ecosystem.toPlace import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt index 3b9aaea..3abf108 100644 --- a/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/transit/ui/viewmodels/HomeViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.cornellappdev.transit.models.LocationRepository @@ -17,10 +18,17 @@ import com.cornellappdev.transit.models.UserPreferenceRepository import com.cornellappdev.transit.models.ecosystem.DayOperatingHours import com.cornellappdev.transit.models.ecosystem.EateryRepository import com.cornellappdev.transit.models.ecosystem.GymRepository +import com.cornellappdev.transit.models.ecosystem.UpliftCapacity import com.cornellappdev.transit.networking.ApiResponse +import com.cornellappdev.transit.ui.theme.AccentClosed +import com.cornellappdev.transit.ui.theme.AccentOpen +import com.cornellappdev.transit.ui.theme.AccentOrange import com.cornellappdev.transit.ui.theme.LateRed import com.cornellappdev.transit.ui.theme.LiveGreen import com.cornellappdev.transit.ui.theme.SecondaryText +import com.cornellappdev.transit.ui.theme.robotoFamily +import com.cornellappdev.transit.util.HIGH_CAPACITY_THRESHOLD +import com.cornellappdev.transit.util.MEDIUM_CAPACITY_THRESHOLD import com.cornellappdev.transit.util.TimeUtils.toPascalCaseString import com.google.android.gms.maps.model.LatLng import dagger.hilt.android.lifecycle.HiltViewModel @@ -37,7 +45,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime @@ -348,22 +355,24 @@ class HomeViewModel @Inject constructor( } /** - * Given operating hours rotated for today's date, return whether it is open and when it is open until + * Given operating hours, return whether it is open and when it is open until * or when it will next open * * @param operatingHours A list of pairs mapping the first value day string to second value list of hours open */ - private fun getOpenStatus( + fun getOpenStatus( operatingHours: List, currentDateTime: LocalDateTime = LocalDateTime.now() ): OpenStatus { + val rotatedOperatingHours = rotateOperatingHours(operatingHours) val currentTime = currentDateTime.toLocalTime() - val todaySchedule = operatingHours[0].hours // First day should be today after rotation + val todaySchedule = + rotatedOperatingHours[0].hours // First day should be today after rotation // Check if closed today if (todaySchedule.any { it.equals("Closed", ignoreCase = true) }) { - return findOpenNextDay(operatingHours) + return findOpenNextDay(rotatedOperatingHours) } val timeRanges = todaySchedule.mapNotNull { parseTimeRange(it) } @@ -383,7 +392,7 @@ class HomeViewModel @Inject constructor( } // Closed for today, find next open day - return findOpenNextDay(operatingHours) + return findOpenNextDay(rotatedOperatingHours) } /** @@ -445,10 +454,40 @@ class HomeViewModel @Inject constructor( */ fun isOpenAnnotatedStringFromOperatingHours(operatingHours: List): AnnotatedString { return getOpenStatusAnnotatedString( - getOpenStatus( - rotateOperatingHours(operatingHours) - ) + getOpenStatus(operatingHours) ) } + /** + * Format percent string based on a gym's current capacity + */ + fun capacityPercentAnnotatedString(capacity: UpliftCapacity?): AnnotatedString { + + // Return empty string if no capacity data available + if (capacity == null) { + return AnnotatedString("") + } + + val color = if (capacity.percent <= MEDIUM_CAPACITY_THRESHOLD) { + AccentOpen + } else if (capacity.percent >= HIGH_CAPACITY_THRESHOLD) { + AccentClosed + } else { + AccentOrange + } + + return buildAnnotatedString { + withStyle( + style = SpanStyle( + fontSize = 14.sp, + fontFamily = robotoFamily, + fontWeight = FontWeight(600), + color = color, + ) + ) { + append("${capacity.percentString()} full") + } + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/util/ColorUtils.kt b/app/src/main/java/com/cornellappdev/transit/util/ColorUtils.kt new file mode 100644 index 0000000..d3c7e93 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/transit/util/ColorUtils.kt @@ -0,0 +1,37 @@ +package com.cornellappdev.transit.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb + +// Source: Uplift Android + +/** + * Interpolates a color between [color1] and [color2] by choosing a color a [fraction] in between. + * Uses HSV interpolation, which generally gives more aesthetically pleasing results than RGB. + * + * @param fraction Float in [0..1]. 0 = color1, 1 = color2. In between interpolates between. + */ +fun colorInterp(fraction: Float, color1: Color, color2: Color): Color { + val fractionToUse = fraction.coerceIn(0f, 1f) + val HSV1 = FloatArray(3) + val HSV2 = FloatArray(3) + android.graphics.Color.colorToHSV(color1.toArgb(), HSV1) + android.graphics.Color.colorToHSV(color2.toArgb(), HSV2) + + for (i in 0..2) { + HSV2[i] = interpolate(fractionToUse, HSV1[i], HSV2[i]) + } + return Color.hsv( + HSV2[0], + HSV2[1], + HSV2[2], + interpolate(fractionToUse, color1.alpha, color2.alpha) + ) +} + +/** + * Interpolates between two floats. + */ +private fun interpolate(fraction: Float, a: Float, b: Float): Float { + return a + (b - a) * fraction +} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/util/ContentConstants.kt b/app/src/main/java/com/cornellappdev/transit/util/ContentConstants.kt index d43f21d..1f47111 100644 --- a/app/src/main/java/com/cornellappdev/transit/util/ContentConstants.kt +++ b/app/src/main/java/com/cornellappdev/transit/util/ContentConstants.kt @@ -1,11 +1,13 @@ package com.cornellappdev.transit.util +import android.adservices.adid.AdId + /** * Temporary mapping for about content */ fun getAboutContent(key: String): String { - val aboutContent = buildMap { + val aboutContent = buildMap { put("104West!", "Cornell's kosher and multicultural dining room is STAR-K and STAR-D certified.") put("Becker House Dining Room", "Dining room located in Carl Becker House on West Campus. Open only to residents from 6-7pm Wednesdays for House Dinners.") put("Cook House Dining Room", "Dining room located in Alice Cook House on West Campus. Open only to residents from 6-7pm Wednesdays for House Dinners.") @@ -16,7 +18,28 @@ fun getAboutContent(key: String): String { put("Okenshields", "Dining room located in Willard Straight Hall on Central Campus.") put("Risley Dining Room", "Risley is our gluten-free, tree nut free and peanut free dining room under the AllerCheckā„¢\uFE0F approved by MenuTrinfoĀ®\uFE0F program, in Risley Residential College on North Campus.") put("Rose House Dining Room", "Dining room located in Flora Rose House on West Campus. Open only to residents from 6-7pm Wednesdays for House Dinners.") + put("Helen Newman", "Helen Newman Hall features a Pool, two-full sized Courts for Basketball/Volleyball/Badminton, a classroom and a dance studio where many Group Fitness classes, Physical Education classes, and club practices are held, a 16-lane Bowling Center and a Fitness Center. Helen Newman is also the home to the majority of the Recreational Services Administration Offices.") + put("Toni Morrison", "The Toni Morrison Fitness Center is located in the basement of Toni Morrison Hall.") + put("Noyes", "The Noyes Fitness Center is located on the second floor of the Noyes Community Recreation Center, adjacent to Jansen's Market.") + put("Teagle Down", "The Teagle Downstairs Fitness Center is located on the ground floor of Teagle Hall. The entrance of this Fitness Center is adjacent from the entrance to Teagle Hall from the parking lot facing the Lynah Ice Rink.") + put("Teagle Up", "The Teagle Upstairs Fitness Center is located on the second floor of Teagle Hall. The staircase to the entrance of this Fitness Center is directly across from the entrance to Teagle Hall from the parking lot facing the Lynah Ice Rink.") } return aboutContent.getOrDefault(key, "") +} + +/** + * Mapping gym names to location descriptions + */ +fun getGymLocationString(key: String): String { + val locationStrings = buildMap { + put("Helen Newman", "Helen Newman Hall") + put("Toni Morrison", "Toni Morrison Hall") + put("Noyes", "Noyes Community Recreation Center") + put("Teagle Up", "Teagle Hall") + put("Teagle Down", "Teagle Hall") + + } + + return locationStrings.getOrDefault(key, "") } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/util/TimeUtils.kt b/app/src/main/java/com/cornellappdev/transit/util/TimeUtils.kt index 34642b0..aabe29f 100644 --- a/app/src/main/java/com/cornellappdev/transit/util/TimeUtils.kt +++ b/app/src/main/java/com/cornellappdev/transit/util/TimeUtils.kt @@ -14,6 +14,33 @@ import java.util.Locale */ object TimeUtils { + /** + * Value to represent the custom order of days in a week (with Sunday as + * the first day). Sunday first indexing courtesy of Eatery + */ + val dayOrder = mapOf( + "Sunday" to 0, + "Monday" to 1, + "Tuesday" to 2, + "Wednesday" to 3, + "Thursday" to 4, + "Friday" to 5, + "Saturday" to 6 + ) + + /** + * Mapping from order to day + */ + val dayString = mapOf( + 0 to "Sunday", + 1 to "Monday", + 2 to "Tuesday", + 3 to "Wednesday", + 4 to "Thursday", + 5 to "Friday", + 6 to "Saturday" + ) + /** * Formatter for date and time. Ex: Mar 17, 1998 4:42 AM */ diff --git a/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt b/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt index 694b25b..19ba516 100644 --- a/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt +++ b/app/src/main/java/com/cornellappdev/transit/util/TransitConstants.kt @@ -6,4 +6,10 @@ const val ECOSYSTEM_FLAG = BuildConfig.ECOSYSTEM_FLAG const val BOTTOM_SHEET_MAX_HEIGHT_PERCENT = 90 +/** When the capacity turns to orange */ +const val MEDIUM_CAPACITY_THRESHOLD = 0.35f + +/** When the capacity turns to red */ +const val HIGH_CAPACITY_THRESHOLD = 0.65f + const val NOTIFICATIONS_ENABLED = false \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/transit/util/ecosystem/PlaceUtils.kt b/app/src/main/java/com/cornellappdev/transit/util/ecosystem/PlaceUtils.kt deleted file mode 100644 index 79a75e2..0000000 --- a/app/src/main/java/com/cornellappdev/transit/util/ecosystem/PlaceUtils.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.cornellappdev.transit.util.ecosystem - -import com.cornellappdev.transit.models.Place -import com.cornellappdev.transit.models.PlaceType -import com.cornellappdev.transit.models.ecosystem.Eatery -import com.cornellappdev.transit.models.ecosystem.Library -import com.cornellappdev.transit.models.ecosystem.Printer - - -/** - * Predefined mapping from library to generic place - */ -fun Library.toPlace(): Place = Place( - latitude = this.latitude, - longitude = this.longitude, - name = this.location, - detail = this.address, - type = PlaceType.APPLE_PLACE -) - -/** - * Predefined mapping from printer to generic place - */ -fun Printer.toPlace(): Place = Place( - latitude = this.latitude, - longitude = this.longitude, - name = this.location, - detail = this.description, - type = PlaceType.APPLE_PLACE -) - -/** - * Predefined mapping from eatery to generic place. Nullable latitudes and longitudes default to 0 - */ -fun Eatery.toPlace(): Place = Place( - latitude = this.latitude ?: 0.0, - longitude = this.longitude ?: 0.0, - name = this.name, - detail = this.location, - type = PlaceType.APPLE_PLACE -) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6488bf..bc71cee 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,4 +10,5 @@ Covers humanities and social sciences with circulation and reference services. Reserve a room View Menu on Eatery + Learn More on Uplift \ No newline at end of file