diff --git a/core/src/main/kotlin/app/transitos/core/util/TextNormalize.kt b/core/src/main/kotlin/app/transitos/core/util/TextNormalize.kt new file mode 100644 index 0000000..ba65e40 --- /dev/null +++ b/core/src/main/kotlin/app/transitos/core/util/TextNormalize.kt @@ -0,0 +1,11 @@ +package com.glossostudio.transitos.core.util + +import java.text.Normalizer + +/** + * Strips diacritics (accents, tildes, umlauts) so that search is + * accent-insensitive: "turia" matches "Túria", "xativa" matches "Xàtiva". + */ +public fun String.stripDiacritics(): String = + Normalizer.normalize(this, Normalizer.Form.NFD) + .replace(Regex("\\p{M}"), "") diff --git a/feature-planner/src/main/kotlin/app/transitos/feature/planner/PlannerScreen.kt b/feature-planner/src/main/kotlin/app/transitos/feature/planner/PlannerScreen.kt index 8f3c14c..b2b1f89 100644 --- a/feature-planner/src/main/kotlin/app/transitos/feature/planner/PlannerScreen.kt +++ b/feature-planner/src/main/kotlin/app/transitos/feature/planner/PlannerScreen.kt @@ -83,6 +83,7 @@ import com.glossostudio.transitos.core.model.JourneyLeg import com.glossostudio.transitos.core.model.Stop import com.glossostudio.transitos.core.ui.R as coreUiR import com.glossostudio.transitos.core.ui.SkeletonBlock +import com.glossostudio.transitos.core.util.stripDiacritics import com.glossostudio.transitos.feature.planner.R import kotlinx.datetime.Clock import kotlinx.datetime.DatePeriod @@ -913,7 +914,10 @@ private fun StationPickerSheet( var query by rememberSaveable { mutableStateOf("") } val filtered = remember(query, stops) { if (query.isBlank()) stops - else stops.filter { it.name.contains(query, ignoreCase = true) } + else { + val nq = query.stripDiacritics() + stops.filter { it.name.stripDiacritics().contains(nq, ignoreCase = true) } + } } ModalBottomSheet( diff --git a/feature-search/src/main/kotlin/app/transitos/feature/search/SearchViewModel.kt b/feature-search/src/main/kotlin/app/transitos/feature/search/SearchViewModel.kt index 9c014b3..7325a19 100644 --- a/feature-search/src/main/kotlin/app/transitos/feature/search/SearchViewModel.kt +++ b/feature-search/src/main/kotlin/app/transitos/feature/search/SearchViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.glossostudio.transitos.core.repository.FavoritesRepository import com.glossostudio.transitos.core.repository.TransitRepository -import java.text.Normalizer +import com.glossostudio.transitos.core.util.stripDiacritics import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -22,7 +22,6 @@ class SearchViewModel( ) : ViewModel() { private val _query = MutableStateFlow("") - private val normalizedNames = mutableMapOf() val query: StateFlow = _query.asStateFlow() val allStops: StateFlow> = repository.observeStops() @@ -37,8 +36,8 @@ class SearchViewModel( combine(allStops, query) { stops, q -> if (q.isBlank()) stops else { - val normalizedQuery = q.normalized() - stops.filter { it.name.normalized().contains(normalizedQuery, ignoreCase = true) } + val normalizedQuery = q.stripDiacritics() + stops.filter { it.name.stripDiacritics().contains(normalizedQuery, ignoreCase = true) } } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @@ -55,10 +54,4 @@ class SearchViewModel( } } } - - private fun String.normalized(): String = - normalizedNames.getOrPut(this) { - Normalizer.normalize(this, Normalizer.Form.NFD) - .replace(Regex("\\p{M}"), "") - } } diff --git a/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/MetrovalenciaRepository.kt b/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/MetrovalenciaRepository.kt index 6f42919..414d9ec 100644 --- a/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/MetrovalenciaRepository.kt +++ b/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/MetrovalenciaRepository.kt @@ -8,9 +8,12 @@ import com.glossostudio.transitos.core.model.Stop import com.glossostudio.transitos.core.provider.ProviderSettingsRepository import com.glossostudio.transitos.core.repository.TransitRepository import com.glossostudio.transitos.provider.metrovalencia.api.MetrovalenciaApi +import com.glossostudio.transitos.provider.metrovalencia.dto.FgvPlanificadorDto import com.glossostudio.transitos.provider.metrovalencia.mapper.LineDisplayInfo +import com.glossostudio.transitos.provider.metrovalencia.mapper.addMinutesToTime import com.glossostudio.transitos.provider.metrovalencia.mapper.formatAsFgvFecha import com.glossostudio.transitos.provider.metrovalencia.mapper.parseArgbHexOrNull +import com.glossostudio.transitos.provider.metrovalencia.mapper.timeDiffMinutes import com.glossostudio.transitos.provider.metrovalencia.mapper.toAlert import com.glossostudio.transitos.provider.metrovalencia.mapper.toArrivals import com.glossostudio.transitos.provider.metrovalencia.mapper.toJourney @@ -195,10 +198,67 @@ class MetrovalenciaRepository( ) } if (response.status != 200) emptyList() - else response.resultado.mapNotNull { it.toJourney(date, minTransferMinutes) } + else response.resultado.mapNotNull { dto -> + val enforced = enforceTransferBuffer(dto, date, minTransferMinutes) + enforced.toJourney(date) + } }.getOrDefault(emptyList()) } + /** + * Enforces the user's minimum transfer time by re-planning tight transfers. + * + * Walks the planned legs: when the real schedule gap at a transfer is + * shorter than [minTransferMinutes], calls `planificador-online2` again + * from the transfer station to the final destination with + * `hora_salida = previous_arrival + buffer`. The returned sub-journey's + * legs replace everything from the transfer onward. If the sub-journey + * itself has a tight transfer, the loop re-checks it (cascading). + * + * Falls back to the original legs when re-planning fails or the station + * lacks an internal id. + */ + private suspend fun enforceTransferBuffer( + dto: FgvPlanificadorDto, + date: LocalDate, + minTransferMinutes: Int, + ): FgvPlanificadorDto { + if (dto.pasos.size <= 1 || minTransferMinutes <= 0) return dto + val pasos = dto.pasos.toMutableList() + val destInternalId = dto.estacion_destino?.id ?: return dto + var i = 1 + while (i < pasos.size) { + val prevArrival = pasos[i - 1].hora_llegada + val thisDeparture = pasos[i].hora_salida + val realWait = timeDiffMinutes(prevArrival, thisDeparture) + if (realWait != null && realWait < minTransferMinutes) { + val transferInternalId = pasos[i].estacion_origen?.id + if (transferInternalId != null) { + val newDeparture = addMinutesToTime(prevArrival, minTransferMinutes) + val subResponse = runCatching { + api.planificadorOnline( + originInternalId = transferInternalId, + destinationInternalId = destInternalId, + fecha = date.formatAsFgvFecha(), + horaSalida = newDeparture, + horaLlegada = null, + ) + }.getOrNull() + if (subResponse?.status == 200 && subResponse.resultado.isNotEmpty()) { + val subPasos = subResponse.resultado.first().pasos + if (subPasos.isNotEmpty()) { + pasos.subList(i, pasos.size).clear() + pasos.addAll(subPasos) + continue + } + } + } + } + i++ + } + return dto.copy(pasos = pasos) + } + /** * Builds alerts using a fresh fetch of lineas + incidencias. The lookup is * keyed on the lineas **internal** `id` because that's what diff --git a/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/mapper/FgvMappers.kt b/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/mapper/FgvMappers.kt index cd4ed8f..d2e984a 100644 --- a/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/mapper/FgvMappers.kt +++ b/provider-metrovalencia/src/main/kotlin/app/transitos/provider/metrovalencia/mapper/FgvMappers.kt @@ -146,26 +146,34 @@ internal fun parseArgbHexOrNull(hex: String): Long? = runCatching { * horarios mapper, this produces legs with specific departure/arrival times, * line colors, and transfer info. * - * [minTransferMinutes] is the user-configurable buffer: if the API says a - * transfer takes fewer minutes than this, we pad the wait and shift subsequent - * leg times so the displayed arrival is realistic. + * The wait at each transfer is the gap between the previous leg's arrival and + * this leg's departure, read from the real schedules — not FGV's `min_espera`, + * which is only a platform-walk estimate and ignores how long until the next + * train actually leaves. + * + * Duration is computed from the first departure to the last arrival so it + * stays correct after the repository stitches re-planned sub-journeys. */ internal fun FgvPlanificadorDto.toJourney( date: LocalDate, - minTransferMinutes: Int = 5, ): Journey? { if (pasos.isEmpty()) return null - val rawLegs = pasos.mapIndexed { index, paso -> + val legs = pasos.mapIndexed { index, paso -> paso.toLeg( - waitFromPrevious = if (index > 0) pasos[index - 1].transbordo?.minEspera else null, + waitFromPrevious = if (index > 0) { + timeDiffMinutes(pasos[index - 1].hora_llegada, paso.hora_salida) + ?: pasos[index - 1].transbordo?.minEspera + } else null, ) } - val legs = applyTransferBuffer(rawLegs, minTransferMinutes) if (legs.isEmpty()) return null - val totalBuffer = totalBufferAdded(rawLegs, legs) + val computedDuration = timeDiffMinutes( + pasos.first().hora_salida, + pasos.last().hora_llegada, + ) ?: duracion_minutos return Journey( date = date, - durationMinutes = duracion_minutos + totalBuffer, + durationMinutes = computedDuration, distanceMeters = 0L, fareZone = tarifas, carbonKg = huella_de_carbono, @@ -188,44 +196,24 @@ internal fun FgvPasoDto.toLeg(waitFromPrevious: Int? = null): JourneyLeg { ) } -private fun applyTransferBuffer( - legs: List, - minTransfer: Int, -): List { - if (legs.size <= 1 || minTransfer <= 0) return legs - var cumulativeShift = 0 - return legs.mapIndexed { index, leg -> - if (index == 0) return@mapIndexed leg - val wait = leg.waitMinutes ?: 0 - val needed = if (wait < minTransfer) minTransfer - wait else 0 - cumulativeShift += needed - if (cumulativeShift > 0) { - leg.copy( - waitMinutes = wait + needed, - departureTime = leg.departureTime?.let { addMinutesToTime(it, cumulativeShift) }, - arrivalTime = leg.arrivalTime?.let { addMinutesToTime(it, cumulativeShift) }, - ) - } else leg - } +internal fun addMinutesToTime(time: String, minutes: Int): String { + val total = (parseHHmm(time) ?: return time) + minutes + return "%02d:%02d".format((total / 60) % 24, total % 60) } -private fun totalBufferAdded(original: List, buffered: List): Int { - var total = 0 - for (i in original.indices) { - val o = original[i].waitMinutes ?: 0 - val b = buffered[i].waitMinutes ?: 0 - total += (b - o).coerceAtLeast(0) - } - return total +internal fun timeDiffMinutes(from: String, to: String): Int? { + val start = parseHHmm(from) ?: return null + val end = parseHHmm(to) ?: return null + val diff = end - start + return if (diff >= 0) diff else diff + 24 * 60 } -private fun addMinutesToTime(time: String, minutes: Int): String { +internal fun parseHHmm(time: String): Int? { val parts = time.split(":") - if (parts.size != 2) return time - val h = parts[0].toIntOrNull() ?: return time - val m = parts[1].toIntOrNull() ?: return time - val total = h * 60 + m + minutes - return "%02d:%02d".format((total / 60) % 24, total % 60) + if (parts.size != 2) return null + val h = parts[0].toIntOrNull() ?: return null + val m = parts[1].toIntOrNull() ?: return null + return h * 60 + m } /** diff --git a/provider-metrovalencia/src/test/kotlin/app/transitos/provider/metrovalencia/mapper/FgvMappersTest.kt b/provider-metrovalencia/src/test/kotlin/app/transitos/provider/metrovalencia/mapper/FgvMappersTest.kt index 4441d30..f7e728b 100644 --- a/provider-metrovalencia/src/test/kotlin/app/transitos/provider/metrovalencia/mapper/FgvMappersTest.kt +++ b/provider-metrovalencia/src/test/kotlin/app/transitos/provider/metrovalencia/mapper/FgvMappersTest.kt @@ -6,10 +6,14 @@ import com.glossostudio.transitos.core.model.TransportMode import com.glossostudio.transitos.provider.metrovalencia.dto.FgvIncidenciaDto import com.glossostudio.transitos.provider.metrovalencia.dto.FgvJourneyAlternativeDto import com.glossostudio.transitos.provider.metrovalencia.dto.FgvLineDto +import com.glossostudio.transitos.provider.metrovalencia.dto.FgvPasoDto +import com.glossostudio.transitos.provider.metrovalencia.dto.FgvPlanificadorDto +import com.glossostudio.transitos.provider.metrovalencia.dto.FgvPlanificadorLineaDto import com.glossostudio.transitos.provider.metrovalencia.dto.FgvPrevisionDto import com.glossostudio.transitos.provider.metrovalencia.dto.FgvStationDto import com.glossostudio.transitos.provider.metrovalencia.dto.FgvTrainDto import com.glossostudio.transitos.provider.metrovalencia.dto.FgvTransbordoDto +import com.glossostudio.transitos.provider.metrovalencia.dto.FgvTransbordoPlanificadorDto import com.glossostudio.transitos.provider.metrovalencia.mapper.LineDisplayInfo import com.google.common.truth.Truth.assertThat import kotlinx.datetime.LocalDate @@ -242,4 +246,121 @@ class FgvMappersTest { assertThat(LocalDate.parse("2026-07-23").formatAsFgvFecha()).isEqualTo("23/07/2026") assertThat(LocalDate.parse("2026-12-01").formatAsFgvFecha()).isEqualTo("01/12/2026") } + + @Test + fun `planificador direct journey maps with real times and duration`() { + val dto = FgvPlanificadorDto( + duracion_minutos = 27, + estacion_origen = FgvStationDto(estacionIdFgv = 12, nombre = "Benimaclet"), + estacion_destino = FgvStationDto(estacionIdFgv = 2, nombre = "La Pobla de Farnals"), + pasos = listOf( + FgvPasoDto( + orden = 1, + hora_salida = "10:00", + hora_llegada = "10:27", + estacion_origen = FgvStationDto(estacionIdFgv = 12, nombre = "Benimaclet"), + estacion_destino = FgvStationDto(estacionIdFgv = 2, nombre = "La Pobla de Farnals"), + tren_origen = "Rafelbunyol", + linea_origen = FgvPlanificadorLineaDto( + color = "#FEC601", nombre_corto = "L3", lineaIdFgv = 3, + ), + ), + ), + ) + + val journey = dto.toJourney(LocalDate.parse("2026-07-23"))!! + + assertThat(journey.legs).hasSize(1) + assertThat(journey.legs[0].departureTime).isEqualTo("10:00") + assertThat(journey.legs[0].arrivalTime).isEqualTo("10:27") + assertThat(journey.legs[0].waitMinutes).isNull() + // Duration from actual times, not the API's duracion_minutos. + assertThat(journey.durationMinutes).isEqualTo(27) + } + + @Test + fun `planificador transfer journey computes real wait from schedule gap`() { + val dto = FgvPlanificadorDto( + duracion_minutos = 40, + estacion_origen = FgvStationDto(estacionIdFgv = 12, nombre = "Benimaclet"), + estacion_destino = FgvStationDto(estacionIdFgv = 2, nombre = "La Pobla de Farnals"), + pasos = listOf( + FgvPasoDto( + orden = 1, + hora_salida = "10:00", + hora_llegada = "10:15", + estacion_origen = FgvStationDto(estacionIdFgv = 12, nombre = "Benimaclet"), + estacion_destino = FgvStationDto(estacionIdFgv = 20, nombre = "Almassera"), + tren_origen = "Rafelbunyol", + linea_origen = FgvPlanificadorLineaDto(color = "#FEC601", nombre_corto = "L3"), + transbordo = FgvTransbordoPlanificadorDto(minEspera = 3), + ), + FgvPasoDto( + orden = 2, + hora_salida = "10:22", + hora_llegada = "10:40", + estacion_origen = FgvStationDto(estacionIdFgv = 20, nombre = "Almassera"), + estacion_destino = FgvStationDto(estacionIdFgv = 2, nombre = "La Pobla de Farnals"), + tren_origen = "Castelló", + linea_origen = FgvPlanificadorLineaDto(color = "#FF6600", nombre_corto = "L5"), + ), + ), + ) + + val journey = dto.toJourney(LocalDate.parse("2026-07-23"))!! + + assertThat(journey.legs).hasSize(2) + // Real wait is 10:22 - 10:15 = 7 minutes, not the minEspera of 3. + assertThat(journey.legs[1].waitMinutes).isEqualTo(7) + // Duration from 10:00 to 10:40 = 40 minutes. + assertThat(journey.durationMinutes).isEqualTo(40) + } + + @Test + fun `planificador transfer falls back to minEspera when times unparseable`() { + val dto = FgvPlanificadorDto( + duracion_minutos = 0, + estacion_destino = FgvStationDto(estacionIdFgv = 2, nombre = "Dest"), + pasos = listOf( + FgvPasoDto( + hora_salida = "10:00", + hora_llegada = "garbage", + estacion_origen = FgvStationDto(estacionIdFgv = 1, nombre = "A"), + estacion_destino = FgvStationDto(estacionIdFgv = 3, nombre = "B"), + transbordo = FgvTransbordoPlanificadorDto(minEspera = 5), + ), + FgvPasoDto( + hora_salida = "10:10", + hora_llegada = "10:20", + estacion_origen = FgvStationDto(estacionIdFgv = 3, nombre = "B"), + estacion_destino = FgvStationDto(estacionIdFgv = 2, nombre = "Dest"), + ), + ), + ) + + val journey = dto.toJourney(LocalDate.parse("2026-07-23"))!! + + // Unparseable arrival → fallback to minEspera. + assertThat(journey.legs[1].waitMinutes).isEqualTo(5) + } + + @Test + fun `timeDiffMinutes handles same-hour, cross-hour, and overnight`() { + assertThat(timeDiffMinutes("10:00", "10:27")).isEqualTo(27) + assertThat(timeDiffMinutes("10:45", "11:15")).isEqualTo(30) + assertThat(timeDiffMinutes("23:50", "00:10")).isEqualTo(20) + } + + @Test + fun `timeDiffMinutes returns null for unparseable input`() { + assertThat(timeDiffMinutes("garbage", "10:00")).isNull() + assertThat(timeDiffMinutes("10:00", "")).isNull() + } + + @Test + fun `addMinutesToTime wraps past midnight`() { + assertThat(addMinutesToTime("10:00", 15)).isEqualTo("10:15") + assertThat(addMinutesToTime("23:50", 20)).isEqualTo("00:10") + assertThat(addMinutesToTime("09:00", 0)).isEqualTo("09:00") + } }