Bimba.git

commit 3ad0d68a424da7ca2bb59c9ae179d372cfca9615

Author: Adam Evyčędo <git@apiote.xyz>

prepare motis 2

# Conflicts:
#	app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt

%!v(PANIC=String method: strings: negative Repeat count)


diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt
index 62d9c8a13101ed601d9234c19ba9b2b8551b6f9b..ae6cebc06ca9353f7d885b38d8fa055eb25c6ec0 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Structs.kt
@@ -11,6 +11,10 @@ import org.yaml.snakeyaml.Yaml
 import xyz.apiote.bimba.czwek.api.structs.VehicleStatusV1
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
+import java.time.OffsetDateTime
+import java.time.Period
+import java.time.ZoneId
+import java.time.ZonedDateTime
 import kotlin.reflect.KClass
 
 class TrafficFormatException(override val message: String) : IllegalArgumentException()
@@ -185,6 +189,11 @@ data class Time(
 	val Hour: UInt, val Minute: UInt, val Second: UInt, val DayOffset: Byte, val Zone: String
 ) {
 	companion object {
+		fun fromOffsetTime(t: OffsetDateTime, zone: ZoneId): Time {
+			val now = ZonedDateTime.now()
+			val zonedTime = t.atZoneSameInstant(zone)
+			return Time(zonedTime.hour.toUInt(), zonedTime.minute.toUInt(), zonedTime.second.toUInt(), Period.between(now.toLocalDate(), zonedTime.toLocalDate()).days.toByte(), zone.id)
+		}
 		fun unmarshal(stream: InputStream): Time {
 			val reader = Reader(stream)
 			return Time(




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitous.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitous.kt
deleted file mode 100644
index e566210b9774cd999d009f32c9b501433dfdf403..0000000000000000000000000000000000000000
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitous.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-// SPDX-FileCopyrightText: Adam Evyčędo
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package xyz.apiote.bimba.czwek.api
-
-import android.content.Context
-import android.os.Build
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import xyz.apiote.bimba.czwek.R
-import java.io.IOException
-import java.net.HttpURLConnection
-import java.net.URL
-
-suspend fun askTransitous(context: Context, json: String): Result {
-	return withContext(Dispatchers.IO) {
-		val url = URL("https://routing.spline.de/api/")
-		val c = (url.openConnection() as HttpURLConnection).apply {
-			setRequestProperty(
-				"User-Agent",
-				"${context.getString(R.string.applicationId)}/${context.getString(R.string.versionName)} (${Build.VERSION.SDK_INT})"
-			)
-			addRequestProperty("Content-Type", "application/json")
-			requestMethod = "POST"
-			doOutput = true
-			outputStream.write(json.toByteArray())
-		}
-		try {
-			if (c.responseCode == 200) {
-				Result(c.inputStream, null)
-			} else {
-				val (string, image) = mapHttpError(c.responseCode)
-				Result(c.errorStream, Error(c.responseCode, string, image))
-			}
-		} catch (e: IOException) {
-			Result(null, Error(0, R.string.error_connecting, R.drawable.error_server))
-		}
-	}
-}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt
index 3c52446a49d0db59620b89fa7ac64a29bd774cf5..fcf627e0871257a5435aafb88f8e5e2be49325f6 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousDepartures.kt
@@ -5,13 +5,11 @@
 package xyz.apiote.bimba.czwek.api
 
 import android.content.Context
-import android.util.JsonReader
-import android.util.JsonToken
 import android.util.Log
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.responses.ErrorResponse
+import xyz.apiote.bimba.czwek.api.transitous.api.TimetableApi
 import xyz.apiote.bimba.czwek.repo.Colour
 import xyz.apiote.bimba.czwek.repo.CongestionLevel
 import xyz.apiote.bimba.czwek.repo.Event
@@ -24,13 +22,12 @@ import xyz.apiote.bimba.czwek.repo.StopEvents
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
 import xyz.apiote.bimba.czwek.repo.Vehicle
 import xyz.apiote.bimba.czwek.units.Mps
-import java.security.MessageDigest
+import java.math.BigDecimal
 import java.time.Instant
 import java.time.LocalDate
+import java.time.OffsetDateTime
 import java.time.ZoneId
-import java.time.ZonedDateTime
 
-@OptIn(ExperimentalStdlibApi::class)
 suspend fun getTransitousDepartures(
 	context: Context,
 	stop: String,
@@ -41,247 +38,59 @@ 	if (!isNetworkAvailable(context)) {
 		throw TrafficResponseException(0, "", Error(0, R.string.error_offline, R.drawable.error_net))
 	}
 
-	// TODO shouldn't it be start-of-day in stop's timezone?
-	val timestamp =
-		date?.atStartOfDay(ZoneId.systemDefault())?.toEpochSecond() ?: ZonedDateTime.now()
-			.toEpochSecond()
-
-	val json = """
-		{
-			"destination": {
-				"type": "Module",
-				"target": "/railviz/get_station"
-			},
-			"content_type": "RailVizStationRequest",
-			"content": {
-				"station_id": "$stop",
-				"time": $timestamp,
-				"event_count": ${limit ?: 12},
-				"direction": "LATER",
-				"by_schedule_time": false
-			}
-		}
-	"""
-
-	val result = askTransitous(context, json)
-
-	if (result.error != null) {
-		if (result.stream != null) {
-			val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
-			throw TrafficResponseException(result.error.statusCode, response.message, result.error)
-		} else {
-			throw TrafficResponseException(result.error.statusCode, "", result.error)
+	return withContext(Dispatchers.IO) {
+		// TODO shouldn't it be start-of-day in stop's timezone?
+		val datetime = date?.let {
+			OffsetDateTime.of(date.atStartOfDay(), ZoneId.systemDefault().rules.getOffset(Instant.now()))
 		}
-	} else {
-		return withContext(Dispatchers.IO) {
-			val events = mutableListOf<Event>()
-			var stopID = ""
-			var stopName = ""
-			var latitude = 0.0
-			var longitude = 0.0
-			val r = JsonReader(result.stream!!.bufferedReader())
-			r.withObject { name ->
-				if (name != "content") {
-					r.skipValue()
-					return@withObject
-				}
-				r.withObject { name ->
-					when (name) {
-						"events" -> {
-							r.withArray {
-								var reason = ""
-								var eventTimestamp: Long = 0
-								var valid = false
-								var tripID = ""
-								var idLineID = ""
-								var stationID = ""
-								var targetStationID = ""
-								var targetTimestamp = 0
-								var idTimestamp = 0
-								var trainNr = 0
-								var clasz = -1
-								var direction = ""
-								var lineID = ""
-								var lineName = ""
-								var lineColour = ""
-								var eventType = ""
-								r.withObject { name ->
-									when (name) {
-										"event" -> {
-											r.withObject { name ->
-												when (name) {
-													"reason" -> reason = r.nextString()
-													"schedule_time" -> r.skipValue()
-													"schedule_track" -> r.skipValue()
-													"time" -> eventTimestamp = r.nextLong()
-													"track" -> r.skipValue()
-													"valid" -> valid = r.nextBoolean()
-													else -> r.skipValue()
-												}
-											}
-										}
-
-										"trips" -> {
-											r.withArray { i ->
-												if (i == 0) {
-													r.withObject { name ->
-														when (name) {
-															"id" -> {
-																r.withObject { name ->
-																	when (name) {
-																		"id" -> tripID = r.nextString()
-																		"line_id" -> idLineID = r.nextString()
-																		"station_id" -> stationID = r.nextString()
-																		"target_station_id" -> targetStationID = r.nextString()
-																		"target_time" -> targetTimestamp = r.nextInt()
-																		"time" -> idTimestamp = r.nextInt()
-																		"train_nr" -> trainNr = r.nextInt()
-																		else -> r.skipValue()
-																	}
-																}
-															}
-
-															"transport" -> {
-																r.withObject { name ->
-																	Log.d("WithObject", "next is ${name}")
-																	when (name) {
-																		"clasz" -> clasz = r.nextInt()
-																		"direction" -> direction = r.nextString()
-																		"line_id" -> lineID = r.nextString()
-																		"name" -> lineName = r.nextString()
-																		"provider" -> r.skipValue()
-																		"provider_url" -> r.skipValue()
-																		"range" -> r.skipValue()
-																		"route_color" -> lineColour = r.nextString()
-																		"route_text_color" -> r.skipValue()
-																		else -> r.skipValue()
-																	}
-																}
-															}
-															else -> r.skipValue()
-														}
-													}
-												} else {
-													r.skipValue()
-												}
-											}
-										}
-
-										"type" -> {
-											eventType = r.nextString()
-										}
-										else -> r.skipValue()
-									}
-								}
-								if (eventType == "ARR") {
-									val hash = MessageDigest.getInstance("SHA-256").let {
-										it.update(tripID.toByteArray())
-										it.update(idLineID.toByteArray())
-										it.update(stationID.toByteArray())
-										it.update(targetStationID.toByteArray())
-										it.update(targetTimestamp.toHexString().toByteArray())
-										it.update(idTimestamp.toHexString().toByteArray())
-										it.update(trainNr.toHexString().toByteArray())
-										it.digest()
-									}.toHexString()
-									val t =
-										ZonedDateTime.ofInstant(
-											Instant.ofEpochSecond(eventTimestamp),
-											ZoneId.systemDefault()
-										)
-									events.add(
-										Event(
-											id = hash,
-											arrivalTime = Time(
-												t.hour.toUInt(),
-												t.minute.toUInt(),
-												t.second.toUInt(),
-												(t.dayOfYear - ZonedDateTime.now().dayOfYear).toByte(),
-												ZoneId.systemDefault().id
-											),
-											departureTime = Time(
-												t.hour.toUInt(),
-												t.minute.toUInt(),
-												t.second.toUInt(),
-												(t.dayOfYear - ZonedDateTime.now().dayOfYear).toByte(),
-												ZoneId.systemDefault().id
-											),
-											status = 0u,
-											isRealtime = reason in setOf("IS", "PROPAGATION", "FORECAST"),
-											vehicle = Vehicle(
-												"",
-												Position(0.0, 0.0),
-												0u,
-												Mps(0),
-												LineStub(
-													lineName,
-													LineType.fromTransitous(clasz),
-													Colour.fromHex(lineColour)
-												),
-												direction,
-												CongestionLevel.UNKNOWN,
-												OccupancyStatus.UNKNOWN
-											),
-											boarding = 0xffu,
-											alerts = emptyList(),
-											exact = true,
-											terminusArrival = !valid
-										)
-									)
-								}
-							}
-						}
-
-						"station" -> {
-							r.withObject { name ->
-								when (name) {
-									"id" -> stopID = r.nextString()
-									"name" -> stopName = r.nextString()
-									"pos" -> {
-										r.withObject { posName ->
-											when (posName) {
-												"lat" -> latitude = r.nextDouble()
-												"lng" -> longitude = r.nextDouble()
-											}
-										}
-									}
-								}
-							}
-						}
-						else -> r.skipValue()
-					}
-				}
+		val times = TimetableApi().stoptimes(stop, limit ?: 12, datetime)
+		var stopName = ""
+		var latitude: BigDecimal = BigDecimal(0)
+		var longitude: BigDecimal = BigDecimal(0)
+		val departures = times.stopTimes.map {
+			Log.i("stop", "stopID recvd: ${it.place.stopId}")
+			if (it.place.arrival == null) {
+				null
+			} else {
+				latitude = it.place.lat
+				longitude = it.place.lon
+				stopName = it.place.name
+				Departure(
+					it.tripId + it.source,
+					Time.fromOffsetTime(it.place.arrival, ZoneId.systemDefault()),
+					0u,
+					it.realTime,
+					Vehicle(
+						it.tripId,
+						Position(0.0, 0.0),
+						0u,
+						Mps(0),
+						LineStub(it.routeShortName, LineType.fromTransitous2(it.mode), Colour.fromHex(it.routeColor)),
+						it.headsign,
+						CongestionLevel.UNKNOWN,
+						OccupancyStatus.UNKNOWN
+					),
+					boarding = 0xffu,
+					alerts = emptyList(),
+					exact = true,
+					terminusArrival = it.place.departure == null
+				)
 			}
-
-			return@withContext StopEvents(
-				events,
-				Stop(stopID, stopName, stopName, "", "transitous", Position(latitude, longitude), listOf()),
-				listOf()
-			)
-		}
+		}.filterNotNull()
+		Log.i("stop", "stopID asked: $stop")
+		StopDepartures(
+			departures,
+			Stop(
+				stop,
+				stopName,
+				stopName,
+				"",
+				"transitous",
+				Position(latitude.toDouble(), longitude.toDouble()),
+				listOf(),
+				null
+			),
+			listOf()
+		)
 	}
 }
-
-fun JsonReader.withObject(f: (String) -> Unit) {
-	beginObject()
-	while (hasNext()) {
-		try {
-			f(nextName())
-		} catch (e: IllegalStateException) {
-			val a: JsonToken = this.peek()
-			Log.d("WithObject", "next is ${a.name} ${nextString()}")
-			throw e
-		}
-	}
-	endObject()
-}
-
-fun JsonReader.withArray(f: (Int) -> Unit) {
-	beginArray()
-	var i = 0
-	while (hasNext()) {
-		f(i)
-		i++
-	}
-	endArray()
-}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousQueryables.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousQueryables.kt
index 71d32872c65e42a1c27a8115ccf99bb3e4cdb819..a3cbf1b7295607079700a2e1071fee8704558e31 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousQueryables.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousQueryables.kt
@@ -5,192 +5,82 @@
 package xyz.apiote.bimba.czwek.api
 
 import android.content.Context
-import android.util.JsonReader
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.responses.ErrorResponse
+import xyz.apiote.bimba.czwek.api.transitous.api.GeocodeApi
+import xyz.apiote.bimba.czwek.api.transitous.api.MapApi
+import xyz.apiote.bimba.czwek.api.transitous.model.Match
 import xyz.apiote.bimba.czwek.repo.Position
 import xyz.apiote.bimba.czwek.repo.Queryable
 import xyz.apiote.bimba.czwek.repo.Stop
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
 import xyz.apiote.bimba.czwek.units.DistanceUnit
+import xyz.apiote.bimba.czwek.units.Km
 import xyz.apiote.bimba.czwek.units.Metre
+import java.util.Locale
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.cos
+
+val MetresPerDegreeLatitude = Metre(111320.0)
 
 suspend fun getTransitousQueryables(query: String, context: Context): List<Queryable> {
 	if (!isNetworkAvailable(context)) {
 		throw TrafficResponseException(0, "", Error(0, R.string.error_offline, R.drawable.error_net))
 	}
 
-	val json = """
-		{
-			"destination": {
-				"type": "Module",
-				"target": "/guesser"
-			},
-			"content_type": "StationGuesserRequest",
-			"content": {
-				"input": "$query",
-				"guess_count": 24
-			}
-		}
-	"""
-	val result = askTransitous(context, json)
+	return withContext(Dispatchers.IO) {
+		GeocodeApi().geocode(query, Locale.getDefault().language).filter { it.type == Match.Type.STOP }
+			.map { Stop(it) }
+	}
+}
 
-	if (result.error != null) {
-		if (result.stream != null) {
-			val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
-			throw TrafficResponseException(result.error.statusCode, response.message, result.error)
-		} else {
-			throw TrafficResponseException(result.error.statusCode, "", result.error)
-		}
+suspend fun locateTransitousQueryables(
+	br: Position,
+	tl: Position,
+	context: Context
+): List<Queryable> {
+	if (!isNetworkAvailable(context)) {
+		throw TrafficResponseException(0, "", Error(0, R.string.error_offline, R.drawable.error_net))
+	}
+
+	val dLat = abs(br.latitude - tl.latitude) / 2
+	var dLon = abs(br.longitude - tl.longitude) / 2
+	val centre = Position(abs(br.latitude + tl.latitude) / 2, abs(br.longitude + tl.longitude) / 2)
+
+	val latitudeLimit = Km(10.0).meters()/MetresPerDegreeLatitude.meters() // ~radius in degrees latitude
+	val corners = if (dLat > latitudeLimit) {
+		dLon = dLon * latitudeLimit / dLat
+		val newBr = Position(centre.latitude-latitudeLimit, centre.longitude+dLon)
+		val newTL = Position(centre.latitude+latitudeLimit, centre.longitude-dLon)
+		Pair(newBr.toString(), newTL.toString())
 	} else {
-		return withContext(Dispatchers.IO) {
-			val queryables = mutableListOf<Queryable>()
-			val r = JsonReader(result.stream!!.bufferedReader())
-			r.beginObject()
-			while (r.hasNext()) {
-				val rootFieldName = r.nextName() // content
-				if (rootFieldName == "content") {
-					r.beginObject()
-					r.nextName() // guesses
-					r.beginArray()
-					while (r.hasNext()) {
-						var id = ""
-						var name = ""
-						var latitude = 0.0
-						var longitude = 0.0
-						r.beginObject()
-						while (r.hasNext()) {
-							val fieldName = r.nextName()
-							when (fieldName) {
-								"id" -> id = r.nextString()
-								"name" -> name = r.nextString()
-								"pos" -> {
-									r.beginObject()
-									while (r.hasNext()) {
-										val positionFieldName = r.nextName()
-										when (positionFieldName) {
-											"lat" -> latitude = r.nextDouble()
-											"lng" -> longitude = r.nextDouble()
-										}
-									}
-									r.endObject()
-								}
-							}
-						}
-						r.endObject()
-						queryables.add(
-							Stop(
-								code = id,
-								name = name,
-								nodeName = name,
-								zone = "",
-								feedID = "transitous",
-								position = Position(latitude = latitude, longitude = longitude),
-								changeOptions = emptyList()
-							)
-						)
-					}
-					r.endArray()
-					r.endObject()
-				} else {
-					r.skipValue()
-				}
-			}
-			r.endObject()
+		Pair(br.toString(), tl.toString())
+	}
 
-			return@withContext queryables
-		}
+	return withContext(Dispatchers.IO) {
+		MapApi().stops(corners.first, corners.second).filter { it.stopId != null }.map { Stop(it) }
 	}
 }
 
-suspend fun locateTransitousQueryables(position: Position, context: Context, radius: DistanceUnit = Metre(500.0)): List<Queryable> {
+suspend fun locateTransitousQueryables(
+	position: Position,
+	context: Context,
+	radius: DistanceUnit = Metre(500.0)
+): List<Queryable> {
 	if (!isNetworkAvailable(context)) {
 		throw TrafficResponseException(0, "", Error(0, R.string.error_offline, R.drawable.error_net))
 	}
 
-	val json = """
-		{
-			"destination": {
-				"type": "Module",
-				"target": "/lookup/geo_station"
-			},
-			"content_type": "LookupGeoStationRequest",
-			"content": {
-				"max_radius": ${radius.meters().toInt()},
-				"pos": {
-					"lat": ${position.latitude},
-					"lng": ${position.longitude}
-				}
-			}
+	val deltaLatitude = radius.meters() / MetresPerDegreeLatitude.meters()
+	val deltaLongitude = radius.meters() / (cos(position.latitude * PI / 180) / 360 * 40075000)
+	val br = Position(position.latitude - deltaLatitude, position.longitude + deltaLongitude)
+	val tl = Position(position.latitude + deltaLatitude, position.longitude - deltaLongitude)
+	return locateTransitousQueryables(br, tl, context).map {
+		when (it) {
+			is Stop -> it
+			else -> null
 		}
-	"""
-	val result = askTransitous(context, json)
-
-	if (result.error != null) {
-		if (result.stream != null) {
-			val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
-			throw TrafficResponseException(result.error.statusCode, response.message, result.error)
-		} else {
-			throw TrafficResponseException(result.error.statusCode, "", result.error)
-		}
-	} else {
-		return withContext(Dispatchers.IO) {
-			val queryables = mutableListOf<Queryable>()
-			val r = JsonReader(result.stream!!.bufferedReader())
-			r.beginObject()
-			while (r.hasNext()) {
-				val rootFieldName = r.nextName() // content
-				if (rootFieldName == "content") {
-					r.beginObject()
-					r.nextName() // guesses
-					r.beginArray()
-					while (r.hasNext()) {
-						var id = ""
-						var name = ""
-						var latitude = 0.0
-						var longitude = 0.0
-						r.beginObject()
-						while (r.hasNext()) {
-							val fieldName = r.nextName()
-							when (fieldName) {
-								"id" -> id = r.nextString()
-								"name" -> name = r.nextString()
-								"pos" -> {
-									r.beginObject()
-									while (r.hasNext()) {
-										val positionFieldName = r.nextName()
-										when (positionFieldName) {
-											"lat" -> latitude = r.nextDouble()
-											"lng" -> longitude = r.nextDouble()
-										}
-									}
-									r.endObject()
-								}
-							}
-						}
-						r.endObject()
-						queryables.add(
-							Stop(
-								code = id,
-								name = name,
-								nodeName = name,
-								zone = "",
-								feedID = "transitous",
-								position = Position(latitude = latitude, longitude = longitude),
-								changeOptions = emptyList()
-							)
-						)
-					}
-					r.endArray()
-					r.endObject()
-				} else {
-					r.skipValue()
-				}
-			}
-			r.endObject()
-			return@withContext queryables
-		}
-	}
+	}.filterNotNull().sortedWith(Stop.distanceComparator(position))
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousSettings.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousSettings.kt
deleted file mode 100644
index b135a0c210d068dcde94ba25b825e54ded1ea350..0000000000000000000000000000000000000000
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/transitousSettings.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-// SPDX-FileCopyrightText: Adam Evyčędo
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package xyz.apiote.bimba.czwek.api
-
-import android.content.Context
-import androidx.preference.PreferenceManager
-
-fun isTransitousEnabled(context: Context): Boolean {
-	return PreferenceManager.getDefaultSharedPreferences(context).getBoolean("transitous_enabled", false)
-}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
index 01f592aa9c53a29de8ef478f6d1ff9f504d97335..fe2c6f61568b7c976c20ded71586077877d1329b 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
@@ -633,4 +633,4 @@ 			}
 		}
 		return content
 	}
-}
\ No newline at end of file
+}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt
index 4b740bd0b13df60606a1ab0eec3e0f1e884a2a39..01fea09a4f4c194afb4d5092864db448f821989b 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt
@@ -18,7 +18,8 @@ 		return rgb
 	}
 
 	companion object {
-		fun fromHex(hex: String): Colour {
+		fun fromHex(hex: String?): Colour {
+			if (hex == null) return Colour(255u, 255u, 255u)
 			return hex.removePrefix("#").let {
 				if (it.isEmpty()) {
 					Colour(255u, 255u, 255u)




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt
index 46be84493816f7bcf0e5e8d25416344dc81b6172..23b70c21de9ad5346c865729fafc721eac726b8c 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt
@@ -7,6 +7,7 @@
 import xyz.apiote.bimba.czwek.api.LineTypeV1
 import xyz.apiote.bimba.czwek.api.LineTypeV2
 import xyz.apiote.bimba.czwek.api.LineTypeV3
+import xyz.apiote.bimba.czwek.api.transitous.model.Mode
 
 enum class LineType {
 	UNKNOWN, TRAM, BUS, TROLLEYBUS, METRO, RAIL, FERRY, CABLE_TRAM, CABLE_CAR, FUNICULAR, MONORAIL, PLANE;
@@ -59,6 +60,38 @@ 				10 -> BUS
 				11 -> FERRY
 				12 -> UNKNOWN // other
 				else -> UNKNOWN
+			}
+		}
+
+		fun fromTransitous2(mode: Mode): LineType {
+			return when(mode) {
+				Mode.AIRPLANE -> PLANE
+				Mode.WALK -> UNKNOWN
+				Mode.BIKE -> UNKNOWN
+				Mode.CAR -> UNKNOWN
+				Mode.BIKE_RENTAL -> UNKNOWN
+				Mode.BIKE_TO_PARK -> UNKNOWN
+				Mode.CAR_TO_PARK -> UNKNOWN
+				Mode.CAR_HAILING -> UNKNOWN
+				Mode.CAR_SHARING -> UNKNOWN
+				Mode.CAR_PICKUP -> UNKNOWN
+				Mode.CAR_RENTAL -> UNKNOWN
+				Mode.FLEXIBLE -> UNKNOWN
+				Mode.SCOOTER_RENTAL -> UNKNOWN
+				Mode.TRANSIT -> UNKNOWN
+				Mode.TRAM -> TRAM
+				Mode.SUBWAY -> METRO
+				Mode.FERRY -> FERRY
+				Mode.METRO -> METRO
+				Mode.BUS -> BUS
+				Mode.COACH -> BUS
+				Mode.RAIL -> RAIL
+				Mode.HIGHSPEED_RAIL -> RAIL
+				Mode.LONG_DISTANCE -> RAIL
+				Mode.NIGHT_RAIL -> RAIL
+				Mode.REGIONAL_FAST_RAIL -> RAIL
+				Mode.REGIONAL_RAIL -> RAIL
+				Mode.OTHER -> UNKNOWN
 			}
 		}
 	}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
index 0b47623ff0b1f191b90a9e19f308f9a50878117f..95819178a6e46c81785a0ba505ce4fab752ac98d 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
@@ -50,7 +50,6 @@ import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV1
 import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV2
 import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV3
 import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV4
-import xyz.apiote.bimba.czwek.units.Metre
 import java.time.LocalDate
 
 // todo [3.2] in Repository check if responses are BARE or HTML
@@ -174,12 +173,10 @@ 		bl: Position,
 		tr: Position,
 	): List<Locatable>? {
 		val transitousQueryables = if (Server.get(context).feeds.transitousEnabled()) {
-			val centre = Position(
-				latitude = (bl.latitude + tr.latitude) / 2,
-				longitude = (bl.longitude + tr.longitude) / 2
-			)
 			locateTransitousQueryables(
-				centre, context, Metre(centre.distanceTo(bl).coerceAtMost(5000f).toDouble())
+				Position(bl.latitude, tr.longitude),
+				Position(tr.latitude, bl.longitude),
+				context
 			).map {
 				when (it) {
 					is Stop -> it




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt
index d03cf34ae8cb789df7e078f0f24c509e63343a12..39ec428637d1165e0c41b12da12f7420de27a8e9 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt
@@ -9,6 +9,7 @@ import xyz.apiote.bimba.czwek.api.PositionV1
 
 data class Position(val latitude: Double, val longitude: Double) {
 	constructor(p: PositionV1) : this(p.latitude, p.longitude)
+
 	fun isZero(): Boolean {
 		return latitude == 0.0 && longitude == 0.0
 	}
@@ -22,4 +23,23 @@ 			latitude = other.latitude
 			longitude = other.longitude
 		})
 	}
-}
\ No newline at end of file
+
+	override fun toString(): String {
+		return "$latitude,$longitude"
+	}
+
+	companion object {
+		fun comparator(centre: Position) = object : Comparator<Position> {
+			override fun compare(
+				o1: Position?,
+				o2: Position?
+			): Int {
+				if (o1 == null || o2 == null) {
+					throw NullPointerException()
+				}
+
+				return o1.distanceTo(centre).compareTo(o2.distanceTo(centre))
+			}
+		}
+	}
+}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt
index af81fbf17847b928c5c2ee0c674cb4a451bf858a..8448bc7210a18e9e0d352a657e4ef0db6139848a 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt
@@ -19,6 +19,8 @@ import xyz.apiote.bimba.czwek.RoundedBackgroundSpan
 import xyz.apiote.bimba.czwek.api.StopV1
 import xyz.apiote.bimba.czwek.api.StopV2
 import xyz.apiote.bimba.czwek.api.StopV3
+import xyz.apiote.bimba.czwek.api.transitous.model.Match
+import xyz.apiote.bimba.czwek.api.transitous.model.Place
 
 
 data class Stop(
@@ -28,8 +30,20 @@ 	val nodeName: String,
 	val zone: String,
 	val feedID: String?,
 	val position: Position,
-	val changeOptions: List<ChangeOption>
+	val changeOptions: List<ChangeOption>,
+	val description: String?
 ) : Queryable, Locatable, StopAbstract {
+	companion object {
+		fun distanceComparator(centre: Position) = object : Comparator<Stop> {
+			override fun compare(
+				o1: Stop?,
+				o2: Stop?
+			): Int {
+				return Position.comparator(centre).compare(o1?.location(), o2?.location())
+			}
+
+		}
+	}
 
 	override fun icon(context: Context, scale: Float): Drawable {
 		return super.icon(context, nodeName, scale)
@@ -46,7 +60,8 @@ 		s.name,
 		s.zone,
 		null,
 		Position(s.position),
-		s.changeOptions.map { ChangeOption(it) })
+		s.changeOptions.map { ChangeOption(it) },
+		null)
 
 	constructor(s: StopV2) : this(
 		s.code,
@@ -55,7 +70,8 @@ 		s.nodeName,
 		s.zone,
 		s.feedID,
 		Position(s.position),
-		s.changeOptions.map { ChangeOption(it) })
+		s.changeOptions.map { ChangeOption(it) },
+		null)
 
 	constructor(s: StopV3) : this(
 		s.code,
@@ -64,7 +80,30 @@ 		s.nodeName,
 		s.zone,
 		s.feedID,
 		Position(s.position),
-		s.changeOptions.map { ChangeOption(it) })
+		s.changeOptions.map { ChangeOption(it) },
+		null)
+
+	constructor(s: Match): this(
+		s.id,
+		s.name,
+		s.areas.sortedBy { it.adminLevel }.map { it.name }.distinct().joinToString() + s.name,
+		"",
+		"transitous",
+		Position(s.lat.toDouble(), s.lon.toDouble()),
+		emptyList(),
+		s.areas.sortedBy { it.adminLevel }.map { it.name }.distinct().joinToString()
+	)
+
+	constructor(s: Place): this(
+		s.stopId!!,
+		s.name,
+		s.name,
+		"",
+		"transitous",
+		Position(s.lat.toDouble(), s.lon.toDouble()),
+		emptyList(),
+		""
+	)
 
 	fun changeOptions(context: Context, decoration: LineDecoration): Pair<Spannable, String> {
 		return Pair(changeOptions.groupBy { it.line }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt
index 6edad804085f30265578b89d4abe728cf3c147c8..040b148244d5331bbd508a160687ab3aa747894d 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt
@@ -30,6 +30,7 @@ class BimbaViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
 	val root: View = itemView.findViewById(R.id.suggestion)
 	val icon: ImageView = itemView.findViewById(R.id.suggestion_image)
 	val title: TextView = itemView.findViewById(R.id.suggestion_title)
+	val changeOptions: TextView = itemView.findViewById(R.id.suggestion_change_options)
 	val description: TextView = itemView.findViewById(R.id.suggestion_description)
 	val feedName: TextView = itemView.findViewById(R.id.feed_name)
 	val distance: TextView = itemView.findViewById(R.id.distance)
@@ -77,7 +78,7 @@ 			holder?.icon?.apply {
 				setImageDrawable(stopStub.icon(context!!))
 				contentDescription = context.getString(R.string.stop_content_description)
 			}
-			holder?.description?.text = when {
+			holder?.changeOptions?.text = when {
 				stopStub.zone != "" && stopStub.onDemand -> context?.getString(
 					R.string.stop_stub_on_demand_in_zone,
 					stopStub.zone
@@ -147,12 +148,23 @@ 				holder?.feedName?.visibility = View.GONE
 			}
 			context?.let {
 				stop.changeOptions(it, Stop.LineDecoration.fromPreferences(context)).let { changeOptions ->
-					holder?.description?.apply {
-						text = changeOptions.first
-						contentDescription = changeOptions.second
+					if (changeOptions.first.isEmpty()) {
+						holder?.changeOptions?.visibility = View.GONE
+					} else {
+						holder?.changeOptions?.apply {
+							text = changeOptions.first
+							contentDescription = changeOptions.second
+							visibility = View.VISIBLE
+						}
 					}
 				}
 			}
+			if (stop.description.isNullOrBlank()) {
+				holder?.description?.visibility = View.GONE
+			} else {
+				holder?.description?.visibility = View.VISIBLE
+				holder?.description?.text = stop.description
+			}
 		}
 
 		private fun bindLine(
@@ -172,7 +184,7 @@ 				holder?.feedName?.visibility = View.VISIBLE
 				holder?.feedName?.text = feeds?.get(line.feedID)?.name ?: ""
 			}
 			holder?.title?.text = line.name
-			holder?.description?.text = if (line.headsigns.size == 1) {
+			holder?.changeOptions?.text = if (line.headsigns.size == 1) {
 				context?.getString(
 					R.string.line_headsign,
 					line.headsigns[0].joinToString { it })
@@ -182,7 +194,7 @@ 					R.string.line_headsigns,
 					line.headsigns[0].joinToString { it },
 					line.headsigns[1].joinToString { it })
 			}
-			holder?.description?.contentDescription = if (line.headsigns.size == 1) {
+			holder?.changeOptions?.contentDescription = if (line.headsigns.size == 1) {
 				context?.getString(
 					R.string.line_headsign_content_description,
 					line.headsigns[0].joinToString { it })




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
index b29210b31e39602eb0a83b32b219f1d55c41ee7b..b8e446eebb0382c72d17ccc9290b5cf967fe8d37 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
@@ -114,20 +114,20 @@ 		binding.resultsRecycler.adapter = adapter
 
 		when (getMode()) {
 			Mode.MODE_LOCATION -> {
-				supportActionBar?.title = getString(R.string.stops_nearby)
+				binding.topAppBar.title = getString(R.string.stops_nearby)
 				locate()
 			}
 
 			Mode.MODE_SHORT_CODE_LOCATION -> {
 				val query = intent.extras?.getString(QUERY_KEY)
-				supportActionBar?.title = getString(R.string.stops_near_code, query)
+				binding.topAppBar.title = getString(R.string.stops_near_code, query)
 				shortOLC = OpenLocationCode(query)
 				locate()
 			}
 
 			Mode.MODE_SHORT_CODE -> {
 				val query = intent.extras?.getString(QUERY_KEY)
-				supportActionBar?.title = getString(R.string.stops_near_code, query)
+				binding.topAppBar.title = getString(R.string.stops_near_code, query)
 				val split = query!!.trim().split(" ")
 				val code = split.first().trim(',').trim()
 				val freePart = split.drop(1).joinToString(" ")
@@ -147,7 +147,7 @@ 			Mode.MODE_POSITION -> {
 				val query = intent.extras?.getString(QUERY_KEY)
 				val lat = intent.extras?.getDouble(LATITUDE_KEY)
 				val lon = intent.extras?.getDouble(LONGITUDE_KEY)
-				supportActionBar?.title = getString(R.string.stops_near_code, query)
+				binding.topAppBar.title = getString(R.string.stops_near_code, query)
 				getQueryablesByLocation(Location(null).apply {
 					latitude = lat!!
 					longitude = lon!!
@@ -156,7 +156,7 @@ 			}
 
 			Mode.MODE_SEARCH -> {
 				val query = intent.extras?.getString(QUERY_KEY)!!
-				supportActionBar?.title = getString(R.string.results_for, query)
+				binding.topAppBar.title = getString(R.string.results_for, query)
 				getQueryablesByQuery(query, this)
 			}
 		}




diff --git a/app/src/main/res/layout/result.xml b/app/src/main/res/layout/result.xml
index 76614ac6dfa60c71c84bfc0c97448af7c18426af..6146e5c5891d3f052e445ddd3a2e4c8fff9fb1a3 100644
--- a/app/src/main/res/layout/result.xml
+++ b/app/src/main/res/layout/result.xml
@@ -74,7 +74,7 @@ 		tool:text="TfL London" />
 
 	<!-- todo maxWidth or separate layout for graphView -->
 	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/suggestion_description"
+		android:id="@+id/suggestion_change_options"
 		style="@style/Theme.Bimba.SearchResult.Description"
 		android:layout_width="0dp"
 		android:layout_height="wrap_content"
@@ -85,12 +85,31 @@ 		android:lineSpacingMultiplier="1.6"
 		android:maxWidth="360dp"
 		android:paddingTop="4dp"
 		android:paddingBottom="4dp"
-		app:layout_constraintBottom_toBottomOf="parent"
 		app:layout_constraintEnd_toStartOf="@+id/distance"
 		app:layout_constraintHorizontal_bias="0.0"
 		app:layout_constraintStart_toStartOf="@+id/suggestion_title"
 		app:layout_constraintTop_toBottomOf="@+id/feed_name"
 		app:layout_constraintVertical_bias="1.0"
 		tool:text="Metropolitan » Baker Street, Tower Hill The Monument, Westminster, Piccadilly Circus, Oxford Street" />
+
+	<!-- todo maxWidth or separate layout for graphView -->
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/suggestion_description"
+		style="@style/Theme.Bimba.SearchResult.Description"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginTop="4dp"
+		android:layout_marginEnd="4dp"
+		android:includeFontPadding="false"
+		android:lineSpacingMultiplier="1.6"
+		android:maxWidth="360dp"
+		android:paddingTop="4dp"
+		android:paddingBottom="4dp"
+		app:layout_constraintEnd_toStartOf="@+id/distance"
+		app:layout_constraintHorizontal_bias="0.0"
+		app:layout_constraintStart_toStartOf="@+id/suggestion_title"
+		app:layout_constraintTop_toBottomOf="@+id/suggestion_change_options"
+		app:layout_constraintVertical_bias="1.0"
+		tool:text="England, London" />
 
 </androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file