Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions core/src/main/kotlin/app/transitos/core/util/TextNormalize.kt
Original file line number Diff line number Diff line change
@@ -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}"), "")
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +22,6 @@ class SearchViewModel(
) : ViewModel() {

private val _query = MutableStateFlow("")
private val normalizedNames = mutableMapOf<String, String>()
val query: StateFlow<String> = _query.asStateFlow()

val allStops: StateFlow<List<com.glossostudio.transitos.core.model.Stop>> = repository.observeStops()
Expand All @@ -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())

Expand All @@ -55,10 +54,4 @@ class SearchViewModel(
}
}
}

private fun String.normalized(): String =
normalizedNames.getOrPut(this) {
Normalizer.normalize(this, Normalizer.Form.NFD)
.replace(Regex("\\p{M}"), "")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -188,44 +196,24 @@ internal fun FgvPasoDto.toLeg(waitFromPrevious: Int? = null): JourneyLeg {
)
}

private fun applyTransferBuffer(
legs: List<JourneyLeg>,
minTransfer: Int,
): List<JourneyLeg> {
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<JourneyLeg>, buffered: List<JourneyLeg>): 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
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
}
Loading