diff --git a/.github/workflows/main-release-flow.yml b/.github/workflows/main-release-flow.yml index 813d48bf..ed0488e1 100644 --- a/.github/workflows/main-release-flow.yml +++ b/.github/workflows/main-release-flow.yml @@ -30,7 +30,6 @@ jobs: - name: "Build: Assemble and Test entire project" run: | ./gradlew :app-android:buildRelease - ./gradlew testReleaseUnitTest # - run: ./gradlew generate coverage report, upload test/coverage reports diff --git a/app-shared/src/commonTest/kotlin/org.pointyware.xyz.shared/ride/RequestRideUiTest.kt b/app-shared/src/commonTest/kotlin/org.pointyware.xyz.shared/ride/RequestRideUiTest.kt deleted file mode 100644 index 699cd700..00000000 --- a/app-shared/src/commonTest/kotlin/org.pointyware.xyz.shared/ride/RequestRideUiTest.kt +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. - */ - -package org.pointyware.xyz.shared.ride - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.filterToOne -import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.onChildren -import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.runComposeUiTest -import androidx.compose.ui.test.waitUntilDoesNotExist -import org.koin.core.context.loadKoinModules -import org.koin.core.context.stopKoin -import org.koin.mp.KoinPlatform.getKoin -import org.pointyware.xyz.core.navigation.StackNavigationController -import org.pointyware.xyz.core.ui.design.XyzTheme -import org.pointyware.xyz.core.ui.di.EmptyTestUiDependencies -import org.pointyware.xyz.feature.ride.di.featureRideDataTestModule -import org.pointyware.xyz.feature.ride.ui.PassengerDashboardScreen -import org.pointyware.xyz.feature.ride.viewmodels.PassengerDashboardUiState -import org.pointyware.xyz.feature.ride.viewmodels.RideViewModel -import org.pointyware.xyz.shared.di.setupKoin -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * System/UI Test for Rider Request Ride View - */ -@OptIn(ExperimentalTestApi::class) -class RequestRideUiTest { - - @BeforeTest - fun setUp() { - setupKoin() - loadKoinModules(listOf( - featureRideDataTestModule() - )) - } - - @AfterTest - fun tearDown() { - stopKoin() - } - - @Test - fun request_ride() = runComposeUiTest { - val di = getKoin() - val viewModel = di.get() - val navController = di.get>() - - assertEquals(PassengerDashboardUiState.Idle, viewModel.state.value, "Initial state is Idle") - - /* - Given: - - User is on the Ride Screen - - UiState is Idle - When: - - No Events - Then: - - The "New Ride" button is shown - */ - - setContent { - XyzTheme( - uiDependencies = EmptyTestUiDependencies() - ) { - PassengerDashboardScreen( - viewModel, - navController - ) - } - } - - - onNodeWithText("New Ride") - .assertExists() - .assertIsEnabled() - - /* - When: - - User clicks on the "New Ride" button - Then: - - The "New Ride" button transforms into the Search Bar - - The "Confirm" search button is shown but disabled - */ - onNodeWithText("New Ride") - .performClick() - - onNodeWithText("Search") - .assertExists() - onNodeWithText("Confirm") - .assertExists() - .assertIsNotEnabled() - - /* - When: - - User types "Red Rock" into the Search Bar - Then: - - The Search Bar displays "Red Rock" - - The "Confirm" search button is enabled - */ - onNodeWithText("Search") - .performTextInput("Red Rock") - onNodeWithText("Search") - .assert(hasText("Red Rock")) - onNodeWithText("Confirm") - .assertIsEnabled() - - /* - When: - - User clicks on the "Confirm" button - Then: - - Location suggestion list is shown - */ - onNodeWithText("Confirm") - .performClick() - onNodeWithContentDescription("Location Suggestions") - .assertExists() - - /* - When: - - User clicks on the "Red Rock" suggestion - Then: - - The Map is updated with the "Red Rock" destination - - The waiting indicator is shown while the route is calculated - - The "Confirm Route" button is shown but disabled while the route is calculated - */ - onNodeWithContentDescription("Location Suggestions") - .onChildren().filterToOne(hasText("Red Rock", substring = true)) - .assertExists() - .performClick() - // TODO: Assert that the map is updated - onNodeWithContentDescription("Loading") - .assertExists() - onNodeWithText("Confirm Route") - .assertExists() - .assertIsNotEnabled() - - waitUntilDoesNotExist(hasContentDescription("Loading"), 2000L) - - /* - When: - - The route is calculated - Then: - - The "Confirm Route" button is shown - - The waiting indicator is no longer shown - */ - onNodeWithText("Confirm Route") - .assertExists() - .assertIsEnabled() - onNodeWithContentDescription("Loading") - .assertDoesNotExist() - - /* - When: - - User clicks on the "Confirm Route" button - Then: - - The "Cancel Request" button is shown - */ - onNodeWithText("Confirm Route") - .performClick() - onNodeWithText("Cancel Request") - .assertExists() - .assertIsEnabled() - } -} diff --git a/core/common/src/commonMain/kotlin/org.pointyware.xyz.core.common/ParameterConstraints.kt b/core/common/src/commonMain/kotlin/org.pointyware.xyz.core.common/ParameterConstraints.kt index 28cff355..27f80b19 100644 --- a/core/common/src/commonMain/kotlin/org.pointyware.xyz.core.common/ParameterConstraints.kt +++ b/core/common/src/commonMain/kotlin/org.pointyware.xyz.core.common/ParameterConstraints.kt @@ -7,14 +7,17 @@ package org.pointyware.xyz.core.common /** * Expresses a constraint on the length of a string. */ -annotation class StringLength(val min: Int, val max: Int) +@Target(AnnotationTarget.PROPERTY) +annotation class StringLength(val min: Int = 0, val max: Int = Int.MAX_VALUE) /** * Expresses a constraint on the range of an integer. */ -annotation class IntRange(val min: Int, val max: Int) +@Target(AnnotationTarget.PROPERTY) +annotation class IntRange(val min: Int = Int.MIN_VALUE, val max: Int = Int.MAX_VALUE) /** * Expresses a regular expression constraint on a string. */ -annotation class Regex(val pattern: String) +@Target(AnnotationTarget.PROPERTY) +annotation class Regex(val pattern: String = "^.*$") diff --git a/core/entities/src/commonMain/kotlin/ride/Accommodation.kt b/core/entities/src/commonMain/kotlin/ride/Accommodation.kt index 51b664e6..c22322ea 100644 --- a/core/entities/src/commonMain/kotlin/ride/Accommodation.kt +++ b/core/entities/src/commonMain/kotlin/ride/Accommodation.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable * Describes a driver accommodation that can be provided to a rider with a [Disability]. */ @Serializable -sealed class Accommodation { - data object WheelchairAccess : Accommodation() - data object AnimalFriendly : Accommodation() +sealed class Accommodation(val name: String) { // TODO: replace with resource ID for i18n + data object WheelchairAccess : Accommodation("Wheelchair Access") + data object AnimalFriendly : Accommodation("Animal Friendly") } diff --git a/core/entities/src/commonMain/kotlin/ride/Ride.kt b/core/entities/src/commonMain/kotlin/ride/Ride.kt index d6d26a81..3a403c27 100644 --- a/core/entities/src/commonMain/kotlin/ride/Ride.kt +++ b/core/entities/src/commonMain/kotlin/ride/Ride.kt @@ -24,7 +24,7 @@ sealed interface Ride { /** * The current status of the ride as it progresses through the system. */ - val status: Status + val status: Status // TODO: remove; redundant /** * The time the ride was posted to the system by the rider. @@ -110,6 +110,14 @@ sealed interface Ride { val timeToStart: Instant ): Status, RouteProgress.Unrealized + /** + * The ride has been accepted by a driver for future completion. + * Possible transitions are [Active], [Ended] + */ + data class AcceptedImmediate( + val timeAccepted: Instant + ): Status, RouteProgress.Unrealized + /** * The ride is in progress. * Possible transitions are [Ended] @@ -242,7 +250,7 @@ data class PendingRide( ): Ride { override val status: Ride.Status - get() = Ride.Status.Immediate + get() = Ride.Status.AcceptedImmediate(timeAccepted) fun arrive(timeArrived: Instant): ActiveRide { return ActiveRide( @@ -271,7 +279,7 @@ data class ActiveRide( ): Ride { override val status: Ride.Status - get() = Ride.Status.Active(TODO()) + get() = Ride.Status.Active(plannedRoute) fun start(timeStarted: Instant): CompletingRide { return CompletingRide( @@ -320,7 +328,7 @@ data class CompletingRide( ): Ride { override val status: Ride.Status - get() = Ride.Status.Active(TODO()) + get() = Ride.Status.Active(plannedRoute) fun complete(timeEnded: Instant): CompletedRide { return CompletedRide( @@ -349,5 +357,5 @@ data class CompletedRide( override val timeEnded: Instant, ): Ride { override val status: Ride.Status - get() = Ride.Status.Completed(TODO()) + get() = Ride.Status.Completed(plannedRoute) } diff --git a/core/ui/src/commonMain/kotlin/MessageInput.kt b/core/ui/src/commonMain/kotlin/MessageInput.kt new file mode 100644 index 00000000..335d64f0 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/MessageInput.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.core.ui + +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics + +/** + * + */ +@Composable +fun MessageInput( + modifier: Modifier = Modifier, +) { + TextField( + value = "", + onValueChange = {}, + modifier = modifier.semantics { contentDescription = "Message Input" } + ) +} diff --git a/core/view-models/src/commonMain/kotlin/BriefProfileUiState.kt b/core/view-models/src/commonMain/kotlin/BriefProfileUiState.kt index 33aadd43..b7b7bc17 100644 --- a/core/view-models/src/commonMain/kotlin/BriefProfileUiState.kt +++ b/core/view-models/src/commonMain/kotlin/BriefProfileUiState.kt @@ -7,6 +7,7 @@ package org.pointyware.xyz.core.viewmodels import org.pointyware.xyz.core.entities.ride.Rating import org.pointyware.xyz.core.entities.data.Uri import org.pointyware.xyz.core.entities.Uuid +import org.pointyware.xyz.core.entities.profile.Profile /** * A brief profile UI state. For more detail see [ProfileUiState]. diff --git a/feature/drive/src/commonMain/kotlin/ui/ProviderDashboardScreen.kt b/feature/drive/src/commonMain/kotlin/ui/ProviderDashboardScreen.kt index 4b22d10c..b7f5f592 100644 --- a/feature/drive/src/commonMain/kotlin/ui/ProviderDashboardScreen.kt +++ b/feature/drive/src/commonMain/kotlin/ui/ProviderDashboardScreen.kt @@ -26,6 +26,7 @@ import org.pointyware.xyz.core.navigation.XyzNavController import org.pointyware.xyz.core.ui.AdView import org.pointyware.xyz.core.ui.AdViewState import org.pointyware.xyz.core.ui.MapView +import org.pointyware.xyz.core.ui.MessageInput import org.pointyware.xyz.core.viewmodels.MapUiState import org.pointyware.xyz.drive.viewmodels.ProviderDashboardViewModel import org.pointyware.xyz.drive.viewmodels.RideRequestUiState @@ -176,17 +177,6 @@ fun DeliveryInfo( } } -@Composable -fun MessageInput( - modifier: Modifier = Modifier, -) { - TextField( - value = "", - onValueChange = {}, - modifier = modifier.semantics { contentDescription = "Message Input" } - ) -} - @Composable fun TripCompletionView( onConfirmCompletion: () -> Unit, diff --git a/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/test/KoinExt.kt b/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/test/KoinExt.kt index 6a8c33d3..fb96158e 100644 --- a/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/test/KoinExt.kt +++ b/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/test/KoinExt.kt @@ -14,7 +14,7 @@ import org.pointyware.xyz.core.viewmodels.di.coreViewModelsModule import org.pointyware.xyz.drive.di.featureDriveModule /** - * TODO: describe purpose/intent of KoinExt + * Starts koin with the required modules for testing */ fun setupKoin() { startKoin { diff --git a/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/ui/ProviderDashboardScreenUiTest.kt b/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/ui/ProviderDashboardScreenUiTest.kt index 7df83cfa..e7fce969 100644 --- a/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/ui/ProviderDashboardScreenUiTest.kt +++ b/feature/drive/src/commonTest/kotlin/org.pointyware.xyz.feature.drive/ui/ProviderDashboardScreenUiTest.kt @@ -7,13 +7,14 @@ package org.pointyware.xyz.feature.drive.ui import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onChild -import androidx.compose.ui.test.onChildren -import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilDoesNotExist +import androidx.compose.ui.test.waitUntilExactlyOneExists import kotlinx.datetime.Clock import org.koin.core.context.loadKoinModules import org.koin.core.context.stopKoin @@ -165,11 +166,7 @@ class ProviderDashboardScreenUiTest { */ rideRepository.addRequest(testRequest) - waitForIdle() - onNodeWithContentDescription("Ride Requests") - .onChildren().onFirst().assertExists() - onNodeWithText("John") - .assertExists() + waitUntilExactlyOneExists(hasText("John")) onNodeWithText("Walmart") .assertExists() onNodeWithText("Walgreens") @@ -231,9 +228,7 @@ class ProviderDashboardScreenUiTest { onNodeWithText("Pick Up") .performClick() - waitForIdle() - onNodeWithText("Pick Up") - .assertDoesNotExist() + waitUntilDoesNotExist(hasText("Pick Up")) onNodeWithContentDescription("Rider Profile") .assertExists() onNodeWithContentDescription("Message Input") @@ -253,6 +248,7 @@ class ProviderDashboardScreenUiTest { */ locationService.setLocation(LatLong(36.1162121,-97.0583766)) + waitUntilExactlyOneExists(hasText("Drop Off")) onNodeWithText("Drop Off") .assertIsEnabled() @@ -331,11 +327,8 @@ class ProviderDashboardScreenUiTest { - The request has a accept/reject buttons */ rideRepository.addRequest(testRequest) - waitForIdle() - onNodeWithContentDescription("Ride Requests") - .onChildren().onFirst().assertExists() - onNodeWithText("John") - .assertExists() + + waitUntilExactlyOneExists(hasText("John")) onNodeWithText("Walmart") .assertExists() onNodeWithText("Walgreens") diff --git a/feature/ride/src/commonMain/kotlin/ModuleInfo.kt b/feature/ride/src/commonMain/kotlin/ModuleInfo.kt deleted file mode 100644 index 5c6b9e08..00000000 --- a/feature/ride/src/commonMain/kotlin/ModuleInfo.kt +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. - */ - -package org.pointyware.xyz.feature.ride - -const val MODULE_NAME = ":feature:ride" diff --git a/feature/ride/src/commonMain/kotlin/di/RideModule.kt b/feature/ride/src/commonMain/kotlin/di/RideModule.kt deleted file mode 100644 index 65815a64..00000000 --- a/feature/ride/src/commonMain/kotlin/di/RideModule.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. - */ - -package org.pointyware.xyz.feature.ride.di - -import org.koin.core.module.dsl.bind -import org.koin.core.module.dsl.factoryOf -import org.koin.core.module.dsl.singleOf -import org.koin.dsl.module -import org.pointyware.xyz.core.common.BuildInfo -import org.pointyware.xyz.core.data.di.dataQualifier -import org.pointyware.xyz.feature.ride.data.RideRequestCache -import org.pointyware.xyz.feature.ride.data.RideRequestCacheImpl -import org.pointyware.xyz.feature.ride.data.RideRequestRepository -import org.pointyware.xyz.feature.ride.data.RideRequestRepositoryImpl -import org.pointyware.xyz.feature.ride.data.RideRequestService -import org.pointyware.xyz.feature.ride.data.RideRequestServiceImpl -import org.pointyware.xyz.feature.ride.data.TestRideRequestRepository -import org.pointyware.xyz.feature.ride.viewmodels.RideViewModel - -/** - * - */ -fun featureRideModule() = module { - single { KoinRideDependencies() } - - includes( - featureRideViewModelModule(), - featureRideDataModule() - ) -} - -fun featureRideViewModelModule() = module { - factoryOf(::RideViewModel) -} - -fun featureRideDataModule() = module { - singleOf(::RideRequestRepositoryImpl) { bind() } - singleOf(::RideRequestCacheImpl) { bind() } - singleOf(::RideRequestServiceImpl) { bind() } -} - -fun featureRideDataTestModule() = module { - single { TestRideRequestRepository(dataScope = get(qualifier = dataQualifier)) } -} diff --git a/feature/ride/src/commonMain/kotlin/data/DestinationSearchResult.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/DestinationSearchResult.kt similarity index 100% rename from feature/ride/src/commonMain/kotlin/data/DestinationSearchResult.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/DestinationSearchResult.kt diff --git a/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/PaymentRepository.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/PaymentRepository.kt new file mode 100644 index 00000000..ffba6cd5 --- /dev/null +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/PaymentRepository.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.feature.ride.data + +import org.pointyware.xyz.feature.ride.entities.PaymentMethod +import org.pointyware.xyz.feature.ride.local.PaymentStore + +interface PaymentRepository { + suspend fun getPaymentMethods(): Result> + suspend fun savePaymentMethod(paymentMethod: PaymentMethod): Result + suspend fun removePaymentMethod(paymentMethod: PaymentMethod): Result +} + +class PaymentRepositoryImpl( + private val paymentStore: PaymentStore +) : PaymentRepository { + + override suspend fun getPaymentMethods(): Result> { + return try { + Result.success(paymentStore.getPaymentMethods()) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun savePaymentMethod(paymentMethod: PaymentMethod): Result { + return try { + Result.success(paymentStore.savePaymentMethod(paymentMethod)) + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun removePaymentMethod(paymentMethod: PaymentMethod): Result { + return try { + Result.success(paymentStore.removePaymentMethod(paymentMethod)) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/feature/ride/src/commonMain/kotlin/data/RideRequestCache.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripCache.kt similarity index 91% rename from feature/ride/src/commonMain/kotlin/data/RideRequestCache.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripCache.kt index 403ea2a5..cf01ec46 100644 --- a/feature/ride/src/commonMain/kotlin/data/RideRequestCache.kt +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripCache.kt @@ -7,15 +7,15 @@ package org.pointyware.xyz.feature.ride.data /** * */ -interface RideRequestCache { +interface TripCache { suspend fun saveDestinations(query: String, searchResult: DestinationSearchResult) suspend fun getDestinations(query: String): DestinationSearchResult? suspend fun dropDestinations(query: String) } -class RideRequestCacheImpl( +class TripCacheImpl( -): RideRequestCache { +): TripCache { private val queryCache = mutableMapOf() diff --git a/feature/ride/src/commonMain/kotlin/data/RideRequestRepository.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripRepository.kt similarity index 51% rename from feature/ride/src/commonMain/kotlin/data/RideRequestRepository.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripRepository.kt index 369a58d9..92c00515 100644 --- a/feature/ride/src/commonMain/kotlin/data/RideRequestRepository.kt +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripRepository.kt @@ -6,16 +6,61 @@ package org.pointyware.xyz.feature.ride.data import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import org.pointyware.xyz.core.entities.Uuid import org.pointyware.xyz.core.entities.geo.Location -import org.pointyware.xyz.core.entities.ride.Ride import org.pointyware.xyz.core.entities.geo.Route +import org.pointyware.xyz.core.entities.profile.DriverProfile +import org.pointyware.xyz.core.entities.profile.RiderProfile +import org.pointyware.xyz.core.entities.ride.ActiveRide +import org.pointyware.xyz.core.entities.ride.CompletedRide +import org.pointyware.xyz.core.entities.ride.PendingRide +import org.pointyware.xyz.core.entities.ride.PlannedRide +import org.pointyware.xyz.core.entities.ride.Ride +import org.pointyware.xyz.core.entities.ride.planRide import kotlin.time.Duration.Companion.milliseconds +sealed interface TripEvent { + val driverProfile: DriverProfile + val ride: Ride + + data class Accepted( + override val driverProfile: DriverProfile, + override val ride: PendingRide + ): TripEvent + data class PickedUp( + override val driverProfile: DriverProfile, + override val ride: ActiveRide + ): TripEvent + data class DroppedOff( + override val driverProfile: DriverProfile, + override val ride: CompletedRide + ): TripEvent +} + /** - * Handles requests for rides. + * Handles trip search and scheduling. */ -interface RideRequestRepository { +interface TripRepository { + /** + * The current trip being taken by the user. + */ + val currentTrip: StateFlow + + /** + * Events that occur during a trip. + */ + val tripEvents: SharedFlow + suspend fun searchDestinations(query: String): Result suspend fun findRoute(origin: Location, destination: Location): Result suspend fun requestRide(route: Route): Result @@ -25,10 +70,15 @@ interface RideRequestRepository { /** * */ -class RideRequestRepositoryImpl( - private val cache: RideRequestCache, - private val service: RideRequestService, -): RideRequestRepository { +class TripRepositoryImpl( + private val cache: TripCache, + private val service: TripService, +): TripRepository { + + override val currentTrip: StateFlow + get() = TODO("Not yet implemented") + override val tripEvents: SharedFlow + get() = TODO("Not yet implemented") override suspend fun searchDestinations(query: String): Result { return service.searchDestinations(query) @@ -56,10 +106,20 @@ class RideRequestRepositoryImpl( /** * */ -class TestRideRequestRepository( +class TestTripRepository( val destinations: MutableSet = mutableSetOf(), val dataScope: CoroutineScope, -): RideRequestRepository { +): TripRepository { + + lateinit var riderProfile: RiderProfile + + private val mutableCurrentTrip = MutableStateFlow(null as Ride?) + override val currentTrip: StateFlow + get() = mutableCurrentTrip.asStateFlow() + + private val mutableTripEvents = MutableSharedFlow() + override val tripEvents: SharedFlow + get() = mutableTripEvents.asSharedFlow() private val maximumLevenshteinDistance = 20 @@ -126,14 +186,62 @@ class TestRideRequestRepository( } override suspend fun requestRide(route: Route): Result { - TODO("Not yet implemented") -// mutableNewRides.emit(ride) -// mutablePostedRides.update { it + ride } -// // no limiting criteria in tests -// return Result.success(ride) + val plannedRide = planRide( + id = Uuid.v4(), + rider = riderProfile, + plannedRoute = route, + timePosted = Clock.System.now(), + ) + mutableCurrentTrip.value = plannedRide + return Result.success(plannedRide) } override suspend fun scheduleRide(route: Route, time: Instant): Result { TODO("Not yet implemented") } + + fun acceptRequest(driverProfile: DriverProfile) { + mutableCurrentTrip.update { + when (it) { + is PlannedRide -> { + it.accept(driverProfile, Clock.System.now()).also { + dataScope.launch { + mutableTripEvents.emit(TripEvent.Accepted(driverProfile, it)) + } + } + } + else -> it + } + } + } + + fun pickUpRider() { + mutableCurrentTrip.update { + when (it) { + is PendingRide -> { + it.arrive(Clock.System.now()).also { + dataScope.launch { + mutableTripEvents.emit(TripEvent.PickedUp(it.driver, it)) + } + } + } + else -> it + } + } + } + + fun dropOffRider() { + mutableCurrentTrip.update { + when (it) { + is ActiveRide -> { + it.complete(Clock.System.now()).also { + dataScope.launch { + mutableTripEvents.emit(TripEvent.DroppedOff(it.driver, it)) + } + } + } + else -> it + } + } + } } diff --git a/feature/ride/src/commonMain/kotlin/data/RideRequestService.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripService.kt similarity index 90% rename from feature/ride/src/commonMain/kotlin/data/RideRequestService.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripService.kt index 0bcc2955..9bf79602 100644 --- a/feature/ride/src/commonMain/kotlin/data/RideRequestService.kt +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/data/TripService.kt @@ -9,7 +9,7 @@ import org.pointyware.xyz.core.entities.ride.Ride /** * Defines actions that can be performed on a remote service to request rides. */ -interface RideRequestService { +interface TripService { /** * Searches for destinations that match the given query. @@ -22,7 +22,7 @@ interface RideRequestService { suspend fun postRide(ride: Ride): Result } -class RideRequestServiceImpl : RideRequestService { +class TripServiceImpl : TripService { override suspend fun searchDestinations(query: String): Result { TODO() diff --git a/feature/ride/src/commonMain/kotlin/di/RideDependencies.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/di/RideDependencies.kt similarity index 69% rename from feature/ride/src/commonMain/kotlin/di/RideDependencies.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/di/RideDependencies.kt index a2ea5098..f5958e5a 100644 --- a/feature/ride/src/commonMain/kotlin/di/RideDependencies.kt +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/di/RideDependencies.kt @@ -6,18 +6,18 @@ package org.pointyware.xyz.feature.ride.di import org.koin.core.component.KoinComponent import org.koin.core.component.get -import org.pointyware.xyz.feature.ride.viewmodels.RideViewModel +import org.pointyware.xyz.feature.ride.viewmodels.TripViewModel /** * */ interface RideDependencies { - fun getRideViewModel(): RideViewModel + fun getRideViewModel(): TripViewModel } class KoinRideDependencies: RideDependencies, KoinComponent { - override fun getRideViewModel(): RideViewModel { + override fun getRideViewModel(): TripViewModel { return get() } } diff --git a/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/di/RideModule.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/di/RideModule.kt new file mode 100644 index 00000000..9b1a05be --- /dev/null +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/di/RideModule.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.di + +import org.koin.core.module.dsl.bind +import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module +import org.pointyware.xyz.core.data.di.dataQualifier +import org.pointyware.xyz.feature.ride.data.PaymentRepository +import org.pointyware.xyz.feature.ride.data.PaymentRepositoryImpl +import org.pointyware.xyz.feature.ride.data.TripCache +import org.pointyware.xyz.feature.ride.data.TripCacheImpl +import org.pointyware.xyz.feature.ride.data.TripRepository +import org.pointyware.xyz.feature.ride.data.TripRepositoryImpl +import org.pointyware.xyz.feature.ride.data.TripService +import org.pointyware.xyz.feature.ride.data.TripServiceImpl +import org.pointyware.xyz.feature.ride.data.TestTripRepository +import org.pointyware.xyz.feature.ride.local.PaymentStore +import org.pointyware.xyz.feature.ride.local.PaymentStoreImpl +import org.pointyware.xyz.feature.ride.local.FakePaymentStore +import org.pointyware.xyz.feature.ride.viewmodels.TripViewModel + +/** + * + */ +fun featureRideModule() = module { + single { KoinRideDependencies() } + + includes( + featureRideViewModelModule(), + featureRideDataModule() + ) +} + +fun featureRideViewModelModule() = module { + factoryOf(::TripViewModel) +} + +fun featureRideDataModule() = module { + singleOf(::TripRepositoryImpl) { bind() } + singleOf(::TripCacheImpl) { bind() } + singleOf(::TripServiceImpl) { bind() } + + singleOf(::PaymentRepositoryImpl) { bind() } + singleOf(::PaymentStoreImpl) { bind() } +} + +fun featureRideDataTestModule() = module { + single { TestTripRepository(dataScope = get(qualifier = dataQualifier)) } + single { get() } + + single { FakePaymentStore() } + single { get() } +} diff --git a/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/entities/ExpirationDate.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/entities/ExpirationDate.kt new file mode 100644 index 00000000..5b9087f0 --- /dev/null +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/entities/ExpirationDate.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.entities + +import org.pointyware.xyz.core.common.IntRange + +private const val MONTH_MIN = 1 +private const val MONTH_MAX = 12 +private const val YEAR_MIN = 2024 +private const val YEAR_MAX = 3023 + +/** + * A payment expiration date, composed of a month and year. + */ +data class ExpirationDate( + /** + * The 1-indexed month of the expiration date. + */ + @IntRange(min = MONTH_MIN, max = MONTH_MAX) + val month: Byte, + + /** + * The year of the expiration date. + */ + @IntRange(min = YEAR_MIN, max = YEAR_MAX) + val year: Short +) { + init { + require(month in MONTH_MIN..MONTH_MAX) { "Month must be between 1 and 12" } + require(year in YEAR_MIN..YEAR_MAX) { "Year must be between 2024 and 3023" } + } + + /** + * Formats the expiration date as a string in the format MM/YY. + */ + fun format(): String { + val monthStr = month.toString().padStart(2, '0') + val lastTwo = year % 100 + val yearStr = lastTwo.toString().padStart(2, '0') + return "$monthStr/$yearStr" + } +} diff --git a/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/entities/PaymentMethod.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/entities/PaymentMethod.kt new file mode 100644 index 00000000..41b55cab --- /dev/null +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/entities/PaymentMethod.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.entities + +import org.pointyware.xyz.core.entities.Uuid + +/** + * + */ +data class PaymentMethod( + val id: Uuid, +// val type: PaymentType, + val lastFour: String, + val expiration: ExpirationDate, + val cardholderName: String, + val paymentProvider: String, +// val billingAddress: Address +) diff --git a/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/local/PaymentStore.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/local/PaymentStore.kt new file mode 100644 index 00000000..ab1890eb --- /dev/null +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/local/PaymentStore.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.local + +import org.pointyware.xyz.feature.ride.entities.PaymentMethod + +/** + * A local store of payment methods. + */ +interface PaymentStore { + fun getPaymentMethods(): List + fun savePaymentMethod(paymentMethod: PaymentMethod) + fun removePaymentMethod(paymentMethod: PaymentMethod) +} + +class PaymentStoreImpl(): PaymentStore { + override fun getPaymentMethods(): List { + TODO("Not yet implemented") + } + + override fun savePaymentMethod(paymentMethod: PaymentMethod) { + TODO("Not yet implemented") + } + + override fun removePaymentMethod(paymentMethod: PaymentMethod) { + TODO("Not yet implemented") + } +} + +class FakePaymentStore( + private val methods: MutableList = mutableListOf() +): PaymentStore { + + override fun getPaymentMethods(): List { + return methods.toList() + } + + override fun savePaymentMethod(paymentMethod: PaymentMethod) { + methods.add(paymentMethod) + } + + override fun removePaymentMethod(paymentMethod: PaymentMethod) { + methods.remove(paymentMethod) + } +} diff --git a/feature/ride/src/commonMain/kotlin/navigation/RideRouting.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/navigation/RideRouting.kt similarity index 100% rename from feature/ride/src/commonMain/kotlin/navigation/RideRouting.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/navigation/RideRouting.kt diff --git a/feature/ride/src/commonMain/kotlin/ui/PassengerDashboardScreen.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PassengerDashboardScreen.kt similarity index 56% rename from feature/ride/src/commonMain/kotlin/ui/PassengerDashboardScreen.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PassengerDashboardScreen.kt index 3699c913..5a53bc34 100644 --- a/feature/ride/src/commonMain/kotlin/ui/PassengerDashboardScreen.kt +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PassengerDashboardScreen.kt @@ -9,14 +9,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier import org.pointyware.xyz.core.navigation.XyzNavController -import org.pointyware.xyz.feature.ride.viewmodels.RideViewModel +import org.pointyware.xyz.feature.ride.viewmodels.TripViewModel /** * Displays a map with controls for starting, monitoring, and canceling a ride. */ @Composable fun PassengerDashboardScreen( - viewModel: RideViewModel, + viewModel: TripViewModel, navController: XyzNavController, ) { val state = viewModel.state.collectAsState() @@ -28,13 +28,18 @@ fun PassengerDashboardScreen( state = rideViewState, loadingState = loadingState.value, modifier = Modifier.fillMaxSize(), - onStartSearch = { viewModel.startSearch() }, - onUpdateQuery = { viewModel.updateQuery(it) }, - onSendQuery = { viewModel.sendQuery() }, - onSelectLocation = { viewModel.selectLocation(it) }, - onConfirmDetails = { viewModel.confirmDetails() }, - onCancel = { viewModel.cancelRide() }, - onBack = { navController.goBack() }, - clearError = { viewModel.clearError() } + onStartSearch = viewModel::startSearch, + onUpdateQuery = viewModel::updateQuery, + onSendQuery = viewModel::sendQuery, + onSelectLocation = viewModel::selectLocation, + onConfirmDetails = viewModel::confirmDetails, + onCancel = viewModel::cancelRide, + onSelectPayment = viewModel::onSelectPayment, + onPaymentSelected = viewModel::onPaymentSelected, + onCancelTrip = viewModel::onCancelTrip, + onRateDriver = viewModel::onRateDriver, + onFinishTrip = viewModel::onFinishTrip, + onBack = navController::goBack, + clearError = viewModel::clearError, ) } diff --git a/feature/ride/src/commonMain/kotlin/ui/PassengerDashboardView.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PassengerDashboardView.kt similarity index 81% rename from feature/ride/src/commonMain/kotlin/ui/PassengerDashboardView.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PassengerDashboardView.kt index 251750f3..98aae147 100644 --- a/feature/ride/src/commonMain/kotlin/ui/PassengerDashboardView.kt +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PassengerDashboardView.kt @@ -16,6 +16,7 @@ import org.pointyware.xyz.core.ui.LoadingResultView import org.pointyware.xyz.core.ui.MapView import org.pointyware.xyz.core.viewmodels.LoadingUiState import org.pointyware.xyz.core.viewmodels.MapUiState +import org.pointyware.xyz.feature.ride.entities.PaymentMethod import org.pointyware.xyz.feature.ride.viewmodels.PassengerDashboardUiState data class PassengerDashboardViewState( @@ -35,8 +36,13 @@ fun PassengerDashboardView( onUpdateQuery: (String)->Unit, onSendQuery: ()->Unit, onSelectLocation: (Location)->Unit, + onSelectPayment: ()->Unit, + onPaymentSelected: (PaymentMethod)->Unit, onConfirmDetails: ()->Unit, onCancel: ()->Unit, + onCancelTrip: ()->Unit, + onFinishTrip: ()->Unit, + onRateDriver: ()->Unit, onBack: ()->Unit, clearError: ()->Unit ) { @@ -67,7 +73,12 @@ fun PassengerDashboardView( onSendQuery = onSendQuery, onSelectLocation = onSelectLocation, onConfirmDetails = onConfirmDetails, - onCancelRequest = onCancel + onSelectPayment = onSelectPayment, + onPaymentSelected = onPaymentSelected, + onCancelRequest = onCancel, + onCancelTrip = onCancelTrip, + onFinishTrip = onFinishTrip, + onRateDriver = onRateDriver, ) } } diff --git a/feature/ride/src/commonMain/kotlin/ui/PassengerProfileView.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PassengerProfileView.kt similarity index 100% rename from feature/ride/src/commonMain/kotlin/ui/PassengerProfileView.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PassengerProfileView.kt diff --git a/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PaymentSelection.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PaymentSelection.kt new file mode 100644 index 00000000..a4a54851 --- /dev/null +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/PaymentSelection.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import org.pointyware.xyz.feature.ride.entities.PaymentMethod + +/** + * Represents the state of the payment selection view. + */ +sealed interface PaymentSelectionViewState { + data class PaymentSelected(val method: PaymentMethod?) : PaymentSelectionViewState + data class SelectPayment(val methods: List) : PaymentSelectionViewState +} + +/** + * Displays the currently selected payment (if any) and allows the user to select a + * different payment method. + */ +@Composable +fun PaymentSelectionView( + state: PaymentSelectionViewState, + modifier: Modifier = Modifier, + onSelectPayment: ()->Unit, + onPaymentSelected: (PaymentMethod)->Unit +) { + val contentDescription = remember(state) { when(state) { + is PaymentSelectionViewState.SelectPayment -> { + "Payment Method Selection" + } + is PaymentSelectionViewState.PaymentSelected -> { + "Payment Method" + } + }} + Column( + modifier = modifier.semantics { + this.contentDescription = contentDescription + } + ) { + AnimatedContent(targetState = state, contentKey = { it::class }) { targetState -> + when (targetState) { + is PaymentSelectionViewState.PaymentSelected -> { + val method = targetState.method + if (method != null) { + Text("Selected Payment Method: ${method.paymentProvider} *${method.lastFour}") + } else { + Text("No Payment Method Selected") + } + Button(onClick = onSelectPayment) { + Text("Select Payment Method") + } + } + is PaymentSelectionViewState.SelectPayment -> { + LazyColumn { + items(targetState.methods) { method -> + Button(onClick = { onPaymentSelected(method) }) { + Text("${method.paymentProvider} *${method.lastFour}") + } + } + } + } + } + } + } +} diff --git a/feature/ride/src/commonMain/kotlin/ui/RideUiStateMapper.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/RideUiStateMapper.kt similarity index 100% rename from feature/ride/src/commonMain/kotlin/ui/RideUiStateMapper.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/RideUiStateMapper.kt diff --git a/feature/ride/src/commonMain/kotlin/ui/TripSearchView.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/TripSearchView.kt similarity index 59% rename from feature/ride/src/commonMain/kotlin/ui/TripSearchView.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/TripSearchView.kt index 4c2a0d3c..6b584fe1 100644 --- a/feature/ride/src/commonMain/kotlin/ui/TripSearchView.kt +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/ui/TripSearchView.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import org.pointyware.xyz.core.entities.geo.Location +import org.pointyware.xyz.core.ui.MessageInput +import org.pointyware.xyz.feature.ride.entities.PaymentMethod import org.pointyware.xyz.feature.ride.viewmodels.PassengerDashboardUiState data class TripSearchViewState( @@ -49,7 +51,12 @@ fun TripSearchView( onSendQuery: ()->Unit, onSelectLocation: (Location)->Unit, onConfirmDetails: ()->Unit, - onCancelRequest: ()->Unit + onSelectPayment: () -> Unit, + onPaymentSelected: (PaymentMethod) -> Unit, + onCancelRequest: ()->Unit, + onCancelTrip: ()->Unit, + onRateDriver: () -> Unit, + onFinishTrip: ()->Unit, ) { val shape = when (state) { is PassengerDashboardUiState.Idle, @@ -78,7 +85,9 @@ fun TripSearchView( state = state, onUpdateSearch = onUpdateSearch, onSendQuery = onSendQuery, - onSelectLocation = onSelectLocation + onSelectLocation = onSelectLocation, + onSelectPayment = onSelectPayment, + onPaymentSelected = onPaymentSelected, ) } @@ -101,7 +110,18 @@ fun TripSearchView( } is PassengerDashboardUiState.Riding -> { - ActiveRideView(state = state) + ActiveRideView( + state = state, + onCancelTrip = onCancelRequest + ) + } + + is PassengerDashboardUiState.Arrived -> { + CompletedRideView( + state = state, + onRateDriver = onRateDriver, + onFinishTrip = onFinishTrip + ) } } } @@ -122,39 +142,48 @@ fun ActiveSearchView( state: PassengerDashboardUiState.Search, onUpdateSearch: (String)->Unit, onSendQuery: ()->Unit, - onSelectLocation: (Location)->Unit + onSelectLocation: (Location)->Unit, + onSelectPayment: ()->Unit, + onPaymentSelected: (PaymentMethod)->Unit ) { - Row( - modifier = Modifier.fillMaxWidth() - ) { - TextField( - value = state.query, - onValueChange = onUpdateSearch, - label = { Text("Search") }, - modifier = Modifier.weight(1f), + Column { + PaymentSelectionView( + state = state.paymentSelection, + onSelectPayment = onSelectPayment, + onPaymentSelected = onPaymentSelected ) - Button( - onClick = { - onSendQuery() - }, - enabled = state.query.isNotBlank() + Row( + modifier = Modifier.fillMaxWidth() ) { - Text("Confirm") - } - var expanded by remember(state.suggestions) { mutableStateOf(state.suggestions.isNotEmpty()) } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.semantics { contentDescription = "Location Suggestions" }, - ) { - state.suggestions.forEach { suggestion -> - DropdownMenuItem( - text = { Text(suggestion.name) }, - onClick = { - expanded = false - onSelectLocation(suggestion) - } - ) + TextField( + value = state.query, + onValueChange = onUpdateSearch, + label = { Text("Search") }, + modifier = Modifier.weight(1f), + ) + Button( + onClick = { + onSendQuery() + }, + enabled = state.query.isNotBlank() + ) { + Text("Confirm") + } + var expanded by remember(state.suggestions) { mutableStateOf(state.suggestions.isNotEmpty()) } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.semantics { contentDescription = "Location Suggestions" }, + ) { + state.suggestions.forEach { suggestion -> + DropdownMenuItem( + text = { Text(suggestion.name) }, + onClick = { + expanded = false + onSelectLocation(suggestion) + } + ) + } } } } @@ -208,14 +237,59 @@ fun PostedRideView( fun AwaitingRideView( state: PassengerDashboardUiState.Waiting ) { - Text("Waiting for driver") - // TODO: rider details + Column( + modifier = Modifier.semantics { + contentDescription = "Driver Profile" + } + ) { + Text("Driver is on the way") + state.driver.accommodations.forEach { + Text(text = it.name) + } + MessageInput() + } } @Composable fun ActiveRideView( - state: PassengerDashboardUiState.Riding + state: PassengerDashboardUiState.Riding, + onCancelTrip: ()->Unit ) { - // Do nothing - // TODO: rider details + Column( + modifier = Modifier.semantics { + contentDescription = "Driver Profile" + } + ) { + Text(text = "You're on your way!") + Button(onClick = onCancelTrip) { + Text("Cancel Ride") + } + MessageInput() + } +} + +@Composable +fun CompletedRideView( + state: PassengerDashboardUiState.Arrived, + modifier: Modifier = Modifier, + onRateDriver: ()->Unit, + onFinishTrip: ()->Unit, +) { + Column( + modifier = modifier + ) { + Text("You've arrived!") + Text("Thanks for riding with us!") + + Button( + onClick = onRateDriver, + ) { + Text("Rate Driver") + } + Button( + onClick = onFinishTrip, + ) { + Text("Done") + } + } } diff --git a/feature/ride/src/commonMain/kotlin/viewmodels/PassengerDashboardUiState.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/viewmodels/PassengerDashboardUiState.kt similarity index 75% rename from feature/ride/src/commonMain/kotlin/viewmodels/PassengerDashboardUiState.kt rename to feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/viewmodels/PassengerDashboardUiState.kt index bba3d499..d06318e4 100644 --- a/feature/ride/src/commonMain/kotlin/viewmodels/PassengerDashboardUiState.kt +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/viewmodels/PassengerDashboardUiState.kt @@ -7,7 +7,8 @@ package org.pointyware.xyz.feature.ride.viewmodels import org.pointyware.xyz.core.entities.business.Currency import org.pointyware.xyz.core.entities.geo.Location import org.pointyware.xyz.core.entities.geo.Route -import org.pointyware.xyz.core.viewmodels.BriefProfileUiState +import org.pointyware.xyz.core.entities.profile.DriverProfile +import org.pointyware.xyz.feature.ride.ui.PaymentSelectionViewState /** * Represents the state of a rider's UI. @@ -24,7 +25,8 @@ sealed interface PassengerDashboardUiState { */ data class Search( val query: String = "", - val suggestions: List + val suggestions: List, + val paymentSelection: PaymentSelectionViewState ): PassengerDashboardUiState /** @@ -50,9 +52,9 @@ sealed interface PassengerDashboardUiState { */ data class Waiting( /** - * The driver's name. + * The driver's information. */ - val driver: BriefProfileUiState, + val driver: DriverProfile, /** * Driver's estimated time of arrival in minutes. */ @@ -64,8 +66,16 @@ sealed interface PassengerDashboardUiState { * The rider is in the car and the ride is in progress. */ data class Riding( - val driver: BriefProfileUiState, + val driver: DriverProfile, val route: Route, val eta: Int ): PassengerDashboardUiState + + /** + * The rider has arrived at the destination. + */ + data class Arrived( + val driver: DriverProfile, + val route: Route + ): PassengerDashboardUiState } diff --git a/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/viewmodels/TripViewModel.kt b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/viewmodels/TripViewModel.kt new file mode 100644 index 00000000..e915cba6 --- /dev/null +++ b/feature/ride/src/commonMain/kotlin/org/pointyware/xyz/feature/ride/viewmodels/TripViewModel.kt @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.viewmodels + +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +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.viewmodels.LoadingUiState +import org.pointyware.xyz.core.viewmodels.MapViewModelImpl +import org.pointyware.xyz.core.viewmodels.postError +import org.pointyware.xyz.feature.ride.data.PaymentRepository +import org.pointyware.xyz.feature.ride.data.TripEvent +import org.pointyware.xyz.feature.ride.data.TripRepository +import org.pointyware.xyz.feature.ride.entities.PaymentMethod +import org.pointyware.xyz.feature.ride.ui.PaymentSelectionViewState + +/** + * Maintains the state of a rider UI and provides actions to update it. + * + * @see PassengerDashboardUiState + */ +class TripViewModel( + private val tripRepository: TripRepository, + private val paymentRepository: PaymentRepository +): MapViewModelImpl() { + + private val userLocation = Location( + lat = 36.1031637, long = -97.0517528, + name = "Mission of Hope", + address = "1804 S Perkins Rd", zip = "74074" + ) + + private val mutableLoadingState = MutableStateFlow>(LoadingUiState.Idle()) + val loadingState: StateFlow> get() = mutableLoadingState + private val mutableState = MutableStateFlow(PassengerDashboardUiState.Idle) + val state: StateFlow get() = mutableState + + fun startSearch() { + mutableState.value = PassengerDashboardUiState.Search("", emptyList(), PaymentSelectionViewState.PaymentSelected(null)) + } + + fun updateQuery(query: String) { + mutableState.update { + if (it is PassengerDashboardUiState.Search) { + it.copy(query = query) + } else { + it + } + } + } + + fun sendQuery() { + mutableState.update { + if (it is PassengerDashboardUiState.Search) { + it.copy(suggestions = listOf( + Location(lat = 36.1314561, long = -97.0605216, name = "Red Rock Bakery", address = "910 N Boomer Rd", zip = "74075"), + Location(lat = 36.1244264, long = -97.0583594, name = "Sonic", address = "215 N Main St", zip = "74075"), + Location(lat = 36.1171898, long = -97.0509852, name = "Sonic", address = "423 S Perkins Rd", zip = "74074"), + Location(lat = 36.1150974, long = -97.1177298, name = "Sonic", address = "4425 W 6th Ave", zip = "74074"), + )) + } else { + it + } + } + } + + private fun findRoute(start: Location, end: Location) { + mutableLoadingState.value = LoadingUiState.Loading() + viewModelScope.launch { + tripRepository.findRoute(start, end) + .onSuccess { route -> + // TODO: Calculate route and price; update state + val rate = 120 // $1.20 per km + val price = (route.distance.to(LengthUnit.KILOMETERS).value * rate).toLong().dollarCents() + mutableState.update { + if (it is PassengerDashboardUiState.Confirm) { + it.copy(route = route, price = price) + } else { + it + } + } + mutableLoadingState.value = LoadingUiState.Idle() + } + .onFailure { + mutableLoadingState.postError(it) + } + } + } + + fun selectLocation(location: Location) { + mutableState.update { + if (it is PassengerDashboardUiState.Search) { + PassengerDashboardUiState.Confirm( + origin = userLocation, + destination = location, + route = null, + price = null + ).also { + findRoute(userLocation, location) + } + } else { + it + } + } + } + + fun confirmDetails() { + viewModelScope.launch { + mutableState.update { uiState -> + if (uiState is PassengerDashboardUiState.Confirm) { + val route = uiState.route ?: return@launch + val price = uiState.price ?: return@launch + tripRepository.requestRide(uiState.route) + .onFailure { + it.printStackTrace() + return@launch + } + PassengerDashboardUiState.Posted( + route = route, + price = price + ).also { + watchDriverAcceptance() + } + } else { + uiState + } + } + } + } + + private var driverAcceptanceJob: Job? = null + private fun watchDriverAcceptance() { + driverAcceptanceJob?.cancel() + driverAcceptanceJob = viewModelScope.launch { + tripRepository.tripEvents.collect { event -> + when (event) { + is TripEvent.Accepted -> { + mutableState.update { + if (it is PassengerDashboardUiState.Posted) { +// val eta = event.pendingRide.route.eta // TODO: create eta use case + PassengerDashboardUiState.Waiting( + driver = event.driverProfile, + eta = 0, + route = it.route + ) + } else { + it + } + } + } + is TripEvent.PickedUp -> { + mutableState.update { + if (it is PassengerDashboardUiState.Waiting) { +// val eta = event.pendingRide.route.eta // TODO: create eta use case + PassengerDashboardUiState.Riding( + driver = event.driverProfile, + eta = 0, + route = it.route + ) + } else { + it + } + } + } + is TripEvent.DroppedOff -> { + mutableState.update { + if (it is PassengerDashboardUiState.Riding) { + PassengerDashboardUiState.Arrived( + driver = event.driverProfile, + route = it.route + ) + } else { + it + } + } + } + } + } + } + } + + fun cancelRide() { + // TODO: send cancellation request to server + mutableState.update { + PassengerDashboardUiState.Idle + } + } + + fun clearError() { + mutableLoadingState.value = LoadingUiState.Idle() + } + + fun onSelectPayment() { + viewModelScope.launch { + paymentRepository.getPaymentMethods() + .onSuccess { methods -> + mutableState.update { + if (it is PassengerDashboardUiState.Search) { + it.copy(paymentSelection = PaymentSelectionViewState.SelectPayment(methods)) + } else { + it + } + } + } + .onFailure { + it.printStackTrace() + } + } + } + + fun onPaymentSelected(paymentMethod: PaymentMethod) { + mutableState.update { + if (it is PassengerDashboardUiState.Search) { + it.copy(paymentSelection = PaymentSelectionViewState.PaymentSelected(paymentMethod)) + } else { + it + } + } + } + + fun onCancelTrip() { + TODO("Not yet implemented") + } + + fun onRateDriver() { + TODO("Not yet implemented") + } + + fun onFinishTrip() { + mutableState.update { + if (it is PassengerDashboardUiState.Arrived) { + PassengerDashboardUiState.Idle + } else { + it + } + } + } + + override fun dispose() { + super.dispose() + driverAcceptanceJob?.cancel() + } +} diff --git a/feature/ride/src/commonMain/kotlin/viewmodels/RideViewModel.kt b/feature/ride/src/commonMain/kotlin/viewmodels/RideViewModel.kt deleted file mode 100644 index 850b95e3..00000000 --- a/feature/ride/src/commonMain/kotlin/viewmodels/RideViewModel.kt +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. - */ - -package org.pointyware.xyz.feature.ride.viewmodels - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -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.viewmodels.LoadingUiState -import org.pointyware.xyz.core.viewmodels.MapViewModelImpl -import org.pointyware.xyz.core.viewmodels.postError -import org.pointyware.xyz.feature.ride.data.RideRequestRepository - -/** - * Maintains the state of a rider UI and provides actions to update it. - * - * @see PassengerDashboardUiState - */ -class RideViewModel( - private val rideRequestRepository: RideRequestRepository -): MapViewModelImpl() { - - private val userLocation = Location( - lat = 36.1031637, long = -97.0517528, - name = "Mission of Hope", - address = "1804 S Perkins Rd", zip = "74074" - ) - - private val mutableLoadingState = MutableStateFlow>(LoadingUiState.Idle()) - val loadingState: StateFlow> get() = mutableLoadingState - private val mutableState = MutableStateFlow(PassengerDashboardUiState.Idle) - val state: StateFlow get() = mutableState - - fun startSearch() { - mutableState.value = PassengerDashboardUiState.Search("", emptyList()) - } - - fun updateQuery(query: String) { - mutableState.update { - if (it is PassengerDashboardUiState.Search) { - it.copy(query = query) - } else { - it - } - } - } - - fun sendQuery() { - mutableState.update { - if (it is PassengerDashboardUiState.Search) { - it.copy(suggestions = listOf( - Location(lat = 36.1314561, long = -97.0605216, name = "Red Rock Bakery", address = "910 N Boomer Rd", zip = "74075"), - Location(lat = 36.1244264, long = -97.0583594, name = "Sonic", address = "215 N Main St", zip = "74075"), - Location(lat = 36.1171898, long = -97.0509852, name = "Sonic", address = "423 S Perkins Rd", zip = "74074"), - Location(lat = 36.1150974, long = -97.1177298, name = "Sonic", address = "4425 W 6th Ave", zip = "74074"), - )) - } else { - it - } - } - } - - private fun findRoute(start: Location, end: Location) { - mutableLoadingState.value = LoadingUiState.Loading() - viewModelScope.launch { - rideRequestRepository.findRoute(start, end) - .onSuccess { route -> - // TODO: Calculate route and price; update state - val rate = 120 // $1.20 per km - val price = (route.distance.to(LengthUnit.KILOMETERS).value * rate).toLong().dollarCents() - mutableState.update { - if (it is PassengerDashboardUiState.Confirm) { - it.copy(route = route, price = price) - } else { - it - } - } - mutableLoadingState.value = LoadingUiState.Idle() - } - .onFailure { - mutableLoadingState.postError(it) - } - } - } - - fun selectLocation(location: Location) { - mutableState.update { - if (it is PassengerDashboardUiState.Search) { - PassengerDashboardUiState.Confirm( - origin = userLocation, - destination = location, - route = null, - price = null - ).also { - findRoute(userLocation, location) - } - } else { - it - } - } - } - - fun confirmDetails() { - mutableState.update { - if (it is PassengerDashboardUiState.Confirm) { - val route = it.route ?: return - val price = it.price ?: return - PassengerDashboardUiState.Posted( - route = route, - price = price - ) - // TODO: listen for driver acceptance; update state - } else { - it - } - } - } - - fun cancelRide() { - // TODO: send cancellation request to server - mutableState.update { - PassengerDashboardUiState.Idle - } - } - - fun clearError() { - mutableLoadingState.value = LoadingUiState.Idle() - } -} diff --git a/feature/ride/src/commonTest/kotlin/ExampleClientTest.kt b/feature/ride/src/commonTest/kotlin/ExampleClientTest.kt deleted file mode 100644 index e4d75984..00000000 --- a/feature/ride/src/commonTest/kotlin/ExampleClientTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -import kotlin.test.Test - -/* - * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. - */ - -/** - * - */ -class ExampleClientTest { - @Test - fun `connect to local server`() { - /* - Given: - - A local server is running - - Valid login credentials - When: - - The client sends a login request to the /auth endpoint - Then: - - The client receives a 200 OK response - */ - } -} diff --git a/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/entities/ExpirationDateTest.kt b/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/entities/ExpirationDateTest.kt new file mode 100644 index 00000000..e38c8d9b --- /dev/null +++ b/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/entities/ExpirationDateTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.entities + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +/** + * + */ +class ExpirationDateTest { + + data class FormatCase( + val month: Byte, + val year: Short, + val expected: String + ) + + @Test + fun `format expiration date`() { + /* + Given: + - A month and year + When: + - Formatting the expiration date + */ + listOf( + FormatCase(1, 2024, "01/24"), + FormatCase(12, 2024, "12/24"), + FormatCase(2, 2025, "02/25"), + FormatCase(10, 2025, "10/25"), + ).forEach { (month, year, expected) -> + val expirationDate = ExpirationDate(month, year) + val formatted = expirationDate.format() + /* + Then: + - The expiration date should be formatted as MM/YY + */ + assertEquals(expected, formatted, "Expected $expected but got $formatted for $expirationDate") + } + } + + data class ConstructionCase( + val month: Byte, + val year: Short, + ) + + @Test + fun `invalid expiration month`() { + /* + Given: + - An invalid month + When: + - Creating an expiration date + Then: + - An exception should be thrown + */ + listOf( + ConstructionCase(-128, 2024), + ConstructionCase(0, 2024), + ConstructionCase(13, 2024), + ConstructionCase(127, 2024), + ).forEach { (month, year) -> + assertFails("Expected failure for month number $month") { + ExpirationDate(month, year) + } + } + } + + @Test + fun `invalid expiration year`() { + /* + Given: + - An invalid year + When: + - Creating an expiration date + Then: + - An exception should be thrown + */ + listOf( + ConstructionCase(4, 2020), + ConstructionCase(3, 2021), + ConstructionCase(2, 2022), + ConstructionCase(1, 2023), + ConstructionCase(5, 3024), + ConstructionCase(6, 3025), + ConstructionCase(7, 3026), + ConstructionCase(8, 3027), + ).forEach { (month, year) -> + assertFails("Expected failure for year $year") { + ExpirationDate(month, year) + } + } + } +} diff --git a/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/test/KoinExt.kt b/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/test/KoinExt.kt new file mode 100644 index 00000000..61c9faff --- /dev/null +++ b/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/test/KoinExt.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.test + +import org.koin.core.context.startKoin +import org.pointyware.xyz.core.data.di.coreDataModule +import org.pointyware.xyz.core.entities.di.coreEntitiesModule +import org.pointyware.xyz.core.interactors.di.coreInteractorsModule +import org.pointyware.xyz.core.navigation.di.coreNavigationModule +import org.pointyware.xyz.core.ui.di.coreUiModule +import org.pointyware.xyz.core.viewmodels.di.coreViewModelsModule +import org.pointyware.xyz.feature.ride.di.featureRideModule + + +/** + * Starts koin with the required modules for testing + */ +fun setupKoin() { + startKoin { + modules( + coreUiModule(), + coreViewModelsModule(), + coreInteractorsModule(), + coreDataModule(), + coreEntitiesModule(), + coreNavigationModule(), + + featureRideModule(), + ) + } +} diff --git a/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/ui/PassengerDashboardScreenUiTest.kt b/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/ui/PassengerDashboardScreenUiTest.kt new file mode 100644 index 00000000..bd8fcd83 --- /dev/null +++ b/feature/ride/src/commonTest/kotlin/org.pointyware.xyz.feature.ride/ui/PassengerDashboardScreenUiTest.kt @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2024 Pointyware. Use of this software is governed by the GPL-3.0 license. + */ + +package org.pointyware.xyz.feature.ride.ui + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.test.waitUntilDoesNotExist +import androidx.compose.ui.test.waitUntilExactlyOneExists +import org.koin.core.context.loadKoinModules +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.koin.mp.KoinPlatform.getKoin +import org.pointyware.xyz.core.entities.Name +import org.pointyware.xyz.core.entities.Uuid +import org.pointyware.xyz.core.entities.business.Individual +import org.pointyware.xyz.core.entities.data.Uri +import org.pointyware.xyz.core.entities.profile.DriverProfile +import org.pointyware.xyz.core.entities.profile.Gender +import org.pointyware.xyz.core.entities.profile.RiderProfile +import org.pointyware.xyz.core.entities.ride.Accommodation +import org.pointyware.xyz.core.navigation.XyzNavController +import org.pointyware.xyz.core.navigation.di.homeQualifier +import org.pointyware.xyz.core.ui.design.XyzTheme +import org.pointyware.xyz.core.ui.di.EmptyTestUiDependencies +import org.pointyware.xyz.feature.ride.data.TestTripRepository +import org.pointyware.xyz.feature.ride.di.featureRideDataTestModule +import org.pointyware.xyz.feature.ride.entities.ExpirationDate +import org.pointyware.xyz.feature.ride.entities.PaymentMethod +import org.pointyware.xyz.feature.ride.local.FakePaymentStore +import org.pointyware.xyz.feature.ride.navigation.rideRoute +import org.pointyware.xyz.feature.ride.test.setupKoin +import org.pointyware.xyz.feature.ride.viewmodels.PassengerDashboardUiState +import org.pointyware.xyz.feature.ride.viewmodels.TripViewModel +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * System/UI Test for Rider Request Ride View + */ +@OptIn(ExperimentalTestApi::class) +class PassengerDashboardScreenUiTest { + + private lateinit var tripRepository: TestTripRepository + private lateinit var paymentStore: FakePaymentStore + + private lateinit var viewModel: TripViewModel + private lateinit var navController: XyzNavController + + private lateinit var driverProfile: DriverProfile + + @BeforeTest + fun setUp() { + setupKoin() + loadKoinModules(listOf( + featureRideDataTestModule(), + module { + single(qualifier = homeQualifier) { rideRoute } + } + )) + + val koin = getKoin() + + tripRepository = koin.get() + tripRepository.riderProfile = RiderProfile( + id = Uuid.v4(), + name = Name("Test", "", "Rider"), + gender = Gender.Man, + picture = Uri.nullDevice, + preferences = "", + disabilities = emptySet() + ) + paymentStore = koin.get() + paymentStore.savePaymentMethod( + PaymentMethod( + id = Uuid.v4(), + lastFour = "3456", + expiration = ExpirationDate(month = 12, year = 2024), + cardholderName = "John Doe", + paymentProvider = "Bisa" + ) + ) + + viewModel = koin.get() + navController = koin.get() + + driverProfile = DriverProfile( + id = Uuid.v4(), + name = Name("Test", "", "Driver"), + gender = Gender.Woman, + picture = Uri.nullDevice, + accommodations = setOf(Accommodation.AnimalFriendly), + business = Individual + ) + } + + @AfterTest + fun tearDown() { + stopKoin() + } + + @Test + fun request_ride() = runComposeUiTest { + /* + Given: + - User is on the Ride Screen + - UiState is Idle + */ + assertEquals(PassengerDashboardUiState.Idle, viewModel.state.value, "Initial state is Idle") + + /* + When: + - The Passenger Dashboard Screen is shown + Then: + - The "New Ride" button is shown + */ + setContent { + XyzTheme( + uiDependencies = EmptyTestUiDependencies() + ) { + PassengerDashboardScreen( + viewModel, + navController + ) + } + } + + onNodeWithText("New Ride") + .assertExists() + .assertIsEnabled() + + /* + When: + - User clicks on the "New Ride" button + Then: + - The "New Ride" button transforms into the Search Bar + - The "Confirm" search button is shown but disabled + - The payment selection is shown + */ + onNodeWithText("New Ride") + .performClick() + + onNodeWithText("Search") + .assertExists() + onNodeWithText("Confirm") + .assertExists() + .assertIsNotEnabled() + onNodeWithContentDescription("Payment Method") + .assertExists() + + /* + When: + - User clicks on the "Payment Selection" button + Then: + - The "Payment Selection" button transforms into the Payment Method Selection + */ + onNodeWithText("Select Payment Method") + .performClick() + + onNodeWithContentDescription("Payment Method Selection") + .assertExists() + + /* + When: + - User selects a payment method - Bisa + Then: + - The "Payment Method Selection" form transforms back into the "Payment Method" form + - The selected payment method is shown + */ + onNodeWithText("Bisa", substring = true) + .performClick() + + onNodeWithContentDescription("Payment Method") + .assertExists() + onNodeWithText("Bisa", substring = true) + .assertExists() + onNodeWithText("Select Payment Method") + .assertExists() + + /* + When: + - User types "Red Rock" into the Search Bar + Then: + - The Search Bar displays "Red Rock" + - The "Confirm" search button is enabled + */ + onNodeWithText("Search") + .performTextInput("Red Rock") + + onNodeWithText("Search") + .assert(hasText("Red Rock")) + onNodeWithText("Confirm") + .assertIsEnabled() + + /* + When: + - User clicks on the "Confirm" button + Then: + - Location suggestion list is shown + */ + onNodeWithText("Confirm") + .performClick() + + onNodeWithContentDescription("Location Suggestions") + .assertExists() + + /* + When: + - User clicks on the "Red Rock" suggestion + Then: + - The Map is updated with the "Red Rock" destination + - The waiting indicator is shown while the route is calculated + - The "Confirm Route" button is shown but disabled while the route is calculated + */ + onNodeWithContentDescription("Location Suggestions") + .onChildren().filterToOne(hasText("Red Rock", substring = true)) + .assertExists() + .performClick() + + // TODO: Assert that the map is updated + onNodeWithContentDescription("Loading") + .assertExists() + onNodeWithText("Confirm Route") + .assertExists() + .assertIsNotEnabled() + + /* + When: + - The route is calculated + Then: + - The "Confirm Route" button is shown + - The waiting indicator is no longer shown + */ + waitUntilDoesNotExist(hasContentDescription("Loading"), 2000L) + + onNodeWithText("Confirm Route") + .assertExists() + .assertIsEnabled() + onNodeWithContentDescription("Loading") + .assertDoesNotExist() + + /* + When: + - User clicks on the "Confirm Route" button + Then: + - The "Hailing a driver" message is shown + - The "Cancel Request" button is shown + */ + onNodeWithText("Confirm Route") + .performClick() + + onNodeWithText("Hailing a driver") + .assertExists() + onNodeWithText("Cancel Request") + .assertExists() + .assertIsEnabled() + + /* + When: + - A driver accepts the request + Then: + - The driver profile information is shown + - The messaging input is shown + - The driver arriving message is shown + */ + tripRepository.acceptRequest(driverProfile) + + waitUntilExactlyOneExists(hasContentDescription("Driver Profile"), 500L) + onNodeWithContentDescription("Driver Profile") + .assertExists() + onNodeWithContentDescription("Message Input") + .assertExists() + onNodeWithText("Animal Friendly") + .assertExists() + onNodeWithText("Driver is on the way") + .assertExists() + + /* + When: + - The driver picks up the rider + Then: + - The "Cancel Ride" button is shown + - The driver profile information is shown + - The messaging input is shown + - The driver delivery message is shown + */ + tripRepository.pickUpRider() + + waitUntilExactlyOneExists(hasText("Cancel Ride"), 500L) + onNodeWithText("Cancel Ride") + .assertExists() + .assertIsEnabled() + onNodeWithContentDescription("Driver Profile") + .assertExists() + onNodeWithContentDescription("Message Input") + .assertExists() + onNodeWithText("You're on your way!") + .assertExists() + + /* + When: + - The driver drops off the rider + Then: + - The "Rate Driver" button is shown + - The messaging input is removed + - The progress message is removed + - The "You've arrived!" message is shown + - The "Done" button is shown + */ + tripRepository.dropOffRider() + + waitUntilExactlyOneExists(hasText("Rate Driver"), 500L) + onNodeWithText("Rate Driver") + .assertExists() + .assertIsEnabled() + onNodeWithContentDescription("Message Input") + .assertDoesNotExist() + onNodeWithText("You're on your way!") + .assertDoesNotExist() + onNodeWithText("You've arrived!") + .assertExists() + onNodeWithText("Done") + .assertExists() + .assertIsEnabled() + } +}