feat: Implement airport connection fetching and filtering

This commit introduces the functionality to fetch available flight connections for a selected origin airport and filter the destination airport list accordingly.

Key changes:
- Added `GetConnectionsForAirportUseCase` interface in `domain/stations` to define the contract for fetching airport connections.
- Implemented `GetConnectionsForAirportUseCaseImpl` in `model/repository` to fetch routes from the `RoutesService` and map them to domain `Airport` types.
- Created `AirportDomainMapper.kt` in `model/repository/mappers` to map `RouteResponse` and its nested types to domain `Airport` and `MacCity` types.
- Added a Hilt module `GetConnectionsForAirportUseCaseModule` to provide the implementation for `GetConnectionsForAirportUseCase`.
- Renamed `OriginAirportsUiState` to `AirportsUiState` in `StationsScreen.kt` and `StationsScreenViewModel.kt` for better generality.
- Updated `StationsScreenViewModel`:
    - Injected `GetConnectionsForAirportUseCase`.
    - Added `_availableDestinationsCodes` StateFlow to hold the codes of airports reachable from the selected origin.
    - Modified `airportsUiState` (previously `originAirportsUiState`) to take `filterDestinations` as a parameter and filter airports based on both search query and available destination codes.
    - Added `AirportInfo.toDepartureAirPort()` extension function.
- Updated `StationsScreen.kt` to use the renamed `AirportsUiState`.
This commit is contained in:
2025-06-16 00:29:57 +02:00
parent 504f798bd3
commit e8ac7c5596
6 changed files with 129 additions and 29 deletions

View File

@ -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<Airport>
}

View File

@ -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
}

View File

@ -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<Airport> {
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)
}

View File

@ -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<Airport> {
val result = routesService.getRoutes(
languageCode = "pl",
departureAirportCode = airport.code,
)
return result.map { it.toDomain() }.flatten().toSet()
}
}

View File

@ -57,7 +57,7 @@ internal fun StationsScreen(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun StationsScreen( private fun StationsScreen(
originAirports: OriginAirportsUiState, originAirports: AirportsUiState,
searchQuery: String, searchQuery: String,
selectedOriginAirport: AirportInfo?, selectedOriginAirport: AirportInfo?,
onSearchQueryChang: (String) -> Unit, onSearchQueryChang: (String) -> Unit,
@ -91,9 +91,9 @@ private fun StationsScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
when (originAirports) { when (originAirports) {
is OriginAirportsUiState.Error -> item { Text("Error") } is AirportsUiState.Error -> item { Text("Error") }
is OriginAirportsUiState.Loading -> item { Text("Loading") } is AirportsUiState.Loading -> item { Text("Loading") }
is OriginAirportsUiState.Success -> groupedAirports( is AirportsUiState.Success -> groupedAirports(
data = originAirports.groupedAirports, data = originAirports.groupedAirports,
onAirportSelected = onAirportSelect, onAirportSelected = onAirportSelect,
) )

View File

@ -5,7 +5,9 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.adriankuta.flights.core.util.Result import dev.adriankuta.flights.core.util.Result
import dev.adriankuta.flights.core.util.asResult 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.stations.ObserveAirportsGroupedByCountry
import dev.adriankuta.flights.domain.types.Airport
import dev.adriankuta.flights.domain.types.AirportInfo import dev.adriankuta.flights.domain.types.AirportInfo
import dev.adriankuta.flights.domain.types.Country import dev.adriankuta.flights.domain.types.Country
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -14,11 +16,13 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class StationsScreenViewModel @Inject constructor( internal class StationsScreenViewModel @Inject constructor(
observeAirportsGroupedByCountry: ObserveAirportsGroupedByCountry, observeAirportsGroupedByCountry: ObserveAirportsGroupedByCountry,
getConnectionsForAirportUseCase: GetConnectionsForAirportUseCase,
) : ViewModel() { ) : ViewModel() {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
@ -26,6 +30,14 @@ internal class StationsScreenViewModel @Inject constructor(
private val _selectedOriginAirport = MutableStateFlow<AirportInfo?>(null) private val _selectedOriginAirport = MutableStateFlow<AirportInfo?>(null)
val selectedOriginAirport: StateFlow<AirportInfo?> = _selectedOriginAirport val selectedOriginAirport: StateFlow<AirportInfo?> = _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) { fun onSearchQueryChanged(query: String) {
_searchQuery.value = query _searchQuery.value = query
@ -39,55 +51,62 @@ internal class StationsScreenViewModel @Inject constructor(
_selectedOriginAirport.value = null _selectedOriginAirport.value = null
} }
val originAirports = originAirportsUiState( val originAirports = airportsUiState(
useCase = observeAirportsGroupedByCountry, useCase = observeAirportsGroupedByCountry,
searchQuery = searchQuery, searchQuery = searchQuery,
filterDestinations = _availableDestinationsCodes,
).stateIn( ).stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = OriginAirportsUiState.Loading, initialValue = AirportsUiState.Loading,
) )
} }
private fun originAirportsUiState( private fun airportsUiState(
useCase: ObserveAirportsGroupedByCountry, useCase: ObserveAirportsGroupedByCountry,
searchQuery: StateFlow<String>, searchQuery: StateFlow<String>,
): Flow<OriginAirportsUiState> { filterDestinations: Flow<List<String>>,
): Flow<AirportsUiState> {
return combine( return combine(
useCase().asResult(), useCase().asResult(),
searchQuery, searchQuery,
) { result, query -> filterDestinations,
) { result, query, destinationCodes ->
when (result) { when (result) {
is Result.Error -> OriginAirportsUiState.Error(result.exception) is Result.Error -> AirportsUiState.Error(result.exception)
is Result.Loading -> OriginAirportsUiState.Loading is Result.Loading -> AirportsUiState.Loading
is Result.Success -> { is Result.Success -> {
val airports = result.data.orEmpty() val airports = result.data.orEmpty()
if (query.isBlank()) { if (query.isBlank() && destinationCodes.isEmpty()) {
OriginAirportsUiState.Success(airports) AirportsUiState.Success(airports)
} else { } else {
val filteredAirports = airports.mapValues { (_, airportList) -> val filteredAirports = airports.mapValues { (_, airportList) ->
airportList.filter { airport -> airportList.asSequence()
airport.name.contains(query, ignoreCase = true) || .filter { destinationCodes.isEmpty() || destinationCodes.contains(it.code) }
airport.code.contains(query, ignoreCase = true) || .filter { airport ->
airport.city.name.contains(query, ignoreCase = true) || airport.name.contains(query, ignoreCase = true) ||
airport.country.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() } }.filterValues { it.isNotEmpty() }
OriginAirportsUiState.Success(filteredAirports) AirportsUiState.Success(filteredAirports)
} }
} }
} }
} }
} }
internal sealed interface OriginAirportsUiState { private fun AirportInfo.toDepartureAirPort(): Airport.Departure =
data object Loading : OriginAirportsUiState Airport.Departure(
data class Success(val groupedAirports: Map<Country, List<AirportInfo>>) : OriginAirportsUiState code = code,
data class Error(val exception: Throwable) : OriginAirportsUiState name = name,
} macCity = macCity,
)
internal sealed interface DestinationAirportsUiState { internal sealed interface AirportsUiState {
data object Loading : DestinationAirportsUiState data object Loading : AirportsUiState
data class Success(val airports: List<AirportInfo>) : DestinationAirportsUiState data class Success(val groupedAirports: Map<Country, List<AirportInfo>>) : AirportsUiState
data class Error(val exception: Throwable) : DestinationAirportsUiState data class Error(val exception: Throwable) : AirportsUiState
} }