diff --git a/core/entities/src/commonMain/kotlin/business/Currency.kt b/core/entities/src/commonMain/kotlin/business/Currency.kt index 1bc2167b..baa278f5 100644 --- a/core/entities/src/commonMain/kotlin/business/Currency.kt +++ b/core/entities/src/commonMain/kotlin/business/Currency.kt @@ -22,6 +22,16 @@ interface Currency: Comparable { fun format(): String { return form.format(amount) } + operator fun plus(other: Currency): Currency { + when (other.form.centsPerUnit) { + form.centsPerUnit -> return Currency(amount + other.amount, form) + else -> { + val thisValue = amount + val otherValue = other.amount * other.form.centsPerUnit / form.centsPerUnit + return Currency(thisValue + otherValue, form) + } + } + } sealed class Form( val prefix: String, diff --git a/core/entities/src/commonMain/kotlin/geo/Length.kt b/core/entities/src/commonMain/kotlin/geo/Length.kt index 4894dbbf..c78194c2 100644 --- a/core/entities/src/commonMain/kotlin/geo/Length.kt +++ b/core/entities/src/commonMain/kotlin/geo/Length.kt @@ -15,6 +15,10 @@ interface Length: Comparable { fun to(otherUnit: LengthUnit): Length fun format(): String + operator fun plus(other: Length): Length { + val otherToThis = other.to(unit) + return LengthValue(value + otherToThis.value, unit) + } } enum class LengthUnit( diff --git a/feature/drive/src/commonMain/kotlin/data/DriverSettingsRepository.kt b/feature/drive/src/commonMain/kotlin/data/DriverSettingsRepository.kt new file mode 100644 index 00000000..90babffa --- /dev/null +++ b/feature/drive/src/commonMain/kotlin/data/DriverSettingsRepository.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.drive.data + +import org.pointyware.xyz.core.entities.geo.LatLong +import org.pointyware.xyz.drive.RideFilter +import org.pointyware.xyz.drive.entities.DriverRates + +/** + * Maintains Driver settings. + */ +interface DriverSettingsRepository { + /** + * + */ + fun getFilter(): RideFilter + /** + * + */ + fun setFilter(filter: RideFilter) + /** + * + */ + fun getDriverRates(): DriverRates + /** + * + */ + fun setDriverRates(rates: DriverRates) + + fun getDriverLocation(): LatLong + fun setDriverLocation(location: LatLong) +} diff --git a/feature/drive/src/commonMain/kotlin/entities/DriverRates.kt b/feature/drive/src/commonMain/kotlin/entities/DriverRates.kt new file mode 100644 index 00000000..a19a1e07 --- /dev/null +++ b/feature/drive/src/commonMain/kotlin/entities/DriverRates.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.drive.entities + +import org.pointyware.xyz.core.entities.business.Rate + +/** + * Rates for a driver's services. + */ +data class DriverRates( + val maintenanceCost: Rate, + val pickupCost: Rate, + val dropoffCost: Rate, +) diff --git a/feature/drive/src/commonMain/kotlin/interactors/EstimatedRequest.kt b/feature/drive/src/commonMain/kotlin/interactors/EstimatedRequest.kt new file mode 100644 index 00000000..ac459e63 --- /dev/null +++ b/feature/drive/src/commonMain/kotlin/interactors/EstimatedRequest.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.drive.interactors + +import org.pointyware.xyz.core.entities.geo.Length +import org.pointyware.xyz.drive.entities.DriverRates +import org.pointyware.xyz.drive.entities.Request + +/** + * A request with estimated pickup distance and driver rates. + */ +data class EstimatedRequest( + val request: Request, + val pickupDistance: Length, + val driverRates: DriverRates +) diff --git a/feature/drive/src/commonMain/kotlin/interactors/WatchRatedRequests.kt b/feature/drive/src/commonMain/kotlin/interactors/WatchRatedRequests.kt new file mode 100644 index 00000000..370b4e5c --- /dev/null +++ b/feature/drive/src/commonMain/kotlin/interactors/WatchRatedRequests.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.drive.interactors + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.pointyware.xyz.drive.data.DriverSettingsRepository +import org.pointyware.xyz.drive.data.RideRepository + +/** + * + */ +class WatchRatedRequests( + private val repository: RideRepository, + private val driverSettingsRepository: DriverSettingsRepository +) { + + suspend fun invoke(): Result>> { + val filter = driverSettingsRepository.getFilter() + val rates = driverSettingsRepository.getDriverRates() + val location = driverSettingsRepository.getDriverLocation() + + repository.watchRequests(filter) + .onSuccess { flow -> + val estimatedFlow = flow.map { request -> + request.map { + EstimatedRequest( + it, + location.distanceTo(it.route.start.coordinates), + rates + ) + } + } + return Result.success(estimatedFlow) + } + .onFailure { + return Result.failure(it) + } + throw IllegalStateException("Unreachable code") + } +} diff --git a/feature/drive/src/commonMain/kotlin/ui/RideRequestView.kt b/feature/drive/src/commonMain/kotlin/ui/RideRequestView.kt index a371d6af..6c12f0b2 100644 --- a/feature/drive/src/commonMain/kotlin/ui/RideRequestView.kt +++ b/feature/drive/src/commonMain/kotlin/ui/RideRequestView.kt @@ -4,12 +4,18 @@ package org.pointyware.xyz.drive.ui +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import org.pointyware.xyz.drive.viewmodels.RideRequestUiState /** @@ -24,14 +30,75 @@ fun RideRequestView( ) { Column( modifier = modifier + .fillMaxWidth() ) { - Text("New Ride Request") - Text(state.riderName) - Text(state.route.start.name) - Text(state.route.end.name) - Text(state.distanceFromDriver.toString()) - Text(state.route.distance.toString()) - Text(state.totalFair.toString()) + Row { + // AsyncImage( + // model = ImageRequest(), + // contentDescription = "Start location image", + // modifier = Modifier.fillMaxWidth() + // ) + Text( + text = state.riderName, + modifier = Modifier.semantics { + contentDescription = "Rider Name: ${state.riderName}" + } + ) + } + Row( + modifier = Modifier.semantics(mergeDescendants = true) {} + ) { + Image( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Pickup Location" + ) + Text( + text = state.route.start.name, + ) + } + Row( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = "Pickup Metrics" + } + ) { + Text( + text = state.pickupDistance.format() + " @ " + state.pickupRate.format(), + ) + Image( + imageVector = Icons.Default.PlayArrow, + contentDescription = null + ) + Text( + text = state.pickupPrice.format() + " - " + state.pickupCost.format(), + ) + } + Row( + modifier = Modifier.semantics(mergeDescendants = true) {} + ) { + Image( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Dropoff Location" + ) + Text( + text = state.route.end.name, + ) + } + Row( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = "Route Metrics" + } + ) { + Text( + text = state.dropoffDistance.format() + " @ " + state.dropoffRate.format(), + ) + Image( + imageVector = Icons.Default.PlayArrow, + contentDescription = null + ) + Text( + text = state.dropoffPrice.format() + " - " + state.dropoffCost.format(), + ) + } Row { Button(onClick = onReject) { diff --git a/feature/drive/src/commonMain/kotlin/viewmodels/DriveViewModel.kt b/feature/drive/src/commonMain/kotlin/viewmodels/DriveViewModel.kt index 69b68a91..87ccab09 100644 --- a/feature/drive/src/commonMain/kotlin/viewmodels/DriveViewModel.kt +++ b/feature/drive/src/commonMain/kotlin/viewmodels/DriveViewModel.kt @@ -11,15 +11,16 @@ import kotlinx.coroutines.launch import org.pointyware.xyz.core.entities.Uuid import org.pointyware.xyz.core.entities.geo.LatLong import org.pointyware.xyz.core.viewmodels.MapViewModelImpl -import org.pointyware.xyz.drive.SimpleRideFilter import org.pointyware.xyz.drive.data.RideRepository +import org.pointyware.xyz.drive.interactors.WatchRatedRequests import org.pointyware.xyz.drive.ui.DriveScreenState /** * */ class DriveViewModel( - private val repository: RideRepository + private val repository: RideRepository, + private val watchRatedRequests: WatchRatedRequests ): MapViewModelImpl() { private val mutableState = MutableStateFlow(DriveScreenState.AvailableRequests(emptyList())) @@ -35,17 +36,19 @@ class DriveViewModel( private fun watchRequests() { requestsJob?.cancel() requestsJob = viewModelScope.launch { - repository.watchRequests(SimpleRideFilter.Permissive) + watchRatedRequests.invoke() .onSuccess { flow -> flow.collect { requestList -> mutableState.value = DriveScreenState.AvailableRequests( requests = requestList.map { + val request = it.request RideRequestUiStateImpl( - requestId = it.rideId, - riderName = it.rider.name.given, - route = it.route, - distanceFromDriver = it.route.start.coordinates.distanceTo(driverLocation), - riderServiceRate = it.rate + requestId = request.rideId, + riderName = request.rider.name.given, + route = request.route, + riderServiceRate = request.rate, + it.pickupDistance, + it.driverRates ) } ) diff --git a/feature/drive/src/commonMain/kotlin/viewmodels/RideRequestUiState.kt b/feature/drive/src/commonMain/kotlin/viewmodels/RideRequestUiState.kt index 8da9863d..af1a7e3c 100644 --- a/feature/drive/src/commonMain/kotlin/viewmodels/RideRequestUiState.kt +++ b/feature/drive/src/commonMain/kotlin/viewmodels/RideRequestUiState.kt @@ -8,8 +8,8 @@ import org.pointyware.xyz.core.entities.Uuid import org.pointyware.xyz.core.entities.business.Currency import org.pointyware.xyz.core.entities.business.Rate import org.pointyware.xyz.core.entities.geo.Length -import org.pointyware.xyz.core.entities.geo.Location import org.pointyware.xyz.core.entities.geo.Route +import org.pointyware.xyz.drive.entities.DriverRates /** * Displays information about a new ride request: distance from driver, distance of route, and rider service rate. @@ -18,18 +18,59 @@ interface RideRequestUiState { val requestId: Uuid val riderName: String val route: Route - val distanceFromDriver: Length val riderServiceRate: Rate val totalFair: Currency + + val pickupDistance: Length + val dropoffDistance: Length + val totalDistance: Length + + val pickupRate: Rate + val dropoffRate: Rate + val maintenanceRate: Rate + + val pickupPrice: Currency + val dropoffPrice: Currency + + val pickupCost: Currency + val dropoffCost: Currency + + val grossProfit: Currency } data class RideRequestUiStateImpl( override val requestId: Uuid, override val riderName: String, override val route: Route, - override val distanceFromDriver: Length, override val riderServiceRate: Rate, + + override val pickupDistance: Length, + val driverRates: DriverRates ): RideRequestUiState { override val totalFair: Currency get() = riderServiceRate * route.distance + + override val dropoffDistance: Length + get() = route.distance + override val totalDistance: Length + get() = pickupDistance + dropoffDistance + + override val pickupRate: Rate + get() = driverRates.pickupCost + override val dropoffRate: Rate + get() = driverRates.dropoffCost + override val maintenanceRate: Rate + get() = driverRates.maintenanceCost + + override val pickupPrice: Currency + get() = pickupRate * pickupDistance + override val dropoffPrice: Currency + get() = dropoffRate * dropoffDistance + override val grossProfit: Currency + get() = pickupPrice + dropoffPrice + + override val pickupCost: Currency + get() = maintenanceRate * pickupDistance + override val dropoffCost: Currency + get() = maintenanceRate * dropoffDistance } diff --git a/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/ui/RideRequestViewUnitTest.kt b/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/ui/RideRequestViewUnitTest.kt new file mode 100644 index 00000000..56985822 --- /dev/null +++ b/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/ui/RideRequestViewUnitTest.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.drive.ui + +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.runComposeUiTest +import org.pointyware.xyz.core.entities.Uuid +import org.pointyware.xyz.core.entities.business.Rate.Companion.div +import org.pointyware.xyz.core.entities.business.dollarCents +import org.pointyware.xyz.core.entities.geo.LengthUnit +import org.pointyware.xyz.core.entities.geo.Location +import org.pointyware.xyz.core.entities.geo.Route +import org.pointyware.xyz.core.entities.geo.miles +import org.pointyware.xyz.core.ui.design.XyzTheme +import org.pointyware.xyz.core.ui.di.EmptyTestUiDependencies +import org.pointyware.xyz.drive.entities.DriverRates +import org.pointyware.xyz.drive.ui.RideRequestView +import org.pointyware.xyz.drive.viewmodels.RideRequestUiState +import org.pointyware.xyz.drive.viewmodels.RideRequestUiStateImpl +import kotlin.test.Test +import kotlin.time.Duration.Companion.minutes + +/** + * + */ +@OptIn(ExperimentalTestApi::class) +class RideRequestViewUnitTest { + + + private fun ComposeUiTest.contentUnderTest() { + val missionOfHopeLocation = Location( + 36.103334, -97.051601, + name = "Mission of Hope", + address = "1804 S Perkins Rd", + ) + val walmartLocation = Location( + 36.124656, -97.048823, + name = "Walmart", + address = "111 N Perkins Rd", + ) + val pickupDistance = 1.1.miles() + val pickupDuration = 4.minutes + val routeDistance = 1.6.miles() + val routeDuration = 5.minutes + setContent { + XyzTheme( + uiDependencies = EmptyTestUiDependencies() + ) { + RideRequestView( + state = RideRequestUiStateImpl( + requestId = Uuid.v4(), + riderName = "John", + route = Route( + start = missionOfHopeLocation, + intermediates = emptyList(), + end = walmartLocation, + distance = routeDistance, + duration = routeDuration + ), + riderServiceRate = 100L.dollarCents() / LengthUnit.MILES, + pickupDistance = pickupDistance, + driverRates = DriverRates( + 16L.dollarCents() / LengthUnit.MILES, + 0L.dollarCents() / LengthUnit.MILES, + 83L.dollarCents() / LengthUnit.MILES + ) + ), + onAccept = {}, + onReject = {}, + ) + } + } + } + + @Test + fun `request view displays rider name`() = runComposeUiTest { + + contentUnderTest() + + onNodeWithContentDescription("Rider Name", substring = true) + .assertExists() + .assertTextContains("John", substring = true) + } + + @Test + fun `request view displays pickup location + distance + cost`() = runComposeUiTest { + + contentUnderTest() + + onNodeWithContentDescription("Pickup Location", substring = true) + .assertExists() + .assertTextContains("Mission of Hope", substring = true) + onNodeWithContentDescription("Pickup Metrics", substring = true) + .assertExists() + .assertTextContains("1.10 mi @ $0.00/mi", substring = true) + .assertTextContains("$0.00 - $0.17", substring = true) + } + + @Test + fun `request view displays dropoff location + distance + cost`() = runComposeUiTest { + + contentUnderTest() + + onNodeWithContentDescription("Dropoff Location", substring = true) + .assertExists() + .assertTextContains("Walmart", substring = true) + onNodeWithContentDescription("Route Metrics", substring = true) + .assertExists() + .assertTextContains("1.60 mi @ $0.83/mi", substring = true) + .assertTextContains("$1.32 - $0.25", substring = true) + } +}