diff --git a/domain/stations/src/main/kotlin/dev/adriankuta/flights/domain/stations/GetConnectionsForAirportUseCase.kt b/domain/stations/src/main/kotlin/dev/adriankuta/flights/domain/stations/GetConnectionsForAirportUseCase.kt new file mode 100644 index 0000000..fc477ed --- /dev/null +++ b/domain/stations/src/main/kotlin/dev/adriankuta/flights/domain/stations/GetConnectionsForAirportUseCase.kt @@ -0,0 +1,8 @@ +package dev.adriankuta.flights.domain.stations + +import dev.adriankuta.flights.domain.types.Airport + +fun interface GetConnectionsForAirportUseCase { + + suspend operator fun invoke(airport: Airport): Set +} diff --git a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/di/GetConnectionsForAirportUseCaseModule.kt b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/di/GetConnectionsForAirportUseCaseModule.kt new file mode 100644 index 0000000..a6609d4 --- /dev/null +++ b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/di/GetConnectionsForAirportUseCaseModule.kt @@ -0,0 +1,19 @@ +package dev.adriankuta.flights.model.repository.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.adriankuta.flights.domain.stations.GetConnectionsForAirportUseCase +import dev.adriankuta.flights.model.repository.usecases.GetConnectionsForAirportUseCaseImpl + +@Module +@InstallIn(SingletonComponent::class) +@Suppress("UnnecessaryAbstractClass") +internal abstract class GetConnectionsForAirportUseCaseModule { + + @Binds + abstract fun bind( + getConnectionsForAirportUseCaseImpl: GetConnectionsForAirportUseCaseImpl, + ): GetConnectionsForAirportUseCase +} diff --git a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/mappers/AirportDomainMapper.kt b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/mappers/AirportDomainMapper.kt new file mode 100644 index 0000000..9404b59 --- /dev/null +++ b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/mappers/AirportDomainMapper.kt @@ -0,0 +1,35 @@ +package dev.adriankuta.flights.model.repository.mappers + +import dev.adriankuta.flights.domain.types.Airport +import dev.adriankuta.flights.domain.types.MacCity +import dev.adriankuta.model.data.api.entities.RouteResponse + +fun RouteResponse.Airport.MacCity.toDomain(): MacCity? { + val macCode = macCode ?: return null + return MacCity(macCode = macCode) +} + +fun RouteResponse.toDomain(): List { + val departureAirportDomain = departureAirport?.let { + Airport.Departure( + code = it.code ?: return@let null, + name = it.name ?: return@let null, + macCity = departureAirport?.macCity?.toDomain(), + ) + } + val arrivalAirportDomain = arrivalAirport?.let { + Airport.Arrival( + code = it.code ?: return@let null, + name = it.name ?: return@let null, + macCity = arrivalAirport?.macCity?.toDomain(), + ) + } + val connectingAirportDomain = connectingAirport?.let { + Airport.Connecting( + code = it.code ?: return@let null, + name = it.name ?: return@let null, + macCity = connectingAirport?.macCity?.toDomain(), + ) + } + return listOfNotNull(departureAirportDomain, arrivalAirportDomain, connectingAirportDomain) +} diff --git a/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetConnectionsForAirportUseCaseImpl.kt b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetConnectionsForAirportUseCaseImpl.kt new file mode 100644 index 0000000..d4c024d --- /dev/null +++ b/model/repository/src/main/kotlin/dev/adriankuta/flights/model/repository/usecases/GetConnectionsForAirportUseCaseImpl.kt @@ -0,0 +1,19 @@ +package dev.adriankuta.flights.model.repository.usecases + +import dev.adriankuta.flights.domain.stations.GetConnectionsForAirportUseCase +import dev.adriankuta.flights.domain.types.Airport +import dev.adriankuta.flights.model.repository.mappers.toDomain +import dev.adriankuta.model.data.api.RoutesService +import javax.inject.Inject + +internal class GetConnectionsForAirportUseCaseImpl @Inject constructor( + private val routesService: RoutesService, +) : GetConnectionsForAirportUseCase { + override suspend fun invoke(airport: Airport): Set { + val result = routesService.getRoutes( + languageCode = "pl", + departureAirportCode = airport.code, + ) + return result.map { it.toDomain() }.flatten().toSet() + } +} diff --git a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt index 6c936b0..affb149 100644 --- a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreen.kt @@ -57,7 +57,7 @@ internal fun StationsScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StationsScreen( - originAirports: OriginAirportsUiState, + originAirports: AirportsUiState, searchQuery: String, selectedOriginAirport: AirportInfo?, onSearchQueryChang: (String) -> Unit, @@ -91,9 +91,9 @@ private fun StationsScreen( modifier = Modifier.fillMaxWidth(), ) { when (originAirports) { - is OriginAirportsUiState.Error -> item { Text("Error") } - is OriginAirportsUiState.Loading -> item { Text("Loading") } - is OriginAirportsUiState.Success -> groupedAirports( + is AirportsUiState.Error -> item { Text("Error") } + is AirportsUiState.Loading -> item { Text("Loading") } + is AirportsUiState.Success -> groupedAirports( data = originAirports.groupedAirports, onAirportSelected = onAirportSelect, ) diff --git a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt index 54d1c33..e702bec 100644 --- a/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt +++ b/ui/stations/src/main/kotlin/dev/adriankuta/flights/ui/home/StationsScreenViewModel.kt @@ -5,7 +5,9 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.adriankuta.flights.core.util.Result import dev.adriankuta.flights.core.util.asResult +import dev.adriankuta.flights.domain.stations.GetConnectionsForAirportUseCase import dev.adriankuta.flights.domain.stations.ObserveAirportsGroupedByCountry +import dev.adriankuta.flights.domain.types.Airport import dev.adriankuta.flights.domain.types.AirportInfo import dev.adriankuta.flights.domain.types.Country import kotlinx.coroutines.flow.Flow @@ -14,11 +16,13 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import javax.inject.Inject @HiltViewModel internal class StationsScreenViewModel @Inject constructor( observeAirportsGroupedByCountry: ObserveAirportsGroupedByCountry, + getConnectionsForAirportUseCase: GetConnectionsForAirportUseCase, ) : ViewModel() { private val _searchQuery = MutableStateFlow("") @@ -26,6 +30,14 @@ internal class StationsScreenViewModel @Inject constructor( private val _selectedOriginAirport = MutableStateFlow(null) val selectedOriginAirport: StateFlow = _selectedOriginAirport + private val _availableDestinationsCodes = selectedOriginAirport.transformLatest { airportInfo -> + val result = airportInfo?.let { nonNullAirportInfo -> + getConnectionsForAirportUseCase(nonNullAirportInfo.toDepartureAirPort()) + .filter { it is Airport.Arrival } + .map { it.code } + }.orEmpty() + emit(result) + } fun onSearchQueryChanged(query: String) { _searchQuery.value = query @@ -39,55 +51,62 @@ internal class StationsScreenViewModel @Inject constructor( _selectedOriginAirport.value = null } - val originAirports = originAirportsUiState( + val originAirports = airportsUiState( useCase = observeAirportsGroupedByCountry, searchQuery = searchQuery, + filterDestinations = _availableDestinationsCodes, ).stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = OriginAirportsUiState.Loading, + initialValue = AirportsUiState.Loading, ) } -private fun originAirportsUiState( +private fun airportsUiState( useCase: ObserveAirportsGroupedByCountry, searchQuery: StateFlow, -): Flow { + filterDestinations: Flow>, +): Flow { return combine( useCase().asResult(), searchQuery, - ) { result, query -> + filterDestinations, + ) { result, query, destinationCodes -> when (result) { - is Result.Error -> OriginAirportsUiState.Error(result.exception) - is Result.Loading -> OriginAirportsUiState.Loading + is Result.Error -> AirportsUiState.Error(result.exception) + is Result.Loading -> AirportsUiState.Loading is Result.Success -> { val airports = result.data.orEmpty() - if (query.isBlank()) { - OriginAirportsUiState.Success(airports) + if (query.isBlank() && destinationCodes.isEmpty()) { + AirportsUiState.Success(airports) } else { val filteredAirports = airports.mapValues { (_, airportList) -> - airportList.filter { airport -> - airport.name.contains(query, ignoreCase = true) || - airport.code.contains(query, ignoreCase = true) || - airport.city.name.contains(query, ignoreCase = true) || - airport.country.name.contains(query, ignoreCase = true) - } + airportList.asSequence() + .filter { destinationCodes.isEmpty() || destinationCodes.contains(it.code) } + .filter { airport -> + airport.name.contains(query, ignoreCase = true) || + airport.code.contains(query, ignoreCase = true) || + airport.city.name.contains(query, ignoreCase = true) || + airport.country.name.contains(query, ignoreCase = true) + } + .toList() }.filterValues { it.isNotEmpty() } - OriginAirportsUiState.Success(filteredAirports) + AirportsUiState.Success(filteredAirports) } } } } } -internal sealed interface OriginAirportsUiState { - data object Loading : OriginAirportsUiState - data class Success(val groupedAirports: Map>) : OriginAirportsUiState - data class Error(val exception: Throwable) : OriginAirportsUiState -} +private fun AirportInfo.toDepartureAirPort(): Airport.Departure = + Airport.Departure( + code = code, + name = name, + macCity = macCity, + ) -internal sealed interface DestinationAirportsUiState { - data object Loading : DestinationAirportsUiState - data class Success(val airports: List) : DestinationAirportsUiState - data class Error(val exception: Throwable) : DestinationAirportsUiState +internal sealed interface AirportsUiState { + data object Loading : AirportsUiState + data class Success(val groupedAirports: Map>) : AirportsUiState + data class Error(val exception: Throwable) : AirportsUiState }