Bimba.git

commit 31660a80e777e6f685543e3fca942e34882b4e2d

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

merge develop into master for version 3.3.0

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


diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc
index e3c5a8f241db4dcc20a981fbc9b060c28283ad16..115cb1a406cd760a463bc2ee73f41809c5a42379 100644
--- a/CHANGELOG.adoc
+++ b/CHANGELOG.adoc
@@ -13,7 +13,28 @@ == Unreleased
 
 * Travel planning
 * Offline timetable
+
+== [3.3] – 2024-05-16
+
+=== Added
+
 * Alerts
+* Selecting date
+* Filtering by time or line
+* Automatically adding locales from build.gradle
+
+=== Changed
+
+* Cached localities have lower alpha
+* Updated deprecated methods
+* Updated dependencies
+
+=== Fixed
+
+* Landscape about screen
+* Vehicle capabilities on map
+* Lines directions other than 2
+* Feed info is cached, not latest response
 
 == [3.2] – 2024-03-13
 




diff --git a/README.adoc b/README.adoc
index 38eaec80f70222d212a80400c87d9782157e7096..d0ddcbe2c696355c6b278213be5538fd8556182e 100644
--- a/README.adoc
+++ b/README.adoc
@@ -4,7 +4,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 = Bimba
 Adam Evyčędo <me@apiote.xyz>
-v3.2 2024-03-13
+v3.3 2024-05-16
 :toc:
 
 Bimba is a FLOSS public transport passenger companion; a timetable in your pocket.
@@ -65,4 +65,3 @@
 * https://github.com/tebriz159 for new logo
 
 * https://fonts.google.com/icons[Material Icons], © Google Apache 2.0
-* https://github.com/mancj/MaterialSearchBar[Search bar], © mancj MIT




diff --git a/app/build.gradle b/app/build.gradle
index 8c58bf222e861cffd0db7c5e995856f2e498b6f4..74c83573693cf4ef41816d56aaaf424ba0785c77 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -11,6 +11,7 @@     id 'com.android.application'
     id 'org.jetbrains.kotlin.android'
     id "org.jetbrains.kotlin.plugin.parcelize"
     id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22'
+    id 'com.mermake.locale-resource-generator' version '0.1'
 }
 
 android {
@@ -20,15 +21,16 @@     defaultConfig {
         applicationId "xyz.apiote.bimba.czwek"
         minSdk 21
         targetSdk 34
-        versionCode 24
-        versionName "3.2.0"
+        versionCode 25
+        versionName "3.3.0"
 
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-        resourceConfigurations += ["en", "pl", "it"]
+        resourceConfigurations += ["en", "pl", "it", "de"]
     }
 
     applicationVariants.configureEach { variant ->
         variant.resValue "string", "versionName", variant.versionName
+        variant.resValue "string", "applicationId", variant.applicationId
     }
 
     buildTypes {
@@ -50,12 +52,12 @@     buildToolsVersion = '34.0.0'
 }
 
 dependencies {
-    implementation 'androidx.core:core-ktx:1.12.0'
+    implementation 'androidx.core:core-ktx:1.13.1'
     implementation 'androidx.appcompat:appcompat:1.6.1'
-    implementation 'com.google.android.material:material:1.11.0'
+    implementation 'com.google.android.material:material:1.12.0'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
-    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.7.0'
-    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
+    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.0'
+    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
     implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
     implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
@@ -63,7 +65,7 @@     implementation 'androidx.core:core-splashscreen:1.0.1'
     implementation 'com.google.openlocationcode:openlocationcode:1.0.4'
     implementation 'org.osmdroid:osmdroid-android:6.1.18'
     implementation 'org.yaml:snakeyaml:2.2'
-    implementation 'androidx.activity:activity-ktx:1.8.2'
+    implementation 'androidx.activity:activity-ktx:1.9.0'
     implementation 'com.google.openlocationcode:openlocationcode:1.0.4'
     implementation 'com.otaliastudios:zoomlayout:1.9.0'
     implementation 'dev.bandb.graphview:graphview:0.8.1'




diff --git a/app/src/debug/java/xyz/apiote/bimba/czwek/api/responses/DevResponses.kt b/app/src/debug/java/xyz/apiote/bimba/czwek/api/responses/DevResponses.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e7e36dc7168480786df2c912ee01962d0bfb3826
--- /dev/null
+++ b/app/src/debug/java/xyz/apiote/bimba/czwek/api/responses/DevResponses.kt
@@ -0,0 +1,123 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.bimba.czwek.api.responses
+
+import xyz.apiote.bimba.czwek.api.AlertV1
+import xyz.apiote.bimba.czwek.api.DepartureV4
+import xyz.apiote.bimba.czwek.api.LineV3
+import xyz.apiote.bimba.czwek.api.LocatableV3
+import xyz.apiote.bimba.czwek.api.QueryableV4
+import xyz.apiote.bimba.czwek.api.StopV2
+import xyz.apiote.bimba.czwek.api.UnknownResourceVersionException
+import xyz.apiote.bimba.czwek.api.VehicleV3
+import xyz.apiote.bimba.czwek.api.structs.FeedInfoV2
+import xyz.apiote.fruchtfleisch.Reader
+import java.io.InputStream
+
+data class DeparturesResponseDev(
+	val alerts: List<AlertV1>,
+	val departures: List<DepartureV4>,
+	val stop: StopV2
+) : DeparturesResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): DeparturesResponseDev {
+			val alerts = mutableListOf<AlertV1>()
+			val departures = mutableListOf<DepartureV4>()
+
+			val reader = Reader(stream)
+			val alertsNum = reader.readUInt().toULong()
+			for (i in 0UL until alertsNum) {
+				val alert = AlertV1.unmarshal(stream)
+				alerts.add(alert)
+			}
+			val departuresNum = reader.readUInt().toULong()
+			for (i in 0UL until departuresNum) {
+				val departure = DepartureV4.unmarshal(stream)
+				departures.add(departure)
+			}
+
+			return DeparturesResponseDev(alerts, departures, StopV2.unmarshal(stream))
+		}
+	}
+}
+
+data class FeedsResponseDev(
+	val feeds: List<FeedInfoV2>
+) : FeedsResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): FeedsResponseDev {
+			val feeds = mutableListOf<FeedInfoV2>()
+			val reader = Reader(stream)
+			val n = reader.readUInt().toULong()
+			for (i in 0UL until n) {
+				feeds.add(FeedInfoV2.unmarshal(stream))
+			}
+			return FeedsResponseDev(feeds)
+		}
+	}
+}
+
+data class LineResponseDev(
+	val line: LineV3
+) : LineResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): LineResponseDev {
+			return LineResponseDev(LineV3.unmarshal(stream))
+		}
+	}
+}
+
+data class LocatablesResponseDev(val locatables: List<LocatableV3>) : LocatablesResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): LocatablesResponseDev {
+			val locatables = mutableListOf<LocatableV3>()
+			val reader = Reader(stream)
+			val n = reader.readUInt().toULong()
+			for (i in 0UL until n) {
+				when (val r = reader.readUInt().toULong()) {
+					0UL -> {
+						locatables.add(StopV2.unmarshal(stream))
+					}
+
+					1UL -> {
+						locatables.add(VehicleV3.unmarshal(stream))
+					}
+
+					else -> {
+						throw UnknownResourceVersionException("Locatable/$r", 0u)
+					}
+				}
+			}
+			return LocatablesResponseDev(locatables)
+		}
+	}
+}
+
+
+data class QueryablesResponseDev(val queryables: List<QueryableV4>) : QueryablesResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): QueryablesResponseDev {
+			val queryables = mutableListOf<QueryableV4>()
+			val reader = Reader(stream)
+			val n = reader.readUInt().toULong()
+			for (i in 0UL until n) {
+				when (val r = reader.readUInt().toULong()) {
+					0UL -> {
+						queryables.add(StopV2.unmarshal(stream))
+					}
+
+					1UL -> {
+						queryables.add(LineV3.unmarshal(stream))
+					}
+
+					else -> {
+						throw UnknownResourceVersionException("Queryable/$r", 0u)
+					}
+				}
+			}
+			return QueryablesResponseDev(queryables)
+		}
+	}
+}




diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2714bb3942edb3cf3ad94de74d9cacc8b8a5eca8..f7c0314f0b78f5e5a72bccc33f8d7b940088a0d9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,7 +22,7 @@ 		android:enableOnBackInvokedCallback="true"
 		android:fullBackupContent="@xml/backup_rules"
 		android:icon="@mipmap/ic_launcher"
 		android:label="@string/app_name"
-		android:localeConfig="@xml/locales_config"
+		android:localeConfig="@xml/locale_config"
 		android:roundIcon="@mipmap/ic_launcher_round"
 		android:supportsRtl="true"
 		android:theme="@style/Theme.Bimba.Style"




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt
index 8a942fd52c4c7aed749d85e8dc70c1953f7bdc22..3ebf1676b286d06d1505a26a162af9843274a88a 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt
@@ -7,6 +7,8 @@
 import android.content.Context
 import android.content.Context.MODE_PRIVATE
 import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.Build
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.R
@@ -17,6 +19,8 @@ import java.net.HttpURLConnection
 import java.net.MalformedURLException
 import java.net.URL
 import java.net.URLEncoder
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
 
 // todo [3.2] constants
 // todo [3.2] split api files to classes files
@@ -45,20 +49,20 @@ data class Result(val stream: InputStream?, val error: Error?)
 
 data class Error(val statusCode: Int, val stringResource: Int, val imageResource: Int)
 
-suspend fun getBimba(cm: ConnectivityManager, server: Server): Result {
+suspend fun getBimba(context: Context, server: Server): Result {
 	return try {
 		rawRequest(
-			URL("${hostWithScheme(server.host)}/.well-known/traffic-api"), server, cm, emptyArray()
+			URL("${hostWithScheme(server.host)}/.well-known/traffic-api"), server, context, emptyArray()
 		)
 	} catch (e: MalformedURLException) {
 		Result(null, Error(0, R.string.error_url, R.drawable.error_url))
 	}
 }
 
-suspend fun getFeeds(cm: ConnectivityManager, server: Server): Result {
+suspend fun getFeeds(context: Context, server: Server): Result {
 	return try {
 		rawRequest(
-			URL("${server.apiPath}/"), server, cm, arrayOf(1u, 2u)
+			URL("${server.apiPath}/"), server, context, arrayOf(1u, 2u)
 		)
 	} catch (_: MalformedURLException) {
 		Result(null, Error(0, R.string.error_url, R.drawable.error_url))
@@ -66,7 +70,7 @@ 	}
 }
 
 suspend fun queryQueryables(
-	cm: ConnectivityManager, server: Server, query: String, limit: Int? = null
+	context: Context, server: Server, query: String, limit: Int? = null
 ): Result {
 	val params = mutableMapOf("q" to query)
 	if (limit != null) {
@@ -77,61 +81,67 @@ 		server,
 		"queryables",
 		null,
 		params,
-		cm,
-		arrayOf(1u, 2u, 3u),
+		context,
+		arrayOf(1u, 2u, 3u, 4u),
 		null
 	)
 }
 
-suspend fun locateQueryables(cm: ConnectivityManager, server: Server, near: PositionV1): Result {
+suspend fun locateQueryables(context: Context, server: Server, near: PositionV1): Result {
 	return request(
 		server,
 		"queryables",
 		null,
 		mapOf("near" to near.toString()),
-		cm,
-		arrayOf(1u, 2u, 3u),
+		context,
+		arrayOf(1u, 2u, 3u, 4u),
 		null
 	)
 }
 
 suspend fun getLocatablesIn(
-	cm: ConnectivityManager, server: Server, bl: PositionV1, tr: PositionV1
+	context: Context, server: Server, bl: PositionV1, tr: PositionV1
 ): Result {
 	return request(
 		server,
 		"locatables",
 		null,
 		mapOf("lb" to bl.toString(), "rt" to tr.toString()),
-		cm,
+		context,
 		arrayOf(1u, 2u, 3u),
 		null
 	)
 }
 
-suspend fun getLine(cm: ConnectivityManager, server: Server, feedID: String, line: String): Result {
+suspend fun getLine(
+	context: Context,
+	server: Server,
+	feedID: String,
+	lineName: String,
+	lineID: String
+): Result {
 	return request(
 		server,
 		"lines",
-		line,
-		mapOf(),
-		cm,
-		arrayOf(1u, 2u),
+		lineName,
+		mapOf("line" to lineID),
+		context,
+		arrayOf(1u, 2u, 3u),
 		feedID
 	)
 }
 
 suspend fun getDepartures(
-	cm: ConnectivityManager,
+	context: Context,
 	server: Server,
 	feedID: String,
 	stop: String,
-	line: String? = null,
+	date: LocalDate?,
 	limit: Int? = null
 ): Result {
 	val params = mutableMapOf("code" to stop)
-	if (line != null) {
-		params["line"] = line
+	if (date != null) {
+		params["date"] = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
 	}
 	if (limit != null) {
 		params["limit"] = limit.toString(10)
@@ -141,8 +151,8 @@ 		server,
 		"departures",
 		null,
 		params,
-		cm,
-		arrayOf(1u, 2u, 3u),
+		context,
+		arrayOf(1u, 2u, 3u, 4u),
 		feedID
 	)
 }
@@ -165,14 +175,17 @@ 	}
 }
 
 suspend fun rawRequest(
-	url: URL, server: Server, cm: ConnectivityManager, responseVersion: Array<UInt>
+	url: URL, server: Server, context: Context, responseVersion: Array<UInt>
 ): Result {
-	@Suppress("DEPRECATION")  // fixme later(API_29, API_23) https://developer.android.com/reference/android/net/ConnectivityManager#getActiveNetwork()
-	if (cm.activeNetworkInfo == null) {
+	if (!isNetworkAvailable(context)) {
 		return Result(null, Error(0, R.string.error_offline, R.drawable.error_net))
 	}
 	return withContext(Dispatchers.IO) {
 		val c = (url.openConnection() as HttpURLConnection).apply {
+			setRequestProperty(
+				"User-Agent",
+				"${context.getString(R.string.applicationId)}/${context.getString(R.string.versionName)} (${Build.VERSION.SDK_INT})"
+			)
 			setRequestProperty("X-Bimba-Token", server.token)
 			responseVersion.forEach { addRequestProperty("Accept", "application/$it+bare") }
 		}
@@ -189,12 +202,30 @@ 		}
 	}
 }
 
+private fun isNetworkAvailable(context: Context): Boolean {
+	val connectivityManager =
+		context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+	
+	return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+		connectivityManager.activeNetwork?.let {network ->
+			connectivityManager.getNetworkCapabilities(network)?.let {capabilities ->
+				capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || capabilities.hasTransport(
+					NetworkCapabilities.TRANSPORT_CELLULAR
+				) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
+			}
+		}?: false
+	} else {
+		@Suppress("DEPRECATION")
+		connectivityManager.activeNetworkInfo?.isConnected ?: false
+	}
+}
+
 suspend fun request(
 	server: Server,
 	resource: String,
 	item: String?,
 	params: Map<String, String>,
-	cm: ConnectivityManager,
+	context: Context,
 	responseVersion: Array<UInt>,
 	feeds: String?
 ): Result {
@@ -204,7 +235,7 @@ 			"${server.apiPath}/${feeds?.ifEmpty { server.feeds.getIDs() } ?: server.feeds.getIDs()}/$resource${
 				if (item == null) {
 					""
 				} else {
-					"/$item"
+					"/${URLEncoder.encode(item, "utf-8")}"
 				}
 			}${
 				params.map {
@@ -216,7 +247,7 @@ 					}"
 				}.joinToString("&", "?")
 			}"
 		)
-		rawRequest(url, server, cm, responseVersion)
+		rawRequest(url, server, context, responseVersion)
 	}
 }
 




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Interfaces.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Interfaces.kt
index ca416f8c472519ef800011d579d02f1d735e6bf6..4bfc2f5c505cd365ec5a973bc01a4558856f35c9 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/Interfaces.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Interfaces.kt
@@ -7,6 +7,7 @@
 interface QueryableV1
 interface QueryableV2
 interface QueryableV3
+interface QueryableV4
 interface LocatableV1
 interface LocatableV2
 interface LocatableV3
\ No newline at end of file




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 5a1468c35018ec56820e55c0129b1983e8aaf534..9e604c62e01918732d5d03565aa5492cb5989dd5 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,11 +11,6 @@ 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.LocalDate
-import java.time.LocalDateTime
-import java.time.LocalTime
-import java.time.ZoneId
-import java.time.ZonedDateTime
 import kotlin.reflect.KClass
 
 class TrafficFormatException(override val message: String) : IllegalArgumentException()
@@ -200,16 +195,6 @@ 				reader.readI8(),
 				reader.readString()
 			)
 		}
-	}
-
-	fun toDateTime(): ZonedDateTime {
-		return ZonedDateTime.of(
-			LocalDateTime.of(
-				LocalDate.now().plusDays(DayOffset.toLong()),
-				LocalTime.of(Hour.toInt(), Minute.toInt(), Second.toInt())
-			),
-			ZoneId.of(Zone)
-		)
 	}
 }
 
@@ -456,6 +441,35 @@ 		}
 	}
 }
 
+data class DepartureV4(
+	val ID: String,
+	val time: Time,
+	val status: VehicleStatusV1,
+	val isRealtime: Boolean,
+	val vehicle: VehicleV3,
+	val boarding: UByte,
+	val alerts: List<AlertV1>
+) {
+
+	companion object {
+		fun unmarshal(stream: InputStream): DepartureV4 {
+			val reader = Reader(stream)
+			val id = reader.readString()
+			val time = Time.unmarshal(stream)
+			val status = VehicleStatusV1.of(reader.readUInt().toULong().toUInt())
+			val isRealtime = reader.readBoolean()
+			val vehicle = VehicleV3.unmarshal(stream)
+			val boarding = reader.readU8()
+			val alertsNum = reader.readUInt().toULong()
+			val alerts = mutableListOf<AlertV1>()
+			for (i in 0UL until alertsNum) {
+				alerts.add(AlertV1.unmarshal(stream))
+			}
+			return DepartureV4(id, time, status, isRealtime, vehicle, boarding, alerts)
+		}
+	}
+}
+
 @Parcelize
 data class StopV2(
 	val code: String,
@@ -465,7 +479,7 @@ 	val zone: String,
 	val feedID: String,
 	val position: PositionV1,
 	val changeOptions: List<ChangeOptionV1>
-) : QueryableV2, Parcelable, LocatableV2, QueryableV3, LocatableV3 {
+) : QueryableV2, Parcelable, LocatableV2, QueryableV3, LocatableV3, QueryableV4 {
 	companion object {
 		fun unmarshal(stream: InputStream): StopV2 {
 			val reader = Reader(stream)
@@ -599,6 +613,54 @@ 			for (i in 0UL until directionsNum) {
 				graphs.add(LineGraph.unmarshal(stream))
 			}
 			return LineV2(
+				name = name,
+				colour = colour,
+				type = LineTypeV3.of(type.toULong().toUInt()),
+				feedID = feedID,
+				headsigns = headsigns,
+				graphs = graphs
+			)
+		}
+	}
+}
+
+data class LineV3(
+	val id: String,
+	val name: String,
+	val colour: ColourV1,
+	val type: LineTypeV3,
+	val feedID: String,
+	val headsigns: List<List<String>>,
+	val graphs: List<LineGraph>,
+) : QueryableV4 {
+	override fun toString(): String {
+		return "$name ($type) [$colour]\n${headsigns.map { "-> ${it.joinToString()}" }}"
+	}
+
+	companion object {
+		fun unmarshal(stream: InputStream): LineV3 {
+			val reader = Reader(stream)
+			val id = reader.readString()
+			val name = reader.readString()
+			val colour = ColourV1.unmarshal(stream)
+			val type = reader.readUInt()
+			val feedID = reader.readString()
+			var directionsNum = reader.readUInt().toULong()
+			val headsigns = (0UL until directionsNum).map {
+				val headsignsNum = reader.readUInt().toULong()
+				val headsignsDir = mutableListOf<String>()
+				for (j in 0UL until headsignsNum) {
+					headsignsDir.add(reader.readString())
+				}
+				headsignsDir
+			}
+			directionsNum = reader.readUInt().toULong()
+			val graphs = mutableListOf<LineGraph>()
+			for (i in 0UL until directionsNum) {
+				graphs.add(LineGraph.unmarshal(stream))
+			}
+			return LineV3(
+				id = id,
 				name = name,
 				colour = colour,
 				type = LineTypeV3.of(type.toULong().toUInt()),




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Departures.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Departures.kt
index ea9d6279fd223e940cb9df6f90889e221c2e435d..0d053162b88f3d4f2a65975d1d2cff92e2b979a4 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Departures.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Departures.kt
@@ -8,9 +8,9 @@ import xyz.apiote.bimba.czwek.api.AlertV1
 import xyz.apiote.bimba.czwek.api.DepartureV1
 import xyz.apiote.bimba.czwek.api.DepartureV2
 import xyz.apiote.bimba.czwek.api.DepartureV3
+import xyz.apiote.bimba.czwek.api.DepartureV4
 import xyz.apiote.bimba.czwek.api.StopV1
 import xyz.apiote.bimba.czwek.api.StopV2
-import xyz.apiote.bimba.czwek.api.responses.UnknownResponseVersion
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
 
@@ -19,25 +19,26 @@ 	companion object {
 		fun unmarshal(stream: InputStream): DeparturesResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> DeparturesResponseDev.unmarshal(stream)
+				// 0UL -> DeparturesResponseDev.unmarshal(stream)
 				1UL -> DeparturesResponseV1.unmarshal(stream)
 				2UL -> DeparturesResponseV2.unmarshal(stream)
 				3UL -> DeparturesResponseV3.unmarshal(stream)
+				4UL -> DeparturesResponseV4.unmarshal(stream)
 				else -> throw UnknownResponseVersion("Departures", v)
 			}
 		}
 	}
 }
 
-data class DeparturesResponseDev(
+data class DeparturesResponseV4(
 	val alerts: List<AlertV1>,
-	val departures: List<DepartureV3>,
+	val departures: List<DepartureV4>,
 	val stop: StopV2
 ) : DeparturesResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): DeparturesResponseDev {
+		fun unmarshal(stream: InputStream): DeparturesResponseV4 {
 			val alerts = mutableListOf<AlertV1>()
-			val departures = mutableListOf<DepartureV3>()
+			val departures = mutableListOf<DepartureV4>()
 
 			val reader = Reader(stream)
 			val alertsNum = reader.readUInt().toULong()
@@ -47,11 +48,11 @@ 				alerts.add(alert)
 			}
 			val departuresNum = reader.readUInt().toULong()
 			for (i in 0UL until departuresNum) {
-				val departure = DepartureV3.unmarshal(stream)
+				val departure = DepartureV4.unmarshal(stream)
 				departures.add(departure)
 			}
 
-			return DeparturesResponseDev(alerts, departures, StopV2.unmarshal(stream))
+			return DeparturesResponseV4(alerts, departures, StopV2.unmarshal(stream))
 		}
 	}
 }
@@ -62,7 +63,7 @@ 	val departures: List,
 	val stop: StopV2
 ) : DeparturesResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): DeparturesResponseDev {
+		fun unmarshal(stream: InputStream): DeparturesResponseV3 {
 			val alerts = mutableListOf<AlertV1>()
 			val departures = mutableListOf<DepartureV3>()
 
@@ -78,7 +79,7 @@ 				val departure = DepartureV3.unmarshal(stream)
 				departures.add(departure)
 			}
 
-			return DeparturesResponseDev(alerts, departures, StopV2.unmarshal(stream))
+			return DeparturesResponseV3(alerts, departures, StopV2.unmarshal(stream))
 		}
 	}
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Feeds.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Feeds.kt
index 2db68ec32cf82ff44a86c514e5111cf2142f5c6f..2bb1c7b23d5db5ea1184c2a787c42124f2ce0211 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Feeds.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Feeds.kt
@@ -4,7 +4,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 package xyz.apiote.bimba.czwek.api.responses
 
-import xyz.apiote.bimba.czwek.api.responses.UnknownResponseVersion
 import xyz.apiote.bimba.czwek.api.structs.FeedInfoV1
 import xyz.apiote.bimba.czwek.api.structs.FeedInfoV2
 import xyz.apiote.fruchtfleisch.Reader
@@ -15,27 +14,11 @@ 	companion object {
 		fun unmarshal(stream: InputStream): FeedsResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> FeedsResponseDev.unmarshal(stream)
+				// 0UL -> FeedsResponseDev.unmarshal(stream)
 				1UL -> FeedsResponseV1.unmarshal(stream)
 				2UL -> FeedsResponseV2.unmarshal(stream)
 				else -> throw UnknownResponseVersion("Feeds", v)
 			}
-		}
-	}
-}
-
-data class FeedsResponseDev(
-	val feeds: List<FeedInfoV2>
-) : FeedsResponse {
-	companion object {
-		fun unmarshal(stream: InputStream): FeedsResponseDev {
-			val feeds = mutableListOf<FeedInfoV2>()
-			val reader = Reader(stream)
-			val n = reader.readUInt().toULong()
-			for (i in 0UL until n) {
-				feeds.add(FeedInfoV2.unmarshal(stream))
-			}
-			return FeedsResponseDev(feeds)
 		}
 	}
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Line.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Line.kt
index 1a4528959aac80bc5f0887ea98a32120318ce351..2ea9f2c732a68df8155e79559e7a23741de253c5 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Line.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Line.kt
@@ -6,7 +6,7 @@ package xyz.apiote.bimba.czwek.api.responses
 
 import xyz.apiote.bimba.czwek.api.LineV1
 import xyz.apiote.bimba.czwek.api.LineV2
-import xyz.apiote.bimba.czwek.api.responses.UnknownResponseVersion
+import xyz.apiote.bimba.czwek.api.LineV3
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
 
@@ -15,21 +15,22 @@ 	companion object {
 		fun unmarshal(stream: InputStream): LineResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> LineResponseDev.unmarshal(stream)
+				// 0UL -> LineResponseDev.unmarshal(stream)
 				1UL -> LineResponseV1.unmarshal(stream)
 				2UL -> LineResponseV2.unmarshal(stream)
+				3UL -> LineResponseV3.unmarshal(stream)
 				else -> throw UnknownResponseVersion("Line", v)
 			}
 		}
 	}
 }
 
-data class LineResponseDev(
-	val line: LineV2
+data class LineResponseV3(
+	val line: LineV3
 ) : LineResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): LineResponseDev {
-			return LineResponseDev(LineV2.unmarshal(stream))
+		fun unmarshal(stream: InputStream): LineResponseV3 {
+			return LineResponseV3(LineV3.unmarshal(stream))
 		}
 	}
 }
@@ -38,8 +39,8 @@ data class LineResponseV2(
 	val line: LineV2
 ) : LineResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): LineResponseDev {
-			return LineResponseDev(LineV2.unmarshal(stream))
+		fun unmarshal(stream: InputStream): LineResponseV2 {
+			return LineResponseV2(LineV2.unmarshal(stream))
 		}
 	}
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Locatables.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Locatables.kt
index f126d564f35d713d84edb60e202ef413e0622e4c..a176bd2dff780abb44aa27b89af2679233679fe7 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Locatables.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Locatables.kt
@@ -10,7 +10,6 @@ import xyz.apiote.bimba.czwek.api.LocatableV3
 import xyz.apiote.bimba.czwek.api.StopV1
 import xyz.apiote.bimba.czwek.api.StopV2
 import xyz.apiote.bimba.czwek.api.UnknownResourceVersionException
-import xyz.apiote.bimba.czwek.api.responses.UnknownResponseVersion
 import xyz.apiote.bimba.czwek.api.VehicleV1
 import xyz.apiote.bimba.czwek.api.VehicleV2
 import xyz.apiote.bimba.czwek.api.VehicleV3
@@ -22,7 +21,7 @@ 	companion object {
 		fun unmarshal(stream: InputStream): LocatablesResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> LocatablesResponseDev.unmarshal(stream)
+				// 0UL -> LocatablesResponseDev.unmarshal(stream)
 				1UL -> LocatablesResponseV1.unmarshal(stream)
 				2UL -> LocatablesResponseV2.unmarshal(stream)
 				3UL -> LocatablesResponseV3.unmarshal(stream)
@@ -32,31 +31,6 @@ 		}
 	}
 }
 
-data class LocatablesResponseDev(val locatables: List<LocatableV3>) : LocatablesResponse {
-	companion object {
-		fun unmarshal(stream: InputStream): LocatablesResponseDev {
-			val locatables = mutableListOf<LocatableV3>()
-			val reader = Reader(stream)
-			val n = reader.readUInt().toULong()
-			for (i in 0UL until n) {
-				when (val r = reader.readUInt().toULong()) {
-					0UL -> {
-						locatables.add(StopV2.unmarshal(stream))
-					}
-
-					1UL -> {
-						locatables.add(VehicleV3.unmarshal(stream))
-					}
-
-					else -> {
-						throw UnknownResourceVersionException("Locatable/$r", 0u)
-					}
-				}
-			}
-			return LocatablesResponseDev(locatables)
-		}
-	}
-}
 
 data class LocatablesResponseV3(val locatables: List<LocatableV3>) : LocatablesResponse {
 	companion object {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Queryables.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Queryables.kt
index 9e5d0a467f6b052d867a9777d3f7ce0e5a9a8a14..8e7cc8f8c7a63204b4c2d69604ae33f2fe4ac768 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Queryables.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/responses/Queryables.kt
@@ -6,13 +6,14 @@ package xyz.apiote.bimba.czwek.api.responses
 
 import xyz.apiote.bimba.czwek.api.LineV1
 import xyz.apiote.bimba.czwek.api.LineV2
+import xyz.apiote.bimba.czwek.api.LineV3
 import xyz.apiote.bimba.czwek.api.QueryableV1
 import xyz.apiote.bimba.czwek.api.QueryableV2
 import xyz.apiote.bimba.czwek.api.QueryableV3
+import xyz.apiote.bimba.czwek.api.QueryableV4
 import xyz.apiote.bimba.czwek.api.StopV1
 import xyz.apiote.bimba.czwek.api.StopV2
 import xyz.apiote.bimba.czwek.api.UnknownResourceVersionException
-import xyz.apiote.bimba.czwek.api.responses.UnknownResponseVersion
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
 
@@ -21,20 +22,21 @@ 	companion object {
 		fun unmarshal(stream: InputStream): QueryablesResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> QueryablesResponseDev.unmarshal(stream)
+				// 0UL -> QueryablesResponseDev.unmarshal(stream)
 				1UL -> QueryablesResponseV1.unmarshal(stream)
 				2UL -> QueryablesResponseV2.unmarshal(stream)
 				3UL -> QueryablesResponseV3.unmarshal(stream)
+				4UL -> QueryablesResponseV4.unmarshal(stream)
 				else -> throw UnknownResponseVersion("Queryables", v)
 			}
 		}
 	}
 }
 
-data class QueryablesResponseDev(val queryables: List<QueryableV3>) : QueryablesResponse {
+data class QueryablesResponseV4(val queryables: List<QueryableV4>) : QueryablesResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): QueryablesResponseDev {
-			val queryables = mutableListOf<QueryableV3>()
+		fun unmarshal(stream: InputStream): QueryablesResponseV4 {
+			val queryables = mutableListOf<QueryableV4>()
 			val reader = Reader(stream)
 			val n = reader.readUInt().toULong()
 			for (i in 0UL until n) {
@@ -44,7 +46,7 @@ 						queryables.add(StopV2.unmarshal(stream))
 					}
 
 					1UL -> {
-						queryables.add(LineV2.unmarshal(stream))
+						queryables.add(LineV3.unmarshal(stream))
 					}
 
 					else -> {
@@ -52,14 +54,14 @@ 						throw UnknownResourceVersionException("Queryable/$r", 0u)
 					}
 				}
 			}
-			return QueryablesResponseDev(queryables)
+			return QueryablesResponseV4(queryables)
 		}
 	}
 }
 
 data class QueryablesResponseV3(val queryables: List<QueryableV3>) : QueryablesResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): QueryablesResponseDev {
+		fun unmarshal(stream: InputStream): QueryablesResponseV3 {
 			val queryables = mutableListOf<QueryableV3>()
 			val reader = Reader(stream)
 			val n = reader.readUInt().toULong()
@@ -78,7 +80,7 @@ 						throw UnknownResourceVersionException("Queryable/$r", 0u)
 					}
 				}
 			}
-			return QueryablesResponseDev(queryables)
+			return QueryablesResponseV3(queryables)
 		}
 	}
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
index 76d0caa48af1122b8b7f89b2beab8543cedfd5d9..090e870cac6a76658ce555f4a8e7a0eac3f98355 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
@@ -44,7 +44,7 @@ 	private lateinit var binding: ActivityMainBinding
 	private lateinit var locationPermissionRequest: ActivityResultLauncher<Array<String>>
 
 	private lateinit var permissionAsker: Fragment
-	var locationPermissionDialogShown = false
+	private var locationPermissionDialogShown = false
 
 	override fun onCreate(savedInstanceState: Bundle?) {
 		super.onCreate(savedInstanceState)




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
index 38afc1078fe260f2e80a6553869cc720b446b48e..d42be675e2f6c4b569a608c3c0e66e7598f1b4ab 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
@@ -5,7 +5,6 @@
 package xyz.apiote.bimba.czwek.dashboard.ui.home
 
 import android.content.Context
-import android.net.ConnectivityManager
 import android.os.Handler
 import android.os.Looper
 import android.text.Editable




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt
index 20f56bd9673a6a06a4c62de22349b81b694a365a..bd2f1614e6497260be727826f03a5a6e3ccf2b9e 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt
@@ -5,12 +5,12 @@
 package xyz.apiote.bimba.czwek.dashboard.ui.map
 
 import android.annotation.SuppressLint
-import android.content.Context
 import android.content.Context.MODE_PRIVATE
 import android.content.SharedPreferences
-import android.content.res.Configuration.*
+import android.content.res.Configuration.UI_MODE_NIGHT_MASK
+import android.content.res.Configuration.UI_MODE_NIGHT_UNDEFINED
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
 import android.graphics.Bitmap
-import android.net.ConnectivityManager
 import android.os.Bundle
 import android.os.Handler
 import android.os.Looper
@@ -103,11 +103,13 @@ 		}
 
 		binding.map.addMapListener(object : MapListener {
 			override fun onScroll(event: ScrollEvent?): Boolean {
-				return onMapMove()
+				onMapMove()
+				return true
 			}
 
 			override fun onZoom(event: ZoomEvent?): Boolean {
-				return onMapMove()
+				onMapMove()
+				return true
 			}
 		})
 
@@ -119,18 +121,17 @@
 		return root
 	}
 
-	private fun onMapMove(): Boolean {
+	private fun onMapMove() {
 		snack?.dismiss()
-		return delayGetLocatables()
+		delayGetLocatables()
 	}
 
-	private fun delayGetLocatables(delay: Long = 1000): Boolean {
+	private fun delayGetLocatables(delay: Long = 1000) {
 		handler.removeCallbacks(workRunnable)
 		workRunnable = Runnable {
 			getLocatables()
 		}
 		handler.postDelayed(workRunnable, delay)
-		return true
 	}
 
 	private fun observeLocatables() {
@@ -197,8 +198,7 @@ 					Position(it.latNorth, it.lonEast)
 				)
 			}
 			context?.let {
-				val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
-				mapViewModel.getLocatablesIn(cm, bl, tr, it)
+				mapViewModel.getLocatablesIn(bl, tr, it)
 			}
 			delayGetLocatables(30000)
 		}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt
index a129db6bbe44d613664c6861c8de3daea78e6a44..f53541d8f75292860a260fd389f57fe047e1bf32 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapViewModel.kt
@@ -7,7 +7,6 @@
 import android.content.ActivityNotFoundException
 import android.content.Context
 import android.content.Intent
-import android.net.ConnectivityManager
 import android.net.Uri
 import android.os.Bundle
 import android.util.Log
@@ -16,17 +15,21 @@ import android.view.View
 import android.view.ViewGroup
 import android.widget.Button
 import android.widget.ImageView
+import android.widget.LinearLayout
 import android.widget.TextView
 import android.widget.Toast
-import androidx.constraintlayout.widget.Group
+import androidx.appcompat.widget.TooltipCompat
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import com.google.android.material.bottomsheet.BottomSheetDialogFragment
 import kotlinx.coroutines.launch
+import org.osmdroid.views.MapView
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.departures.DeparturesActivity
+import xyz.apiote.bimba.czwek.repo.CongestionLevel
 import xyz.apiote.bimba.czwek.repo.Locatable
+import xyz.apiote.bimba.czwek.repo.OccupancyStatus
 import xyz.apiote.bimba.czwek.repo.OnlineRepository
 import xyz.apiote.bimba.czwek.repo.Position
 import xyz.apiote.bimba.czwek.repo.Stop
@@ -38,12 +41,12 @@
 	private val _locatables = MutableLiveData<List<Locatable>>()
 	val locatables: MutableLiveData<List<Locatable>> = _locatables
 
-	fun getLocatablesIn(cm: ConnectivityManager, bl: Position, tr: Position, context: Context) {
+	fun getLocatablesIn(bl: Position, tr: Position, context: Context) {
 		viewModelScope.launch {
 			viewModelScope.launch {
 				try {
 					val repository = OnlineRepository()
-					_locatables.value = repository.getLocatablesIn(cm, bl, tr, context) ?: emptyList()
+					_locatables.value = repository.getLocatablesIn(context, bl, tr) ?: emptyList()
 				} catch (e: TrafficResponseException) {
 					Log.w("Map", "$e")
 				}
@@ -58,11 +61,8 @@ 		const val TAG = "MapBottomSheet"
 	}
 
 	private fun showVehicle(content: View, vehicle: Vehicle) {
-		content.findViewById<Group>(R.id.stop_group).visibility = View.GONE
-		content.findViewById<Group>(R.id.vehicle_group).visibility = View.VISIBLE
-
 		context?.let { ctx ->
-			content.findViewById<TextView>(R.id.title).apply {
+			content.findViewById<TextView>(R.id.line).apply {
 				text = ctx.getString(R.string.vehicle_headsign, vehicle.Line.name, vehicle.Headsign)
 				contentDescription = ctx.getString(
 					R.string.vehicle_headsign_content_description,
@@ -70,51 +70,95 @@ 					vehicle.Line.name,
 					vehicle.Headsign
 				)
 			}
+
+			content.findViewById<TextView>(R.id.time).visibility = View.GONE
+
+			content.findViewById<MapView>(R.id.map).visibility = View.GONE
+			content.findViewById<LinearLayout>(R.id.boarding).visibility = View.GONE
+			content.findViewById<ImageView>(R.id.rt_icon).visibility = View.GONE
+			// TODO vehicle accessible
+			content.findViewById<ImageView>(R.id.wheelchair_icon).visibility = View.GONE
+
 			// todo units -- [3.2] settings or system-based
 			content.findViewById<TextView>(R.id.speed_text).text =
 				ctx.getString(R.string.speed_in_km_per_h, vehicle.Speed * 3.6)
+
+			content.findViewById<LinearLayout>(R.id.congestion).visibility =
+				if (vehicle.congestionLevel == CongestionLevel.UNKNOWN) View.GONE else View.VISIBLE
 			content.findViewById<TextView>(R.id.congestion_text).text = vehicle.congestion(ctx)
+
+			content.findViewById<LinearLayout>(R.id.occupancy).visibility =
+				if (vehicle.occupancyStatus == OccupancyStatus.UNKNOWN) View.GONE else View.VISIBLE
 			content.findViewById<TextView>(R.id.occupancy_text).text = vehicle.occupancy(ctx)
-			content.findViewById<ImageView>(R.id.ac).visibility =
-				if (vehicle.getCapability(Vehicle.Capability.AC)) {
-					View.VISIBLE
-				} else {
-					View.GONE
-				}
-			content.findViewById<ImageView>(R.id.bike).visibility =
-				if (vehicle.getCapability(Vehicle.Capability.BIKE)) {
-					View.VISIBLE
-				} else {
-					View.GONE
-				}
-			content.findViewById<ImageView>(R.id.voice).visibility =
-				if (vehicle.getCapability(Vehicle.Capability.VOICE)) {
-					View.VISIBLE
-				} else {
-					View.GONE
-				}
-			content.findViewById<ImageView>(R.id.ticket).visibility =
-				if (vehicle.let {
-						it.getCapability(Vehicle.Capability.TICKET_DRIVER) || it.getCapability(Vehicle.Capability.TICKET_MACHINE)
-					}) {
-					View.VISIBLE
-				} else {
-					View.GONE
-				}
-			content.findViewById<ImageView>(R.id.usb).visibility =
-				if (vehicle.getCapability(Vehicle.Capability.USB_CHARGING)) {
-					View.VISIBLE
-				} else {
-					View.GONE
-				}
+
+			content.findViewById<ImageView>(R.id.ac).let {
+				TooltipCompat.setTooltipText(
+					it,
+					getString(R.string.air_condition_content_description)
+				)
+				it.visibility =
+					if (vehicle.getCapability(Vehicle.Capability.AC)) {
+						View.VISIBLE
+					} else {
+						View.GONE
+					}
+			}
+			content.findViewById<ImageView>(R.id.bike).let {
+				TooltipCompat.setTooltipText(
+					it,
+					getString(R.string.bicycles_allowed_content_description)
+				)
+				it.visibility =
+					if (vehicle.getCapability(Vehicle.Capability.BIKE)) {
+						View.VISIBLE
+					} else {
+						View.GONE
+					}
+			}
+			content.findViewById<ImageView>(R.id.voice).let {
+				TooltipCompat.setTooltipText(
+					it,
+					getString(R.string.voice_announcements_content_description)
+				)
+				it.visibility =
+					if (vehicle.getCapability(Vehicle.Capability.VOICE)) {
+						View.VISIBLE
+					} else {
+						View.GONE
+					}
+			}
+			content.findViewById<ImageView>(R.id.ticket).let { ticketImage ->
+				TooltipCompat.setTooltipText(
+					ticketImage,
+					getString(R.string.tickets_sold_content_description)
+				)
+				ticketImage.visibility =
+					if (vehicle.let {
+							it.getCapability(Vehicle.Capability.TICKET_DRIVER) || it.getCapability(Vehicle.Capability.TICKET_MACHINE)
+						}) {
+						View.VISIBLE
+					} else {
+						View.GONE
+					}
+			}
+			content.findViewById<ImageView>(R.id.usb).let {
+				TooltipCompat.setTooltipText(
+					it,
+					getString(R.string.usb_charging_content_description)
+				)
+				it.visibility =
+					if (vehicle.getCapability(Vehicle.Capability.USB_CHARGING)) {
+						View.VISIBLE
+					} else {
+						View.GONE
+					}
+			}
 		}
 	}
 
 	private fun showStop(content: View, stop: Stop) {
 		context?.let { ctx ->
-			content.findViewById<Group>(R.id.stop_group).visibility = View.VISIBLE
-			content.findViewById<Group>(R.id.vehicle_group).visibility = View.GONE
-			content.findViewById<TextView>(R.id.title).text = stop.name
+			content.findViewById<TextView>(R.id.stop_name).text = stop.name
 			content.findViewById<Button>(R.id.departures_button).setOnClickListener {
 				val intent = Intent(ctx, DeparturesActivity::class.java).apply {
 					putExtra("code", stop.code)
@@ -151,20 +195,22 @@ 		inflater: LayoutInflater,
 		container: ViewGroup?,
 		savedInstanceState: Bundle?
 	): View {
-		val content = inflater.inflate(R.layout.map_bottom_sheet, container, false)
-		content.apply {
-			when (locatable) {
-				is Vehicle -> {
+		return when (locatable) {
+			is Vehicle -> {
+				inflater.inflate(R.layout.departure_bottom_sheet, container, false).apply {
 					showVehicle(this, locatable)
 				}
+			}
 
-				is Stop -> {
+			is Stop -> {
+				inflater.inflate(R.layout.stop_bottom_sheet, container, false).apply {
 					showStop(this, locatable)
 				}
 			}
+
+			else -> {
+				throw IllegalStateException("locatable is neither a vehicle nor a stop")
+			}
 		}
-		//(dialog as BottomSheetDialog).behavior.peekHeight = dpToPixelI(90f)
-
-		return content
 	}
 }
\ No newline at end of file




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 04c4d0a1eab4505ce9237ecf6e031cf6c5beeb22..7b334c3d4efecdfa7b18e8d98c1de5bb9d8e1e6c 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
@@ -15,11 +15,15 @@ import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.widget.ImageView
+import android.widget.LinearLayout
 import android.widget.TextView
+import androidx.appcompat.widget.TooltipCompat
 import androidx.recyclerview.widget.DiffUtil
 import androidx.recyclerview.widget.RecyclerView
 import com.google.android.material.bottomsheet.BottomSheetDialog
 import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textview.MaterialTextView
 import org.osmdroid.tileprovider.tilesource.TileSourceFactory
 import org.osmdroid.util.GeoPoint
 import org.osmdroid.views.CustomZoomButtonsController
@@ -29,7 +33,9 @@ import org.osmdroid.views.overlay.TilesOverlay
 import org.osmdroid.views.overlay.gestures.RotationGestureOverlay
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.dpToPixelI
+import xyz.apiote.bimba.czwek.repo.CongestionLevel
 import xyz.apiote.bimba.czwek.repo.Departure
+import xyz.apiote.bimba.czwek.repo.OccupancyStatus
 import xyz.apiote.bimba.czwek.repo.Vehicle
 import java.time.ZoneId
 import java.time.ZonedDateTime
@@ -47,6 +53,7 @@ 		fun bind(
 			departure: Departure,
 			holder: BimbaDepartureViewHolder?,
 			context: Context?,
+			showAsTime: Boolean,
 			onClickListener: (Departure) -> Unit
 		) {
 			holder?.root?.setOnClickListener {
@@ -63,7 +70,7 @@ 					R.string.departure_headsign_content_description,
 					departure.vehicle.Headsign
 				)
 
-			holder?.departureTime?.text = departure.statusText(context)
+			holder?.departureTime?.text = departure.statusText(context, showAsTime)
 		}
 	}
 }
@@ -78,10 +85,12 @@ 	RecyclerView.Adapter() {
 	var lastUpdate: ZonedDateTime =
 		ZonedDateTime.of(0, 1, 1, 0, 0, 0, 0, ZoneId.systemDefault())
 		private set
+	private var showAsTime: Boolean = false
 
 	inner class DiffUtilCallback(
 		private val oldDepartures: List<Departure>,
-		private val newDepartures: List<Departure>
+		private val newDepartures: List<Departure>,
+		private val showAsTimeChanged: Boolean
 	) : DiffUtil.Callback() {
 		override fun getOldListSize() = oldDepartures.size
 
@@ -94,7 +103,10 @@ 		override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
 			val oldDeparture = oldDepartures[oldItemPosition]
 			val newDeparture = newDepartures[newItemPosition]
 			return oldDeparture.vehicle.Line == newDeparture.vehicle.Line && oldDeparture.vehicle.Headsign == newDeparture.vehicle.Headsign &&
-				oldDeparture.statusText(context, lastUpdate) == newDeparture.statusText(context)
+				oldDeparture.statusText(context, false, lastUpdate) == newDeparture.statusText(
+				context,
+				false
+			)  && !showAsTimeChanged
 		}
 	}
 
@@ -112,13 +124,19 @@ 		return BimbaDepartureViewHolder(rowView)
 	}
 
 	override fun onBindViewHolder(holder: BimbaDepartureViewHolder, position: Int) {
-		BimbaDepartureViewHolder.bind(departures[position], holder, context, onClickListener)
+		BimbaDepartureViewHolder.bind(
+			departures[position],
+			holder,
+			context,
+			showAsTime,
+			onClickListener
+		)
 	}
 
 	override fun getItemCount(): Int = departures.size
 
-	fun get(ID: String): Departure? {
-		val position = departuresPositions[ID]
+	fun get(id: String): Departure? {
+		val position = departuresPositions[id]
 		return if (position == null) {
 			null
 		} else {
@@ -126,12 +144,14 @@ 			departures[position]
 		}
 	}
 
-	fun update(departures: List<Departure>, areNewObserved: Boolean = false) {
+	fun update(departures: List<Departure>, showAsTime: Boolean, areNewObserved: Boolean = false) {
 		val newPositions: MutableMap<String, Int> = HashMap()
 		departures.forEachIndexed { i, departure ->
 			newPositions[departure.ID] = i
 		}
-		val diff = DiffUtil.calculateDiff(DiffUtilCallback(this.departures, departures))
+		val diff = DiffUtil.calculateDiff(DiffUtilCallback(this.departures, departures, this.showAsTime != showAsTime))
+
+		this.showAsTime = showAsTime
 
 		this.departures = departures
 		departuresPositions = newPositions
@@ -142,7 +162,7 @@ 		diff.dispatchUpdatesTo(this)
 	}
 
 	fun refreshItems() {
-		update(this.departures)
+		update(this.departures, showAsTime)
 	}
 }
 
@@ -211,41 +231,94 @@ 			findViewById(R.id.boarding_text).text = departure.boardingText(ctx)
 			// todo units -- [3.2] settings or system-based
 			findViewById<TextView>(R.id.speed_text).text =
 				getString(R.string.speed_in_km_per_h, departure.vehicle.Speed * 3.6)
+
+			findViewById<LinearLayout>(R.id.congestion).visibility =
+				if (departure.vehicle.congestionLevel == CongestionLevel.UNKNOWN) View.GONE else View.VISIBLE
 			findViewById<TextView>(R.id.congestion_text).text = departure.vehicle.congestion(ctx)
+
+			findViewById<LinearLayout>(R.id.occupancy).visibility =
+				if (departure.vehicle.occupancyStatus == OccupancyStatus.UNKNOWN) View.GONE else View.VISIBLE
 			findViewById<TextView>(R.id.occupancy_text).text = departure.vehicle.occupancy(ctx)
 
-			findViewById<ImageView>(R.id.ac).visibility =
-				if (departure.vehicle.getCapability(Vehicle.Capability.AC)) {
-					View.VISIBLE
-				} else {
-					View.GONE
-				}
-			findViewById<ImageView>(R.id.bike).visibility =
-				if (departure.vehicle.getCapability(Vehicle.Capability.BIKE)) {
-					View.VISIBLE
-				} else {
-					View.GONE
-				}
-			findViewById<ImageView>(R.id.voice).visibility =
-				if (departure.vehicle.getCapability(Vehicle.Capability.VOICE)) {
-					View.VISIBLE
-				} else {
-					View.GONE
-				}
-			findViewById<ImageView>(R.id.ticket).visibility =
-				if (departure.vehicle.let {
+			findViewById<ImageView>(R.id.ac).let {
+				TooltipCompat.setTooltipText(
+					it,
+					getString(R.string.air_condition_content_description)
+				)
+				it.visibility =
+					if (departure.vehicle.getCapability(Vehicle.Capability.AC)) View.VISIBLE else View.GONE
+			}
+
+			findViewById<ImageView>(R.id.bike).let {
+				TooltipCompat.setTooltipText(
+					it,
+					getString(R.string.bicycles_allowed_content_description)
+				)
+				it.visibility =
+					if (departure.vehicle.getCapability(Vehicle.Capability.BIKE)) {
+						View.VISIBLE
+					} else {
+						View.GONE
+					}
+			}
+
+			findViewById<ImageView>(R.id.voice).let {
+				TooltipCompat.setTooltipText(
+					it,
+					getString(R.string.voice_announcements_content_description)
+				)
+				it.visibility =
+					if (departure.vehicle.getCapability(Vehicle.Capability.VOICE)) {
+						View.VISIBLE
+					} else {
+						View.GONE
+					}
+			}
+			findViewById<ImageView>(R.id.ticket).let { ticketImage ->
+				TooltipCompat.setTooltipText(
+					ticketImage,
+					getString(R.string.tickets_sold_content_description)
+				)
+				ticketImage.visibility = if (departure.vehicle.let {
 						it.getCapability(Vehicle.Capability.TICKET_DRIVER) || it.getCapability(Vehicle.Capability.TICKET_MACHINE)
 					}) {
 					View.VISIBLE
 				} else {
 					View.GONE
 				}
-			findViewById<ImageView>(R.id.usb).visibility =
-				if (departure.vehicle.getCapability(Vehicle.Capability.USB_CHARGING)) {
-					View.VISIBLE
-				} else {
-					View.GONE
+			}
+			findViewById<ImageView>(R.id.usb).let {
+				TooltipCompat.setTooltipText(
+					it,
+					getString(R.string.usb_charging_content_description)
+				)
+				it.visibility =
+					if (departure.vehicle.getCapability(Vehicle.Capability.USB_CHARGING)) {
+						View.VISIBLE
+					} else {
+						View.GONE
+					}
+			}
+
+			if (departure.alerts.isNotEmpty()) {
+				findViewById<MaterialTextView>(R.id.alerts_text).text = departure.alerts.map {
+					it.header.ifEmpty {
+						getString(R.string.alert_header)
+					}
+				}.toSet().joinToString(separator = "\n")
+				findViewById<LinearLayout>(R.id.alerts).apply {
+					visibility = View.VISIBLE
+					setOnClickListener {
+						MaterialAlertDialogBuilder(context)
+							.setTitle("Alerts")
+							.setPositiveButton(R.string.ok) { _, _ -> }
+							.setMessage(departure.alerts.map { it.description }.filter { it != "" }
+								.joinToString(separator = "\n"))
+							.show()
+					}
 				}
+			}
+
 			findViewById<MapView>(R.id.map).let { map ->
 				if (departure.vehicle.Position.isZero()) {
 					map.visibility = View.GONE
@@ -255,13 +328,13 @@ 				map.controller.apply {
 					GeoPoint(
 						departure.vehicle.location().latitude,
 						departure.vehicle.location().longitude
-					).let {
+					).let { geoPoint ->
 						if (updating) {
 							animateTo(
-								it, 19.0f.toDouble(), 3 * 1000
+								geoPoint, 19.0f.toDouble(), 3 * 1000
 							)
 						} else {
-							setCenter(it)
+							setCenter(geoPoint)
 							setZoom(19f.toDouble())
 						}
 					}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt
index f47185c9f0a0948217cb80a2aa3dbbb6db37e310..3ace64fd176fe6079d89e6ecd848ed72e9a42ec6 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt
@@ -5,12 +5,17 @@
 package xyz.apiote.bimba.czwek.departures
 
 import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.ConnectivityManager.NetworkCallback
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
 import android.os.Bundle
 import android.os.Handler
 import android.os.Looper
 import android.text.format.DateUtils
 import android.text.format.DateUtils.MINUTE_IN_MILLIS
-import android.util.Log
 import android.view.View
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
@@ -19,13 +24,21 @@ import androidx.core.view.WindowCompat
 import androidx.lifecycle.ViewModelProvider
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.datepicker.MaterialDatePicker
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.timepicker.MaterialTimePicker
+import com.google.android.material.timepicker.TimeFormat
 import kotlinx.coroutines.Runnable
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.api.Error
 import xyz.apiote.bimba.czwek.databinding.ActivityDeparturesBinding
 import xyz.apiote.bimba.czwek.repo.Departure
 import xyz.apiote.bimba.czwek.repo.Stop
+import java.time.Instant
+import java.time.LocalDate
+import java.time.LocalTime
+import java.time.ZoneId
 import java.time.ZonedDateTime
 
 class DeparturesActivity : AppCompatActivity() {
@@ -42,6 +55,20 @@ 	private lateinit var snackbar: Snackbar
 
 	private lateinit var viewModel: DeparturesViewModel
 
+	private val datePicker =
+		MaterialDatePicker.Builder.datePicker().setTitleText(R.string.title_select_date)
+			.setNegativeButtonText(R.string.clear_date_selection)
+			.build()
+	private var timePickerStart: MaterialTimePicker? = null
+	private var timePickerEnd: MaterialTimePicker? = null
+	private var linePicker: MaterialAlertDialogBuilder? = null
+	private var date: LocalDate? = null
+	private val linesFilter = mutableMapOf<String, Boolean>()
+	private val linesFilterTemporary = mutableMapOf<String, Boolean>()
+	private var startTime: LocalTime = LocalTime.MIN
+	private var endTime: LocalTime = LocalTime.MAX
+	private var alertDescriptions: String = ""
+
 	override fun onCreate(savedInstanceState: Bundle?) {
 		super.onCreate(savedInstanceState)
 		_binding = ActivityDeparturesBinding.inflate(layoutInflater)
@@ -49,20 +76,150 @@ 		setContentView(binding.root)
 
 		viewModel = ViewModelProvider(this)[DeparturesViewModel::class.java]
 
+		linePicker = MaterialAlertDialogBuilder(this)
+			.setTitle(resources.getString(R.string.title_select_line))
+			.setNegativeButton(R.string.clear_date_selection) { _, _ ->
+				linesFilter.clear()
+				getDepartures()
+			}
+			.setPositiveButton(R.string.ok) { _, _ ->
+				linesFilterTemporary.forEach { linesFilter[it.key] = it.value }
+				getDepartures()
+			}
+
+		binding.moreButton.setOnClickListener {
+			MaterialAlertDialogBuilder(this)
+				.setTitle("Alerts")
+				.setPositiveButton(R.string.ok) { _, _ -> }
+				.setMessage(alertDescriptions)
+				.show()
+		}
+
 		viewModel.departures.observe(this) { stopDepartures ->
-			updateItems(stopDepartures.departures, stopDepartures.stop)
+			if (stopDepartures.alerts.isNotEmpty()) {
+				binding.alerts.visibility = View.VISIBLE
+				binding.alertsText.text = stopDepartures.alerts.map {
+					it.header.ifEmpty {
+						getString(R.string.alert_header)
+					}
+				}.toSet().joinToString(separator = "\n")
+				alertDescriptions = stopDepartures.alerts.map { it.description }.filter { it != "" }
+					.joinToString(separator = "\n")
+				binding.moreButton.visibility = if (alertDescriptions == "") View.GONE else View.VISIBLE
+
+			} else {
+				binding.alerts.visibility = View.GONE
+			}
+			updateItems(
+				stopDepartures.departures
+					.filter { d ->
+						linesFilter.values.all { !it } or (linesFilter[d.vehicle.Line.name] ?: false)
+					}
+					.filter { d ->
+						val t = LocalTime.of(d.time.Hour.toInt(), d.time.Minute.toInt())
+						t >= startTime && t <= endTime
+					},
+				stopDepartures.stop
+			)
 			openBottomSheet?.departureID()?.let { adapter.get(it) }?.let { openBottomSheet?.update(it) }
+
+			val lines = stopDepartures.departures.map { it.vehicle.Line.name }.sortedWith { s1, s2 ->
+				val s1n = s1.toIntOrNull()
+				val s2n = s2.toIntOrNull()
+				if (s1n != null && s2n != null) {
+					s1.toInt() - s2.toInt()
+				} else {
+					s1.compareTo(s2)
+				}
+			}.toSet().toTypedArray()
+			val selections = lines.map { linesFilter.getOrDefault(it, false) }.toBooleanArray()
+
+			linePicker?.setMultiChoiceItems(lines, selections) { _, which, checked ->
+				linesFilterTemporary[lines[which]] = checked
+			}
 		}
 		viewModel.error.observe(this) {
 			showError(it)
 		}
 
+		binding.departuresAppBar.menu.findItem(R.id.departures_filter_bytime).setEnabled(false)
+
+
+		datePicker.addOnNegativeButtonClickListener {
+			date = null
+			startTime = LocalTime.MIN
+			endTime = LocalTime.MAX
+			binding.departuresAppBar.menu.findItem(R.id.departures_filter_bytime).setEnabled(false)
+			getDepartures(true)
+		}
+		datePicker.addOnPositiveButtonClickListener {
+			if (date == null) {
+				startTime = LocalTime.MIN
+				endTime = LocalTime.MAX
+			}
+			date = Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())
+				.toLocalDate()
+			binding.departuresAppBar.menu.findItem(R.id.departures_filter_bytime).setEnabled(true)
+			getDepartures(true)
+		}
+
 		binding.collapsingLayout.apply {
 			title = getName()
 			val tf = ResourcesCompat.getFont(this@DeparturesActivity, R.font.yellowcircle8)
 			setCollapsedTitleTypeface(tf)
 			setExpandedTitleTypeface(tf)
 		}
+		binding.departuresAppBar.setOnMenuItemClickListener {
+			when (it.itemId) {
+				R.id.departures_calendar -> {
+					datePicker.show(supportFragmentManager, "datePicker")
+					true
+				}
+
+				R.id.departures_filter_byline -> {
+					linesFilterTemporary.clear()
+					linesFilter.forEach { filter -> linesFilterTemporary[filter.key] = filter.value }
+					linePicker?.show()
+					true
+				}
+
+				R.id.departures_filter_bytime -> {
+					timePickerStart =
+						MaterialTimePicker.Builder().setTitleText(R.string.title_select_time_start)
+							.setTimeFormat(TimeFormat.CLOCK_24H)
+							.setHour(startTime.hour)
+							.setMinute(startTime.minute)
+							.setNegativeButtonText(R.string.clear_date_selection)
+							.build()
+					timePickerEnd = MaterialTimePicker.Builder().setTitleText(R.string.title_select_time_end)
+						.setTimeFormat(TimeFormat.CLOCK_24H)
+						.setHour(endTime.hour)
+						.setMinute(endTime.minute)
+						.setNegativeButtonText(R.string.clear_date_selection)
+						.build()
+					timePickerEnd!!.addOnPositiveButtonClickListener {
+						endTime = LocalTime.of(timePickerEnd!!.hour, timePickerEnd!!.minute)
+						getDepartures(true)
+					}
+					timePickerEnd!!.addOnNegativeButtonClickListener {
+						endTime = LocalTime.MAX
+						getDepartures(true)
+					}
+					timePickerStart!!.addOnPositiveButtonClickListener {
+						startTime = LocalTime.of(timePickerStart!!.hour, timePickerStart!!.minute)
+						timePickerEnd!!.show(supportFragmentManager, "timePickerEnd")
+					}
+					timePickerStart!!.addOnNegativeButtonClickListener {
+						startTime = LocalTime.MIN
+						timePickerEnd!!.show(supportFragmentManager, "timePickerEnd")
+					}
+					timePickerStart!!.show(supportFragmentManager, "timePickerStart")
+					true
+				}
+
+				else -> super.onOptionsItemSelected(it)
+			}
+		}
 
 		binding.departuresRecycler.layoutManager = LinearLayoutManager(this)
 		binding.departuresRecycler.addOnScrollListener(
@@ -91,6 +248,33 @@ 		binding.departuresRecycler.adapter = adapter
 		WindowCompat.setDecorFitsSystemWindows(window, false)
 
 		snackbar = Snackbar.make(binding.root, "", Snackbar.LENGTH_INDEFINITE)
+
+		val networkCallback: NetworkCallback = object : NetworkCallback() {
+			override fun onAvailable(network: Network) {
+				getDepartures()
+			}
+
+			override fun onLost(network: Network) {
+			}
+		}
+
+		val connectivityManager = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
+
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+			connectivityManager.registerDefaultNetworkCallback(networkCallback)
+		} else {
+			val request = NetworkRequest.Builder()
+				.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build()
+			connectivityManager.registerNetworkCallback(request, networkCallback)
+		}
+	}
+
+	override fun onStart() {
+		super.onStart()
+		linesFilter.clear()
+		getLine()?.let {
+			linesFilter[it] = true
+		}
 	}
 
 	override fun onResume() {
@@ -103,6 +287,12 @@ 		super.onPause()
 		handler.removeCallbacks(runnable)
 	}
 
+	override fun onStop() {
+		super.onStop()
+		handler.removeCallbacks(runnable)
+		handler.removeCallbacksAndMessages(null)
+	}
+
 	private fun getName(): String {
 		return when (intent?.action) {
 			Intent.ACTION_VIEW -> getString(R.string.stop_from_qr_code)
@@ -118,10 +308,14 @@ 			else -> null
 		}
 	}
 
-	fun getDepartures() {
-		adapter.refreshItems()
-		setupSnackbar()
-		viewModel.getDepartures(this, getLine())
+	fun getDepartures(force: Boolean = false) {
+		if (force) {
+			showLoading()
+		} else {
+			adapter.refreshItems()
+			setupSnackbar()
+		}
+		viewModel.getDepartures(this, date, force)
 		handler.removeCallbacks(runnable)
 		runnable = Runnable { getDepartures() }
 		handler.postDelayed(runnable, 30 * 1000)
@@ -130,12 +324,16 @@
 	private fun setupSnackbar() {
 		val lastUpdateAgo = ZonedDateTime.now().toEpochSecond() - adapter.lastUpdate.toEpochSecond()
 		if (lastUpdateAgo > 59 && adapter.lastUpdate.year != 0) {
-			snackbar.setText(getString(R.string.departures_snackbar,
-				DateUtils.getRelativeTimeSpanString(
-				adapter.lastUpdate.toEpochSecond() * 1000,
-				ZonedDateTime.now().toEpochSecond() * 1000,
-				MINUTE_IN_MILLIS,
-				DateUtils.FORMAT_ABBREV_RELATIVE))
+			snackbar.setText(
+				getString(
+					R.string.departures_snackbar,
+					DateUtils.getRelativeTimeSpanString(
+						adapter.lastUpdate.toEpochSecond() * 1000,
+						ZonedDateTime.now().toEpochSecond() * 1000,
+						MINUTE_IN_MILLIS,
+						DateUtils.FORMAT_ABBREV_RELATIVE
+					)
+				)
 			).show()
 		} else {
 			snackbar.dismiss()
@@ -149,13 +347,26 @@ 		binding.errorImage.visibility = View.VISIBLE
 		binding.errorText.visibility = View.VISIBLE
 
 		binding.errorText.text = getString(error.stringResource)
-		binding.errorImage.setImageDrawable(AppCompatResources.getDrawable(this, error.imageResource))
+		binding.errorImage.setImageDrawable(
+			AppCompatResources.getDrawable(
+				this,
+				error.imageResource
+			)
+		)
+	}
+
+	private fun showLoading() {
+		binding.departuresOverlay.visibility = View.VISIBLE
+		binding.departuresProgress.visibility = View.VISIBLE
+		binding.departuresRecycler.visibility = View.GONE
+		binding.errorImage.visibility = View.GONE
+		binding.errorText.visibility = View.GONE
 	}
 
 	private fun updateItems(departures: List<Departure>, stop: Stop) {
 		setupSnackbar()
 		binding.departuresProgress.visibility = View.GONE
-		adapter.update(departures, true)
+		adapter.update(departures, date != null, true)
 		binding.collapsingLayout.apply {
 			title = stop.name
 		}
@@ -179,7 +390,6 @@ 			binding.errorImage.visibility = View.GONE
 			binding.errorText.visibility = View.GONE
 			binding.departuresRecycler.visibility = View.VISIBLE
 		}
-		// todo [3.2; traffic] alerts
 		// todo [3.2; traffic] stop info
 	}
 }
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
index 57f6fb5bfc9505ba2262d3cfe16cf7e48345f627..b973174eeb0b9325f3c5282aaa682545ac5187e4 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
@@ -22,6 +22,7 @@ import xyz.apiote.bimba.czwek.repo.OnlineRepository
 import xyz.apiote.bimba.czwek.repo.QrLocation
 import xyz.apiote.bimba.czwek.repo.StopDepartures
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
+import java.time.LocalDate
 
 class DeparturesViewModel : ViewModel() {
 	private val _departures = MutableLiveData<StopDepartures>()
@@ -33,7 +34,7 @@ 	var allItemsRequested = false
 	private var feed: FeedInfo? = null
 	private lateinit var code: String
 
-	fun getDepartures(context: Context, line: String?) {
+	fun getDepartures(context: Context, date: LocalDate?, force: Boolean) {
 		MainScope().launch {
 			try {
 				if (feed == null) {
@@ -46,19 +47,19 @@ 				val stopDepartures =
 					repository.getDepartures(
 						feed!!.id,
 						code,
-						line,
+						date,
 						context,
 						requestedItemsNumber
 					)
 				stopDepartures?.let {
-					if (stopDepartures.departures.isEmpty()) {
+					if (stopDepartures.departures.isEmpty()) {  // TODO other error for empty than not-found
 						val (string, image) = mapHttpError(404)
 						throw TrafficResponseException(404, "", Error(404, string, image))
 					}
 					_departures.value = it
 				}
 			} catch (e: TrafficResponseException) {
-				if (!departures.isInitialized) {
+				if (!departures.isInitialized || force) {
 					_error.value = e.error
 				}
 				Log.w("Departures", "$e")
@@ -71,6 +72,9 @@ 		val intent = (context as Activity).intent
 		var feeds = OfflineRepository().getFeeds(context)
 		if (feeds.isNullOrEmpty()) {
 			feeds = OnlineRepository().getFeeds(context)
+			if (feeds != null) {
+				OfflineRepository().saveFeedCache(context, feeds)
+			}
 		}
 		return when (intent.action) {
 			Intent.ACTION_VIEW -> {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt
index 617515c72577de45af91523cd643eb14e07a9760..358a7bbd7ed88d19af4e2e9fdb6a26ff7774cb6f 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt
@@ -13,11 +13,13 @@ import xyz.apiote.bimba.czwek.api.AlertV1
 import xyz.apiote.bimba.czwek.api.DepartureV1
 import xyz.apiote.bimba.czwek.api.DepartureV2
 import xyz.apiote.bimba.czwek.api.DepartureV3
+import xyz.apiote.bimba.czwek.api.DepartureV4
 import xyz.apiote.bimba.czwek.api.Time
 import xyz.apiote.bimba.czwek.api.UnknownResourceVersionException
 import java.time.Instant
 import java.time.ZoneId
 import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
 import java.time.temporal.ChronoUnit
 
 enum class AlertCause {
@@ -89,7 +91,8 @@ 	val time: Time,
 	val status: ULong,
 	val isRealtime: Boolean,
 	val vehicle: Vehicle,
-	val boarding: UByte
+	val boarding: UByte,
+	val alerts: List<Alert>
 ) {
 
 	constructor(d: DepartureV1) : this(
@@ -98,7 +101,8 @@ 		d.time,
 		d.status,
 		d.isRealtime,
 		Vehicle(d.vehicle),
-		d.boarding
+		d.boarding,
+		emptyList()
 	)
 
 	constructor(d: DepartureV2) : this(
@@ -107,7 +111,8 @@ 		d.time,
 		d.status,
 		d.isRealtime,
 		Vehicle(d.vehicle),
-		d.boarding
+		d.boarding,
+		emptyList()
 	)
 
 	constructor(d: DepartureV3) : this(
@@ -116,15 +121,29 @@ 		d.time,
 		d.status.ordinal.toULong(), // TODO VehicleStatus
 		d.isRealtime,
 		Vehicle(d.vehicle),
-		d.boarding
+		d.boarding,
+		emptyList()
+	)
+
+	constructor(d: DepartureV4) : this(
+		d.ID,
+		d.time,
+		d.status.ordinal.toULong(), // TODO VehicleStatus
+		d.isRealtime,
+		Vehicle(d.vehicle),
+		d.boarding,
+		d.alerts.map { Alert(it) }
 	)
 
-	fun statusText(context: Context?, at: ZonedDateTime? = null): String {
+	fun statusText(context: Context?, showAsTime: Boolean, at: ZonedDateTime? = null): String {
 		val now = at ?: Instant.now().atZone(ZoneId.systemDefault())
 		val departureTime = ZonedDateTime.of(
 			now.year, now.monthValue, now.dayOfMonth,
 			time.Hour.toInt(), time.Minute.toInt(), time.Second.toInt(), 0, ZoneId.of(time.Zone)
 		).plus(time.DayOffset.toLong(), ChronoUnit.DAYS)
+		if (showAsTime) {
+			return departureTime.format(DateTimeFormatter.ofPattern("HH:mm"))
+		}
 		var r = status.toUInt()
 		if (departureTime.isBefore(now) && r < 3u) {
 			r = 0u
@@ -164,4 +183,4 @@ 			boarding.and(0b0011_0011u) == (0b0001_0001).toUByte() -> context.getString(R.string.boarding)
 			else -> context.getString(R.string.on_demand)
 		}
 	}
-}
\ No newline at end of file
+}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/FeedInfo.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/FeedInfo.kt
index 4c3211555a8fc4219845e9562e824ca6195c25f5..3d645e655861a1f67f3694aa12da1ab90eb1d675 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/FeedInfo.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/FeedInfo.kt
@@ -6,11 +6,24 @@ package xyz.apiote.bimba.czwek.repo
 
 import xyz.apiote.bimba.czwek.api.structs.FeedInfoV1
 import xyz.apiote.bimba.czwek.api.structs.FeedInfoV2
+import xyz.apiote.bimba.czwek.api.structs.QrLocationV1
+import xyz.apiote.fruchtfleisch.Reader
+import xyz.apiote.fruchtfleisch.Writer
+import java.io.InputStream
+import java.io.OutputStream
 import java.time.LocalDate
 import java.time.format.DateTimeFormatter
 import java.time.format.FormatStyle
 import java.util.Locale
 
+class FeedInfoPrev {
+	companion object {
+		fun unmarshal(stream: InputStream): FeedInfo {
+			return FeedInfo(FeedInfoPrev())
+		}
+	}
+}
+
 data class FeedInfo(
 	val id: String,
 	val name: String,
@@ -24,6 +37,41 @@ 	val validSince: LocalDate?,
 	val validTill: LocalDate?,
 	val cached: Boolean
 ) {
+	companion object {
+		const val VERSION = 100u
+		private fun parseDate(dateString: String) =
+			LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE)
+
+
+		fun unmarshal(stream: InputStream): FeedInfo {
+			val reader = Reader(stream)
+			val id = reader.readString()
+			val name = reader.readString()
+			val attribution = reader.readString()
+			val description = reader.readString()
+			val lastUpdate = parseDate(reader.readString())
+			val qrHost = reader.readString()
+			val qrIn = QrLocation.of(QrLocationV1.of(reader.readUInt().toULong().toUInt()))
+			val qrSelector = reader.readString()
+			val validSince = reader.readString()
+			val validTill = reader.readString()
+
+			return FeedInfo(
+				id,
+				name,
+				attribution,
+				description,
+				lastUpdate,
+				qrHost,
+				qrIn,
+				qrSelector,
+				if (validSince != "") parseDate(validSince) else null,
+				if (validTill != "") parseDate(validTill) else null,
+				true
+			)
+		}
+	}
+
 	constructor(f: FeedInfoV2, cached: Boolean = false) : this(
 		f.id,
 		f.name,
@@ -52,6 +100,38 @@ 		null,
 		cached
 	)
 
+	constructor(f: FeedInfoPrev) : this(
+		"",
+		"",
+		"",
+		"",
+		LocalDate.MIN,
+		"",
+		QrLocation.UNKNOWN,
+		"",
+		null,
+		null,
+		false
+	)
+
+	fun marshal(stream: OutputStream) {
+		val writer = Writer(stream)
+		writer.writeString(id)
+		writer.writeString(name)
+		writer.writeString(attribution)
+		writer.writeString(description)
+		writer.writeString(formatDateMarshal(lastUpdate))
+		writer.writeString(qrHost)
+		writer.writeUInt(qrIn.value().toULong())
+		writer.writeString(qrSelector)
+		writer.writeString(if (validSince == null) "" else formatDateMarshal(validSince))
+		writer.writeString(if (validTill == null) "" else formatDateMarshal(validTill))
+	}
+
+	private fun formatDateMarshal(date: LocalDate): String {
+		return date.format(DateTimeFormatter.ISO_LOCAL_DATE)
+	}
+
 	fun formatDate(): String {
 		return lastUpdate.format(
 			DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(Locale.getDefault())
@@ -74,8 +154,12 @@ 		if (lastUpdate.isAfter(other.lastUpdate)) lastUpdate else other.lastUpdate,
 		other.qrHost,
 		other.qrIn,
 		other.qrSelector,
-		if (other.validSince == null || (validSince?:LocalDate.MIN).isAfter(other.validSince)) validSince else other.validSince,
-		if (other.validTill == null || (validTill?:LocalDate.MIN).isAfter(other.validTill)) validTill else other.validTill,
+		if (other.validSince == null || (validSince
+				?: LocalDate.MIN).isAfter(other.validSince)
+		) validSince else other.validSince,
+		if (other.validTill == null || (validTill
+				?: LocalDate.MIN).isAfter(other.validTill)
+		) validTill else other.validTill,
 		this.cached && other.cached
 	)
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
index dc8bdba932bf7327316bd331ae92791002adce35..7d70dbc8aa3e2cedc903e6e19ea399841623f2a1 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
@@ -6,8 +6,8 @@ package xyz.apiote.bimba.czwek.repo
 
 import android.content.Context
 import android.graphics.drawable.Drawable
-import android.net.ConnectivityManager
 import xyz.apiote.bimba.czwek.api.Server
+import java.time.LocalDate
 
 interface Queryable
 interface Locatable {
@@ -25,23 +25,22 @@
 	suspend fun getDepartures(
 		feedID: String,
 		stop: String,
-		line: String?,
+		date: LocalDate?,
 		context: Context,
 		limit: Int?
 	): StopDepartures?
 
 	suspend fun getLocatablesIn(
-		cm: ConnectivityManager,
+		context: Context,
 		bl: Position,
 		tr: Position,
-		context: Context
 	): List<Locatable>?
 
 	suspend fun getLine(
-		cm: ConnectivityManager,
+		context: Context,
 		feedID: String,
-		line: String,
-		context: Context
+		lineName: String,
+		lineID: String,
 	): Line?
 
 	suspend fun queryQueryables(




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt
index cdffec7beddc8f0caee7728aa408c9bb8204092b..7a28e2b3e2128782b096ffc2744719151997fe65 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt
@@ -9,8 +9,10 @@ import android.graphics.drawable.BitmapDrawable
 import android.graphics.drawable.Drawable
 import xyz.apiote.bimba.czwek.api.LineV1
 import xyz.apiote.bimba.czwek.api.LineV2
+import xyz.apiote.bimba.czwek.api.LineV3
 
 data class Line(
+	val id: String,
 	val name: String,
 	val colour: Colour,
 	val type: LineType,
@@ -21,6 +23,7 @@ ) : Queryable, LineAbstract {
 
 	constructor(line: LineV1) : this(
 		line.name,
+		line.name,
 		Colour(line.colour),
 		LineType.of(line.type),
 		line.feedID,
@@ -28,6 +31,17 @@ 		line.headsigns,
 		line.graphs.map{LineGraph(it)}
 	)
 	constructor(line: LineV2) : this(
+		line.name,
+		line.name,
+		Colour(line.colour),
+		LineType.of(line.type),
+		line.feedID,
+		line.headsigns,
+		line.graphs.map{LineGraph(it)}
+	)
+
+	constructor(line: LineV3) : this(
+		line.id,
 		line.name,
 		Colour(line.colour),
 		LineType.of(line.type),




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineAbstract.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineAbstract.kt
index 527ba30671fdb54696be7f43caf2c228762faf68..d5ecc9bbbba7111c55e31d0015ce15f696e17274 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineAbstract.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineAbstract.kt
@@ -17,6 +17,7 @@ import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.dpToPixel
 import xyz.apiote.bimba.czwek.dpToPixelI
 import kotlin.math.abs
+import kotlin.math.cbrt
 import kotlin.math.pow
 
 interface LineAbstract {
@@ -88,10 +89,10 @@ 		val radiusToPow = (radius * radius * radius).toDouble()
 		val path = Path()
 		path.moveTo(-radius.toFloat(), 0f)
 		for (x in -radius..radius) path.lineTo(
-			x.toFloat(), Math.cbrt(radiusToPow - abs(x * x * x)).toFloat()
+			x.toFloat(), cbrt(radiusToPow - abs(x * x * x)).toFloat()
 		)
 		for (x in radius downTo -radius) path.lineTo(
-			x.toFloat(), -Math.cbrt(radiusToPow - abs(x * x * x)).toFloat()
+			x.toFloat(), -cbrt(radiusToPow - abs(x * x * x)).toFloat()
 		)
 		path.close()
 		val matrix = Matrix()




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
index 0a92c37cd563b5560cd44f559b7ff8ce787d82bb..6f9849cfc8b086ec4809afba0ad14e6218b4f58f 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
@@ -5,60 +5,72 @@
 package xyz.apiote.bimba.czwek.repo
 
 import android.content.Context
-import android.net.ConnectivityManager
+import androidx.core.content.edit
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.api.Server
-import xyz.apiote.bimba.czwek.api.responses.FeedsResponse
-import xyz.apiote.bimba.czwek.api.responses.FeedsResponseDev
-import xyz.apiote.bimba.czwek.api.responses.FeedsResponseV1
-import xyz.apiote.bimba.czwek.api.responses.FeedsResponseV2
+import xyz.apiote.fruchtfleisch.Reader
+import xyz.apiote.fruchtfleisch.Writer
 import java.io.File
-import java.io.FileInputStream
 import java.net.URLEncoder
+import java.time.LocalDate
 
 class OfflineRepository : Repository {
+	fun saveFeedCache(context: Context, feedInfos: Map<String, FeedInfo>) {
+		val file = File(
+			context.filesDir, URLEncoder.encode(Server.get(context).apiPath, "utf-8")
+		)
+		context.getSharedPreferences("offlineFeeds", Context.MODE_PRIVATE).edit {
+			putInt("version", FeedInfo.VERSION.toInt())
+		}
+		val stream = file.outputStream()
+		val writer = Writer(stream)
+		writer.writeUInt(feedInfos.size.toULong())
+		feedInfos.forEach {
+			it.value.marshal(stream)
+		}
+		stream.flush()
+		stream.close()
+	}
+
+	@Suppress("RedundantNullableReturnType")
 	override suspend fun getFeeds(
 		context: Context,
 		server: Server
 	): Map<String, FeedInfo>? {
 		val file = File(
-			context.filesDir, URLEncoder.encode(server.apiPath, "utf-8")
+			context.filesDir, withContext(Dispatchers.IO) {
+				URLEncoder.encode(server.apiPath, "utf-8")
+			}
 		)
 		if (!file.exists()) {
 			return emptyMap()
 		}
-		return when (val response =
-			withContext(Dispatchers.IO) { FeedsResponse.unmarshal(FileInputStream(file)) }) {
-			is FeedsResponseDev -> response.feeds.associate {
-				Pair(
-					it.id,
-					FeedInfo(it).copy(cached = true)
-				)
-			}
 
-			is FeedsResponseV2 -> response.feeds.associate {
-				Pair(
-					it.id,
-					FeedInfo(it).copy(cached = true)
-				)
-			}
-
-			is FeedsResponseV1 -> response.feeds.associate {
-				Pair(
-					it.id,
-					FeedInfo(it).copy(cached = true)
-				)
+		val version =
+			context.getSharedPreferences("offlineFeeds", Context.MODE_PRIVATE).getInt("version", -1)
+		if (version < 0) {
+			return emptyMap()
+		}
+		val unmarshaller =
+			if (version.toUInt() == FeedInfo.VERSION) FeedInfo::unmarshal else FeedInfoPrev::unmarshal
+		val stream = file.inputStream()
+		val feeds = mutableMapOf<String, FeedInfo>()
+		val n = Reader(stream).readUInt().toULong().toInt()
+		repeat(n) {
+			val feed = unmarshaller(stream)
+			feeds[feed.id] = feed
+			if (version.toUInt() != FeedInfo.VERSION) {
+				saveFeedCache(context, feeds)
 			}
-
-			else -> null
 		}
+		return feeds
 	}
 
 	override suspend fun getDepartures(
 		feedID: String,
 		stop: String,
-		line: String?,
+		date: LocalDate?,
 		context: Context,
 		limit: Int?
 	): StopDepartures? {
@@ -66,19 +78,18 @@ 		TODO("Not yet implemented")
 	}
 
 	override suspend fun getLocatablesIn(
-		cm: ConnectivityManager,
+		context: Context,
 		bl: Position,
 		tr: Position,
-		context: Context
 	): List<Locatable>? {
 		TODO("Not yet implemented")
 	}
 
 	override suspend fun getLine(
-		cm: ConnectivityManager,
+		context: Context,
 		feedID: String,
-		line: String,
-		context: Context
+		lineName: String,
+		lineID: String,
 	): Line? {
 		TODO("Not yet implemented")
 	}




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 e25943d37a5bb1d941a759ad6cd56a29f23b20ba..8432f937e713904e92c77440c6d634607f8167fe 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
@@ -5,11 +5,11 @@
 package xyz.apiote.bimba.czwek.repo
 
 import android.content.Context
-import android.net.ConnectivityManager
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.api.LineV1
 import xyz.apiote.bimba.czwek.api.LineV2
+import xyz.apiote.bimba.czwek.api.LineV3
 import xyz.apiote.bimba.czwek.api.PositionV1
 import xyz.apiote.bimba.czwek.api.Server
 import xyz.apiote.bimba.czwek.api.StopV1
@@ -23,6 +23,7 @@ import xyz.apiote.bimba.czwek.api.responses.DeparturesResponseDev
 import xyz.apiote.bimba.czwek.api.responses.DeparturesResponseV1
 import xyz.apiote.bimba.czwek.api.responses.DeparturesResponseV2
 import xyz.apiote.bimba.czwek.api.responses.DeparturesResponseV3
+import xyz.apiote.bimba.czwek.api.responses.DeparturesResponseV4
 import xyz.apiote.bimba.czwek.api.responses.ErrorResponse
 import xyz.apiote.bimba.czwek.api.responses.FeedsResponse
 import xyz.apiote.bimba.czwek.api.responses.FeedsResponseDev
@@ -32,6 +33,7 @@ import xyz.apiote.bimba.czwek.api.responses.LineResponse
 import xyz.apiote.bimba.czwek.api.responses.LineResponseDev
 import xyz.apiote.bimba.czwek.api.responses.LineResponseV1
 import xyz.apiote.bimba.czwek.api.responses.LineResponseV2
+import xyz.apiote.bimba.czwek.api.responses.LineResponseV3
 import xyz.apiote.bimba.czwek.api.responses.LocatablesResponse
 import xyz.apiote.bimba.czwek.api.responses.LocatablesResponseDev
 import xyz.apiote.bimba.czwek.api.responses.LocatablesResponseV1
@@ -42,26 +44,18 @@ import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseDev
 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 java.io.File
-import java.net.URLEncoder
+import xyz.apiote.bimba.czwek.api.responses.QueryablesResponseV4
+import java.time.LocalDate
 
 // todo [3.2] in Repository check if responses are BARE or HTML
 
 class OnlineRepository : Repository {
-	private fun saveFeedCache(server: Server, context: Context, rawResponse: ByteArray) {
-		val file = File(
-			context.filesDir, URLEncoder.encode(server.apiPath, "utf-8")
-		)
-		file.writeBytes(rawResponse)
-	}
-
 	override suspend fun getFeeds(
 		context: Context,
 		server: Server
 	): Map<String, FeedInfo>? {
-		val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		val result =
-			xyz.apiote.bimba.czwek.api.getFeeds(cm, server)
+			xyz.apiote.bimba.czwek.api.getFeeds(context, server)
 		if (result.error != null) {
 			if (result.stream != null) {
 				val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
@@ -71,7 +65,6 @@ 				throw TrafficResponseException(result.error.statusCode, "", result.error)
 			}
 		} else {
 			val rawResponse = result.stream!!.readBytes()
-			saveFeedCache(server, context, rawResponse)
 			return when (val response =
 				withContext(Dispatchers.IO) { FeedsResponse.unmarshal(rawResponse.inputStream()) }) {
 				is FeedsResponseDev -> response.feeds.associate { Pair(it.id, FeedInfo(it)) }
@@ -86,13 +79,19 @@
 	override suspend fun getDepartures(
 		feedID: String,
 		stop: String,
-		line: String?,
+		date: LocalDate?,
 		context: Context,
 		limit: Int?
 	): StopDepartures? {
-		val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		val result =
-			xyz.apiote.bimba.czwek.api.getDepartures(cm, Server.get(context), feedID, stop, line, limit)
+			xyz.apiote.bimba.czwek.api.getDepartures(
+				context,
+				Server.get(context),
+				feedID,
+				stop,
+				date,
+				limit
+			)
 		if (result.error != null) {
 			if (result.stream != null) {
 				val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
@@ -104,6 +103,11 @@ 		} else {
 			return when (val response =
 				withContext(Dispatchers.IO) { DeparturesResponse.unmarshal(result.stream!!) }) {
 				is DeparturesResponseDev -> StopDepartures(
+					response.departures.map { Departure(it) },
+					Stop(response.stop),
+					response.alerts.map { Alert(it) })
+
+				is DeparturesResponseV4 -> StopDepartures(
 					response.departures.map { Departure(it) },
 					Stop(response.stop),
 					response.alerts.map { Alert(it) })
@@ -129,13 +133,12 @@ 		}
 	}
 
 	override suspend fun getLocatablesIn(
-		cm: ConnectivityManager,
+		context: Context,
 		bl: Position,
 		tr: Position,
-		context: Context
 	): List<Locatable>? {
 		val result = xyz.apiote.bimba.czwek.api.getLocatablesIn(
-			cm,
+			context,
 			Server.get(context),
 			PositionV1(bl.latitude, bl.longitude),
 			PositionV1(tr.latitude, tr.longitude)
@@ -188,9 +191,10 @@ 		}
 	}
 
 	override suspend fun getLine(
-		cm: ConnectivityManager, feedID: String, line: String, context: Context
+		context: Context, feedID: String, lineName: String, lineID: String
 	): Line? {
-		val result = xyz.apiote.bimba.czwek.api.getLine(cm, Server.get(context), feedID, line)
+		val result =
+			xyz.apiote.bimba.czwek.api.getLine(context, Server.get(context), feedID, lineName, lineID)
 		if (result.error != null) {
 			if (result.stream != null) {
 				val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
@@ -204,6 +208,7 @@ 				withContext(Dispatchers.IO) { LineResponse.unmarshal(result.stream!!) }) {
 				is LineResponseDev -> Line(response.line)
 				is LineResponseV1 -> Line(response.line)
 				is LineResponseV2 -> Line(response.line)
+				is LineResponseV3 -> Line(response.line)
 				else -> null
 			}
 		}
@@ -224,14 +229,18 @@
 	private suspend fun getQueryables(
 		query: String?, position: Position?, context: Context, type: String
 	): List<Queryable>? {
-		val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		val result = when (type) {
 			"query" -> {
-				xyz.apiote.bimba.czwek.api.queryQueryables(cm, Server.get(context), query!!, limit = 12)
+				xyz.apiote.bimba.czwek.api.queryQueryables(
+					context,
+					Server.get(context),
+					query!!,
+					limit = 12
+				)
 			}
 
 			"locate" -> xyz.apiote.bimba.czwek.api.locateQueryables(
-				cm, Server.get(context), PositionV1(position!!.latitude, position.longitude)
+				context, Server.get(context), PositionV1(position!!.latitude, position.longitude)
 			)
 
 			else -> throw RuntimeException("Unknown query type $type")
@@ -249,8 +258,8 @@ 				withContext(Dispatchers.IO) { QueryablesResponse.unmarshal(result.stream!!) }) {
 				is QueryablesResponseDev -> response.queryables.map {
 					when (it) {
 						is StopV2 -> Stop(it)
-						is LineV2 -> Line(it)
-						else -> throw UnknownResourceException("queryablesV2", it::class)
+						is LineV3 -> Line(it)
+						else -> throw UnknownResourceException("queryablesV4", it::class)
 					}
 				}
 
@@ -273,7 +282,15 @@ 				is QueryablesResponseV3 -> response.queryables.map {
 					when (it) {
 						is StopV2 -> Stop(it)
 						is LineV2 -> Line(it)
-						else -> throw UnknownResourceException("queryablesV2", it::class)
+						else -> throw UnknownResourceException("queryablesV3", it::class)
+					}
+				}
+
+				is QueryablesResponseV4 -> response.queryables.map {
+					when (it) {
+						is StopV2 -> Stop(it)
+						is LineV3 -> Line(it)
+						else -> throw UnknownResourceException("queryablesV4", it::class)
 					}
 				}
 




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/QrLocation.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/QrLocation.kt
index 93d5932ed09463d59ff583a66727df435461f03e..108b0077fdb81df22d1e537ed9e696f3bc6d4f92 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/QrLocation.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/QrLocation.kt
@@ -20,4 +20,13 @@ 				QrLocationV1.QUERY -> QUERY
 			}
 		}
 	}
+
+	fun value(): UInt {
+		return when (this) {
+			UNKNOWN -> 0u
+			NONE -> 1u
+			PATH -> 2u
+			QUERY -> 3u
+		}
+	}
 }




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 3c79dffea5278fa9f9090bd4cbd18c8f098a3a79..0c6909006f92fe6a5bc7add619c14d4b7cc7a0a4 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
@@ -5,20 +5,10 @@
 package xyz.apiote.bimba.czwek.repo
 
 import android.content.Context
-import android.content.res.Configuration
-import android.content.res.TypedArray
-import android.graphics.Bitmap
-import android.graphics.drawable.BitmapDrawable
 import android.graphics.drawable.Drawable
-import androidx.appcompat.content.res.AppCompatResources
-import androidx.core.graphics.ColorUtils
-import androidx.core.graphics.drawable.toBitmap
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.api.StopV1
 import xyz.apiote.bimba.czwek.api.StopV2
-import xyz.apiote.bimba.czwek.dpToPixelI
-import java.util.zip.Adler32
-
 
 data class Stop(
 	val code: String,
@@ -28,32 +18,10 @@ 	val zone: String,
 	val feedID: String?,
 	val position: Position,
 	val changeOptions: List<ChangeOption>
-) : Queryable, Locatable {
+) : Queryable, Locatable, StopAbstract {
 
 	override fun icon(context: Context, scale: Float): Drawable {
-		val md = Adler32().let {
-			it.update(nodeName.toByteArray())
-			it.value
-		}
-		val h = md % 359f
-		val s = 1.0f
-		val a: TypedArray = context.theme.obtainStyledAttributes(
-			R.style.Theme_Bimba, intArrayOf(R.attr.randomColourLightness)
-		)
-		val l = a.getFloat(0, when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
-			Configuration.UI_MODE_NIGHT_YES -> 1f
-			Configuration.UI_MODE_NIGHT_NO -> 0f
-			Configuration.UI_MODE_NIGHT_UNDEFINED -> 0f
-			else -> 0f
-		})
-		a.recycle()
-		val bg = AppCompatResources.getDrawable(context, R.drawable.stop)!!.mutate().apply {
-			setTint(ColorUtils.HSLToColor(floatArrayOf(h, s, l)))
-		}
-		return BitmapDrawable(
-			context.resources,
-			bg.toBitmap(dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888)
-		)
+		return super.icon(context, nodeName, scale)
 	}
 
 	override fun id(): String = code




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopAbstract.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopAbstract.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5a50f42e09d60e52d10100d188243eaa51cae669
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopAbstract.kt
@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.content.res.Configuration
+import android.content.res.TypedArray
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.graphics.ColorUtils
+import androidx.core.graphics.drawable.toBitmap
+import xyz.apiote.bimba.czwek.R
+import xyz.apiote.bimba.czwek.dpToPixelI
+import java.util.zip.Adler32
+
+interface StopAbstract{
+	fun icon(context: Context, nodeName: String, scale: Float): Drawable {
+		val md = Adler32().let {
+			it.update(nodeName.toByteArray())
+			it.value
+		}
+		val h = md % 359f
+		val s = 1.0f
+		val a: TypedArray = context.theme.obtainStyledAttributes(
+			R.style.Theme_Bimba, intArrayOf(R.attr.randomColourLightness)
+		)
+		val l = a.getFloat(0, when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
+			Configuration.UI_MODE_NIGHT_YES -> 1f
+			Configuration.UI_MODE_NIGHT_NO -> 0f
+			Configuration.UI_MODE_NIGHT_UNDEFINED -> 0f
+			else -> 0f
+		})
+		a.recycle()
+		val bg = AppCompatResources.getDrawable(context, R.drawable.stop)!!.mutate().apply {
+			setTint(ColorUtils.HSLToColor(floatArrayOf(h, s, l)))
+		}
+		return BitmapDrawable(
+			context.resources,
+			bg.toBitmap(dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888)
+		)
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt
index b3536dc60d5d3c69af3f9dab98b919110aa0856e..361b093c3f8dd836f328f608dda785f7db11d186 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt
@@ -5,24 +5,14 @@
 package xyz.apiote.bimba.czwek.repo
 
 import android.content.Context
-import android.content.res.Configuration
-import android.content.res.TypedArray
-import android.graphics.Bitmap
-import android.graphics.drawable.BitmapDrawable
 import android.graphics.drawable.Drawable
 import android.os.Parcelable
-import androidx.appcompat.content.res.AppCompatResources
-import androidx.core.graphics.ColorUtils
-import androidx.core.graphics.drawable.toBitmap
 import kotlinx.parcelize.Parcelize
-import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.api.StopStub
-import xyz.apiote.bimba.czwek.dpToPixelI
-import java.util.zip.Adler32
 
 @Parcelize
 data class StopStub(val name: String, val nodeName: String, val code: String, val zone: String, val onDemand: Boolean) :
-	Parcelable {
+	Parcelable, StopAbstract {
 	constructor(stopStub: StopStub) : this(
 		stopStub.name,
 		stopStub.nodeName,
@@ -31,29 +21,6 @@ 		stopStub.zone,
 		stopStub.onDemand
 	)
 	fun icon(context: Context, scale: Float = 1f): Drawable {
-		// TODO same is in Stop
-		val md = Adler32().let {
-			it.update(nodeName.toByteArray())
-			it.value
-		}
-		val h = md % 359f
-		val s = 1.0f
-		val a: TypedArray = context.theme.obtainStyledAttributes(
-			R.style.Theme_Bimba, intArrayOf(R.attr.randomColourLightness)
-		)
-		val l = a.getFloat(0, when (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) {
-			Configuration.UI_MODE_NIGHT_YES -> 1f
-			Configuration.UI_MODE_NIGHT_NO -> 0f
-			Configuration.UI_MODE_NIGHT_UNDEFINED -> 0f
-			else -> 0f
-		})
-		a.recycle()
-		val bg = AppCompatResources.getDrawable(context, R.drawable.stop)!!.mutate().apply {
-			setTint(ColorUtils.HSLToColor(floatArrayOf(h, s, l)))
-		}
-		return BitmapDrawable(
-			context.resources,
-			bg.toBitmap(dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888)
-		)
+		return super.icon(context, nodeName, scale)
 	}
 }
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt
index df0662fa875ec77a21b4c94b520104eb546c9949..128a2199a685110c6bfd442bf81ed6509cbda12f 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt
@@ -4,8 +4,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 package xyz.apiote.bimba.czwek.search
 
-import android.content.Context
-import android.net.ConnectivityManager
 import android.os.Bundle
 import android.util.Log
 import android.view.View
@@ -31,22 +29,22 @@
 		binding = ActivityLineGraphBinding.inflate(layoutInflater)
 		setContentView(binding.root)
 
-		val lineName = intent.getStringExtra("line")!!
+		val lineName = intent.getStringExtra("lineName")!!
+		val lineID = intent.getStringExtra("lineID")!!
 		val feedID = intent.getStringExtra("feedID")!!
-		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		binding.title.text = lineName
-		getGraph(lineName, feedID, cm)
+		getGraph(lineName, lineID, feedID)
 	}
 
 	private fun getGraph(
 		lineName: String,
+		lineID: String,
 		feedID: String,
-		cm: ConnectivityManager,
 	) {
 		MainScope().launch {
 			try {
 				val repository = OnlineRepository()
-				val line = repository.getLine(cm, feedID, lineName, this@LineGraphActivity)
+				val line = repository.getLine(this@LineGraphActivity, feedID, lineName, lineID)
 				line?.let {
 					sectionsPagerAdapter = SectionsPagerAdapter(supportFragmentManager, it)
 					val viewPager: ViewPager = binding.viewPager




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 ef4757d062459420ae3714d74bce0c0592bcb441..d18cb129731d39ea38fdf1878605754b52088caa 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
@@ -226,7 +226,8 @@ 			}
 
 			is Line -> {
 				val intent = Intent(context, LineGraphActivity::class.java).apply {
-					putExtra("line", it.name)
+					putExtra("lineName", it.name)
+					putExtra("lineID", it.id)
 					putExtra("feedID", it.feedID)
 				}
 				context!!.startActivity(intent)




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 6243e61e18928bc6551514c3f3195c511222738c..95054348f2de4faf524e27245737e784f1222b45 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
@@ -8,7 +8,6 @@ import android.content.Context
 import android.location.Location
 import android.location.LocationListener
 import android.location.LocationManager
-import android.net.ConnectivityManager
 import android.os.Build
 import android.os.Bundle
 import android.os.Handler




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt
index 88c8d402f9f5bd10433e93faf71794962ea269a3..84c6285e11884e3a4ff364dbe0b27f128f519785 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt
@@ -38,6 +38,7 @@
 	override fun onCreate(savedInstanceState: Bundle?) {
 		super.onCreate(savedInstanceState)
 		adapter = LineGraphAdapter(
+			arguments?.getString("lineID", "") ?: "",
 			arguments?.getString("lineName", "") ?: "",
 			arguments?.getString("feedID", "") ?: ""
 		)
@@ -87,10 +88,16 @@ 	}
 
 	companion object {
 		@JvmStatic
-		fun newInstance(lineGraph: LineGraph, lineName: String, feedID: String): LineGraphFragment {
+		fun newInstance(
+			lineGraph: LineGraph,
+			lineID: String,
+			lineName: String,
+			feedID: String
+		): LineGraphFragment {
 			return LineGraphFragment().apply {
 				arguments = Bundle().apply {
 					putParcelable("graph", lineGraph)
+					putString("lineID", lineID)
 					putString("lineName", lineName)
 					putString("feedID", feedID)
 				}
@@ -104,7 +111,11 @@ 		_binding = null
 	}
 }
 
-class LineGraphAdapter(private val lineName: String, private val feedID: String) :
+class LineGraphAdapter(
+	private val lineID: String,
+	private val lineName: String,
+	private val feedID: String
+) :
 	AbstractGraphAdapter<BimbaViewHolder>() {
 	private lateinit var context: Context
 	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaViewHolder {
@@ -120,9 +131,10 @@ 			val intent = Intent(context, DeparturesActivity::class.java).apply {
 				putExtra("code", it.code)
 				putExtra("name", it.name)
 				putExtra("line", lineName)
+				putExtra("lineID", lineID)
 				putExtra("feedID", feedID)
 			}
 			context.startActivity(intent)
 		}
 	}
-}
\ No newline at end of file
+}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/SectionsPagerAdapter.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/SectionsPagerAdapter.kt
index 4233f8e172aef60ad7224c25771165cf556fd4c4..ec1c7b9130af99eef56553f45c9014948f705c95 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/SectionsPagerAdapter.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/SectionsPagerAdapter.kt
@@ -13,7 +13,7 @@ class SectionsPagerAdapter(fm: FragmentManager, val line: Line) :
 	FragmentPagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
 
 	override fun getItem(position: Int): Fragment {
-		return LineGraphFragment.newInstance(line.graphs[position], line.name, line.feedID)
+		return LineGraphFragment.newInstance(line.graphs[position], line.id, line.name, line.feedID)
 	}
 
 	override fun getPageTitle(position: Int): CharSequence {
@@ -21,6 +21,6 @@ 		return line.headsigns[position].joinToString()
 	}
 
 	override fun getCount(): Int {
-		return 2
+		return line.headsigns.size
 	}
-}
\ No newline at end of file
+}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt
index 7a7c268eae257dddc5e7be27a5230ff270e3c569..2780cb83d4dea6903fe6a720c6ad8c95622ddc0b 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt
@@ -134,7 +134,7 @@
 	private fun checkServer(isSimple: Boolean) {
 		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		MainScope().launch {
-			val result = getBimba(cm, Server.get(this@ServerChooserActivity))
+			val result = getBimba(this@ServerChooserActivity, Server.get(this@ServerChooserActivity))
 			if (result.error != null) {
 				showDialog(R.string.error, result.error.stringResource, result.error.imageResource, null)
 				Log.w(




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt
index 9a0d146235b441b3dba102139db061d33fa0ef89..c01d13f54ea0addd2b246d3d070d96aac3aed5f0 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt
@@ -84,7 +84,6 @@ 			BimbaFeedInfoAdapter(
 				layoutInflater,
 				(viewModel.feeds.value ?: emptyMap()).map { it.value }.sortedBy { it.name },
 				viewModel.settings.value!!,
-				this,
 				{
 					showBottomSheet(it)
 				},




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedInfos.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedInfos.kt
index aca53807906122b038f7b86289157d6176d1daaa..411804efd7acd137d2fb6b463d7794c3cf1395a5 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedInfos.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedInfos.kt
@@ -4,7 +4,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 package xyz.apiote.bimba.czwek.settings.feeds
 
-import android.content.Context
 import android.content.DialogInterface
 import android.os.Bundle
 import android.view.LayoutInflater
@@ -33,20 +32,11 @@ 		fun bind(
 			feed: FeedInfo,
 			feedSettings: FeedSettings?,
 			holder: BimbaFeedInfoViewHolder?,
-			context: Context,
 			onClickListener: (String) -> Unit,
 			onCheckedChangeListener: (String, Boolean) -> Unit
 		) {
-			val colorAttr = if (feed.cached) {
-				com.google.android.material.R.attr.colorOnSurfaceVariant
-			} else {
-				com.google.android.material.R.attr.colorOnSurface
-			}
-			holder?.name?.setTextColor(
-				context.theme.obtainStyledAttributes(
-					R.style.Theme_Bimba, intArrayOf(colorAttr)
-				).getColor(0, 0)
-			)
+			holder?.name?.alpha = if (feed.cached) { .5f } else { 1f }
+
 			holder?.root?.setOnClickListener {
 				onClickListener(feed.id)
 			}
@@ -70,7 +60,6 @@ class BimbaFeedInfoAdapter(
 	private val inflater: LayoutInflater,
 	private var feeds: List<FeedInfo>,
 	private var feedsSettings: FeedsSettings,
-	private val context: Context,
 	private val onClickListener: ((String) -> Unit),
 	private val onEnabledChangedListener: ((String, Boolean) -> Unit)
 ) :
@@ -109,7 +98,6 @@ 		BimbaFeedInfoViewHolder.bind(
 			feed,
 			feedsSettings.settings[feed.id],
 			holder,
-			context,
 			onClickListener,
 			onEnabledChangedListener
 		)
@@ -162,7 +150,7 @@ 	): View {
 		val content = inflater.inflate(R.layout.feed_bottom_sheet, container, false)
 		val feed = feeds[feedID]!!
 		var settings = feedsSettings.settings[feedID]
-		content.findViewById<MaterialTextView>(R.id.title).text = feed.name
+		content.findViewById<MaterialTextView>(R.id.feed_name).text = feed.name
 		content.findViewById<MaterialTextView>(R.id.description).text = feed.description
 		content.findViewById<MaterialTextView>(R.id.outdated_info_warning).visibility =
 			if (feed.cached) {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt
index 4b651ced88475df9b4e0d3ae1982b9eb49d26500..6d5fb983e6baf603ce0237922088462d9450f328 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt
@@ -11,6 +11,7 @@ import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.api.Error
 import xyz.apiote.bimba.czwek.repo.FeedInfo
 import xyz.apiote.bimba.czwek.repo.OfflineRepository
@@ -44,25 +45,36 @@ 		setSettings(feedID, feedSettings?.copy(enabled = enabled) ?: FeedSettings(enabled, true))
 	}
 
 	fun loadFeeds(context: Context) {
+		var offlineFeeds: Map<String, FeedInfo>? = null
+		var onlineFeeds: Map<String, FeedInfo>? = null
+		var error: Error? = null
 		MainScope().launch {
-			val offlineRepository = OfflineRepository()
-			val offlineFeeds =
-				offlineRepository.getFeeds(context)
-			if (!offlineFeeds.isNullOrEmpty()) {
-				_feeds.value = offlineFeeds!!
+			withContext(coroutineContext) {
+				launch {
+					offlineFeeds =
+						OfflineRepository().getFeeds(context)
+					if (!offlineFeeds.isNullOrEmpty()) {
+						_feeds.value = offlineFeeds!!
+					}
+				}
+				launch {
+					try {
+						val repository = OnlineRepository()
+						onlineFeeds =
+							repository.getFeeds(context)
+					} catch (e: TrafficResponseException) {
+						error = e.error
+						Log.e("Feeds", "$e")
+					}
+				}
 			}
-			try {
-				val repository = OnlineRepository()
-				val onlineFeeds =
-					repository.getFeeds(context)
+			if (offlineFeeds.isNullOrEmpty() && error != null) {
+				_error.value = error!!
+			}  else{
 				joinFeeds(offlineFeeds, onlineFeeds).let { joinedFeeds ->
 					_feeds.value = joinedFeeds
+					OfflineRepository().saveFeedCache(context, joinedFeeds)
 				}
-			} catch (e: TrafficResponseException) {
-				if (offlineFeeds.isNullOrEmpty()) {
-					_error.value = e.error
-				}
-				Log.e("Feeds", "$e")
 			}
 		}
 	}
@@ -82,6 +94,8 @@ 		if (feeds2.isNullOrEmpty()) {
 			return feeds1
 		}
 
-		return feeds1.keys.union(feeds2.keys).associateWith { feeds1[it].join(feeds2[it]) }
+		return feeds1.keys.union(feeds2.keys).associateWith {
+			feeds1[it].join(feeds2[it])
+		}
 	}
 }




diff --git a/app/src/main/res/drawable/calendar.xml b/app/src/main/res/drawable/calendar.xml
new file mode 100644
index 0000000000000000000000000000000000000000..135a57526208e28cfa5c7032fab1b7387b2692f9
--- /dev/null
+++ b/app/src/main/res/drawable/calendar.xml
@@ -0,0 +1,11 @@
+<!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+
+<vector android:height="24dp" android:tint="?attr/colorOnSurface"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M19,4h-1V2h-2v2H8V2H6v2H5C3.89,4 3.01,4.9 3.01,6L3,20c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V6C21,4.9 20.1,4 19,4zM19,20H5V10h14V20zM9,14H7v-2h2V14zM13,14h-2v-2h2V14zM17,14h-2v-2h2V14zM9,18H7v-2h2V18zM13,18h-2v-2h2V18zM17,18h-2v-2h2V18z"/>
+</vector>




diff --git a/app/src/main/res/drawable/filter.xml b/app/src/main/res/drawable/filter.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c391d75031b1c369760e31b44a0e7506cacf94b4
--- /dev/null
+++ b/app/src/main/res/drawable/filter.xml
@@ -0,0 +1,11 @@
+<!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+
+<vector android:height="24dp" android:tint="?attr/colorOnSurface"
+  android:viewportHeight="24" android:viewportWidth="24"
+  android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z"/>
+</vector>




diff --git a/app/src/main/res/drawable/warning.xml b/app/src/main/res/drawable/warning.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5b2e32cdd6384ca1445fd00351b7b0e6bcb62565
--- /dev/null
+++ b/app/src/main/res/drawable/warning.xml
@@ -0,0 +1,16 @@
+<!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960"
+  android:tint="?attr/colorOnSurface">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M40,840L480,80L920,840L40,840ZM178,760L782,760L480,240L178,760ZM480,720Q497,720 508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720ZM440,600L520,600L520,400L440,400L440,600ZM480,500L480,500L480,500L480,500Z"/>
+</vector>




diff --git a/app/src/main/res/layout/activity_departures.xml b/app/src/main/res/layout/activity_departures.xml
index d71bb1c201f10214475335f515a81489fd690a3b..4744dc352f27edaf2b35495e8d89e7775388ef95 100644
--- a/app/src/main/res/layout/activity_departures.xml
+++ b/app/src/main/res/layout/activity_departures.xml
@@ -13,6 +13,67 @@ 	android:layout_width="match_parent"
 	android:layout_height="match_parent"
 	android:paddingBottom="16dp">
 
+	<androidx.recyclerview.widget.RecyclerView
+		android:id="@+id/departures_recycler"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		android:clipToPadding="false"
+		android:fitsSystemWindows="true"
+		android:visibility="gone"
+		app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+	<com.google.android.material.card.MaterialCardView
+		android:id="@+id/alerts"
+		android:layout_width="match_parent"
+		android:layout_height="100dp"
+		android:backgroundTint="@color/safety"
+		android:visibility="gone"
+		app:layout_anchor="@id/app_bar_layout"
+		app:layout_anchorGravity="bottom">
+
+		<androidx.constraintlayout.widget.ConstraintLayout
+			android:layout_width="match_parent"
+			android:layout_height="match_parent">
+
+			<ImageView
+				android:id="@+id/imageView"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:layout_marginStart="8dp"
+				android:importantForAccessibility="no"
+				android:src="@drawable/warning"
+				app:layout_constraintStart_toStartOf="parent"
+				app:layout_constraintTop_toTopOf="@+id/alerts_text"
+				app:tint="@color/black" />
+
+			<com.google.android.material.textview.MaterialTextView
+				android:id="@+id/alerts_text"
+				android:layout_width="0dp"
+				android:layout_height="0dp"
+				android:layout_marginStart="8dp"
+				android:layout_marginTop="58dp"
+				android:layout_marginEnd="8dp"
+				android:ellipsize="end"
+				android:maxLines="2"
+				android:textColor="@color/black"
+				app:layout_constraintBottom_toBottomOf="parent"
+				app:layout_constraintEnd_toStartOf="@+id/more_button"
+				app:layout_constraintStart_toEndOf="@+id/imageView"
+				app:layout_constraintTop_toTopOf="parent"
+				tool:text="Warning: Serious blockade on Piastowska towards Wojska Polskiego. Lines 5, 14, 163 diverted. Change for other means of transport, e.g. lines \n\naaaaa" />
+
+			<com.google.android.material.button.MaterialButton
+				android:id="@+id/more_button"
+				style="@style/Widget.Material3.Button.TextButton"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:text="@string/more"
+				android:textColor="@color/link"
+				app:layout_constraintBottom_toBottomOf="parent"
+				app:layout_constraintEnd_toEndOf="parent" />
+		</androidx.constraintlayout.widget.ConstraintLayout>
+	</com.google.android.material.card.MaterialCardView>
+
 	<androidx.constraintlayout.widget.ConstraintLayout
 		android:id="@+id/departures_overlay"
 		android:layout_width="match_parent"
@@ -57,6 +118,7 @@ 			tool:text="No connection" />
 	</androidx.constraintlayout.widget.ConstraintLayout>
 
 	<com.google.android.material.appbar.AppBarLayout
+		android:id="@+id/app_bar_layout"
 		android:layout_width="match_parent"
 		android:layout_height="wrap_content"
 		android:fitsSystemWindows="true">
@@ -74,19 +136,11 @@ 				android:id="@+id/departures_app_bar"
 				android:layout_width="match_parent"
 				android:layout_height="?attr/actionBarSize"
 				android:elevation="0dp"
-				app:layout_collapseMode="pin" />
+				app:layout_collapseMode="pin"
+				app:menu="@menu/departures_menu" />
 
 		</com.google.android.material.appbar.CollapsingToolbarLayout>
 
 	</com.google.android.material.appbar.AppBarLayout>
-
-	<androidx.recyclerview.widget.RecyclerView
-		android:id="@+id/departures_recycler"
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"
-		android:clipToPadding="false"
-		android:fitsSystemWindows="true"
-		android:visibility="gone"
-		app:layout_behavior="@string/appbar_scrolling_view_behavior" />
 
 </androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/departure_bottom_sheet.xml b/app/src/main/res/layout/departure_bottom_sheet.xml
index 07fdfe3f032d1d913bd62072817aef68823c0c42..5d5c7b3acb6ae7a0b5a21c7c50f67771ed7fc176 100644
--- a/app/src/main/res/layout/departure_bottom_sheet.xml
+++ b/app/src/main/res/layout/departure_bottom_sheet.xml
@@ -23,11 +23,11 @@ 	 		android:id="@+id/time"
 		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
-		android:layout_marginTop="48dp"
+		android:layout_marginTop="0dp"
 		android:textAppearance="@style/TextAppearance.Material3.DisplaySmall"
 		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toTopOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/drag_handle"
 		tool:text="at 12:10:30" />
 
 	<com.google.android.material.textview.MaterialTextView
@@ -75,105 +75,113 @@ 		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
 		app:layout_constraintTop_toBottomOf="@id/time" />
 
-	<ImageView
-		android:id="@+id/boarding_icon"
-		android:layout_width="16dp"
-		android:layout_height="16dp"
-		android:layout_marginEnd="8dp"
-		android:importantForAccessibility="no"
-		app:layout_constraintBottom_toBottomOf="@+id/boarding_text"
-		app:layout_constraintEnd_toStartOf="@+id/boarding_text"
-		app:layout_constraintTop_toTopOf="@+id/boarding_text"
-		app:srcCompat="@drawable/transfer" />
+	<androidx.constraintlayout.helper.widget.Flow
+		android:id="@+id/info"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="48dp"
+		android:layout_marginTop="48dp"
+		android:layout_marginEnd="48dp"
+		app:constraint_referenced_ids="boarding,speed,congestion,occupancy"
+		app:flow_horizontalGap="4dp"
+		app:flow_horizontalStyle="spread_inside"
+		app:flow_verticalGap="4dp"
+		app:flow_wrapMode="chain"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/line" />
 
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/boarding_text"
+	<LinearLayout
+		android:id="@+id/boarding"
 		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="180dp"
-		android:layout_marginEnd="8dp"
-		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
-		app:layout_constraintEnd_toEndOf="@id/middle"
-		app:layout_constraintTop_toTopOf="parent"
-		tool:text="on demand" />
+		android:gravity="center_vertical">
 
-	<ImageView
-		android:id="@+id/speed_icon"
-		android:layout_width="16dp"
-		android:layout_height="16dp"
-		android:layout_marginEnd="8dp"
-		android:importantForAccessibility="no"
-		app:layout_constraintBottom_toBottomOf="@+id/speed_text"
-		app:layout_constraintEnd_toStartOf="@+id/speed_text"
-		app:layout_constraintTop_toTopOf="@+id/speed_text"
-		app:srcCompat="@drawable/speed" />
+		<ImageView
+			android:id="@+id/boarding_icon"
+			android:layout_width="16dp"
+			android:layout_height="16dp"
+			android:layout_marginEnd="8dp"
+			android:importantForAccessibility="no"
+			app:srcCompat="@drawable/transfer" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/boarding_text"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginEnd="8dp"
+			android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+			tool:text="on demand" />
+	</LinearLayout>
 
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/speed_text"
+	<LinearLayout
+		android:id="@+id/speed"
 		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
-		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
-		app:layout_constraintEnd_toStartOf="@+id/middle"
-		app:layout_constraintTop_toBottomOf="@id/boarding_text"
-		tool:text="10 Vl" />
+		android:gravity="center_vertical">
 
-	<ImageView
-		android:id="@+id/congestion_icon"
-		android:layout_width="16dp"
-		android:layout_height="16dp"
-		android:layout_marginStart="8dp"
-		android:layout_marginEnd="8dp"
-		android:importantForAccessibility="no"
-		app:layout_constraintBottom_toBottomOf="@+id/congestion_text"
-		app:layout_constraintStart_toStartOf="@+id/middle"
-		app:layout_constraintTop_toTopOf="@+id/congestion_text"
-		app:srcCompat="@drawable/traffic" />
+		<ImageView
+			android:id="@+id/speed_icon"
+			android:layout_width="16dp"
+			android:layout_height="16dp"
+			android:layout_marginEnd="8dp"
+			android:importantForAccessibility="no"
+			app:srcCompat="@drawable/speed" />
 
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/congestion_text"
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/speed_text"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginEnd="8dp"
+			android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+			tool:text="10 Vl" />
+	</LinearLayout>
+
+	<LinearLayout
+		android:id="@+id/congestion"
 		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="180dp"
-		android:layout_marginEnd="8dp"
-		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
-		app:layout_constraintStart_toEndOf="@id/congestion_icon"
-		app:layout_constraintTop_toTopOf="parent"
-		tool:text="smooth traffic" />
+		android:gravity="center_vertical">
+
+		<ImageView
+			android:id="@+id/congestion_icon"
+			android:layout_width="16dp"
+			android:layout_height="16dp"
+			android:layout_marginEnd="8dp"
+			android:importantForAccessibility="no"
+			app:srcCompat="@drawable/traffic" />
 
-	<ImageView
-		android:id="@+id/occupancy_icon"
-		android:layout_width="16dp"
-		android:layout_height="16dp"
-		android:layout_marginStart="8dp"
-		android:layout_marginEnd="8dp"
-		android:importantForAccessibility="no"
-		app:layout_constraintBottom_toBottomOf="@+id/occupancy_text"
-		app:layout_constraintStart_toStartOf="@+id/middle"
-		app:layout_constraintTop_toTopOf="@+id/occupancy_text"
-		app:srcCompat="@drawable/crowd" />
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/congestion_text"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginEnd="8dp"
+			android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+			tool:text="smooth traffic" />
+	</LinearLayout>
 
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/occupancy_text"
+	<LinearLayout
+		android:id="@+id/occupancy"
 		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
-		app:layout_constraintStart_toEndOf="@id/occupancy_icon"
-		app:layout_constraintTop_toBottomOf="@id/congestion_text"
-		tool:text="empty vehicle" />
+		android:gravity="center_vertical">
+
+		<ImageView
+			android:id="@+id/occupancy_icon"
+			android:layout_width="16dp"
+			android:layout_height="16dp"
+			android:layout_marginEnd="8dp"
+			android:importantForAccessibility="no"
+			app:srcCompat="@drawable/crowd" />
 
-	<androidx.constraintlayout.widget.Guideline
-		android:id="@+id/middle"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:orientation="vertical"
-		app:layout_constraintGuide_percent=".5" />
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/occupancy_text"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginEnd="8dp"
+			android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+			tool:text="empty vehicle" />
+	</LinearLayout>
 
 	<androidx.constraintlayout.helper.widget.Flow
 		android:id="@+id/capabilities"
@@ -189,7 +197,7 @@ 		app:flow_verticalGap="4dp"
 		app:flow_wrapMode="chain"
 		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/occupancy_text" />
+		app:layout_constraintTop_toBottomOf="@+id/info" />
 
 	<ImageView
 		android:id="@+id/ac"
@@ -231,6 +239,34 @@ 		android:contentDescription="@string/usb_charging_content_description"
 		app:srcCompat="@drawable/usb"
 		tool:ignore="MissingConstraints" />
 
+	<LinearLayout
+		android:layout_marginTop="8dp"
+		android:id="@+id/alerts"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:gravity="center_vertical"
+		android:visibility="gone"
+		android:background="@color/safety"
+		app:layout_constraintTop_toBottomOf="@+id/capabilities">
+
+		<ImageView
+			android:layout_marginStart="8dp"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			app:tint="@color/black"
+			android:importantForAccessibility="no"
+			android:src="@drawable/warning" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/alerts_text"
+			android:layout_width="match_parent"
+			android:layout_height="wrap_content"
+			android:layout_margin="8dp"
+			android:padding="8dp"
+			android:textColor="@color/black"
+			tool:text="Severe stops on Metropolitan line towards Tower Hill through Victoria" />
+	</LinearLayout>
+
 	<org.osmdroid.views.MapView
 		android:id="@+id/map"
 		android:layout_width="match_parent"
@@ -238,5 +274,5 @@ 		android:layout_height="250dp"
 		android:layout_margin="16dp"
 		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/capabilities" />
+		app:layout_constraintTop_toBottomOf="@+id/alerts" />
 </androidx.constraintlayout.widget.ConstraintLayout>




diff --git a/app/src/main/res/layout/feed_bottom_sheet.xml b/app/src/main/res/layout/feed_bottom_sheet.xml
index bcb47b534c804342efbcdece3b2dee15fa746216..97c3bd552d9fd51347bba2fa92c98a0068714e9a 100644
--- a/app/src/main/res/layout/feed_bottom_sheet.xml
+++ b/app/src/main/res/layout/feed_bottom_sheet.xml
@@ -20,7 +20,7 @@ 		android:layout_height="wrap_content"
 		app:layout_constraintTop_toTopOf="parent" />
 
 	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/title"
+		android:id="@+id/feed_name"
 		android:layout_width="0dp"
 		android:layout_height="wrap_content"
 		android:layout_marginStart="8dp"
@@ -54,7 +54,7 @@ 		android:text="@string/use_online_feed"
 		android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium"
 		app:layout_constraintEnd_toStartOf="@+id/onlineSwitch"
 		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/title" />
+		app:layout_constraintTop_toBottomOf="@+id/feed_name" />
 
 	<com.google.android.material.divider.MaterialDivider
 		android:id="@+id/onlineOfflineDivider"




diff --git a/app/src/main/res/layout/map_bottom_sheet.xml b/app/src/main/res/layout/map_bottom_sheet.xml
deleted file mode 100644
index 24a9d0f75191b3c2fb2a4dec332d713083618041..0000000000000000000000000000000000000000
--- a/app/src/main/res/layout/map_bottom_sheet.xml
+++ /dev/null
@@ -1,215 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-
-<!--
-SPDX-FileCopyrightText: Adam Evyčędo
-
-SPDX-License-Identifier: GPL-3.0-or-later
--->
-
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-	xmlns:app="http://schemas.android.com/apk/res-auto"
-	xmlns:tool="http://schemas.android.com/tools"
-	android:layout_width="match_parent"
-	android:layout_height="wrap_content"
-	android:paddingBottom="16dp">
-
-	<com.google.android.material.bottomsheet.BottomSheetDragHandleView
-		android:id="@+id/drag_handle"
-		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		app:layout_constraintTop_toTopOf="parent" />
-
-
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/title"
-		android:layout_width="0dp"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="48dp"
-		android:layout_marginEnd="8dp"
-		android:textAlignment="center"
-		android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toTopOf="parent" />
-
-	<androidx.constraintlayout.widget.Group
-		android:id="@+id/stop_group"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		app:constraint_referenced_ids="change_options,departures_button,navigation_button" />
-
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/change_options"
-		android:layout_width="0dp"
-		android:layout_height="wrap_content"
-		android:layout_margin="16dp"
-		android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/title" />
-
-	<Button
-		android:id="@+id/departures_button"
-		style="@style/Widget.Material3.Button.TextButton.Icon"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_margin="4dp"
-		android:text="@string/show_departures"
-		app:icon="@drawable/departure"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/change_options" />
-
-	<Button
-		android:id="@+id/navigation_button"
-		style="@style/Widget.Material3.Button.TextButton.Icon"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_margin="4dp"
-		android:text="@string/open_in_maps_app"
-		app:icon="@drawable/open_outside"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/departures_button" />
-
-	<androidx.constraintlayout.widget.Group
-		android:id="@+id/vehicle_group"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		app:constraint_referenced_ids="speed_icon,speed_text,congestion_icon,congestion_text,occupancy_icon,occupancy_text,ac,bike,voice,ticket,usb" />
-
-	<ImageView
-		android:id="@+id/speed_icon"
-		android:layout_width="16dp"
-		android:layout_height="16dp"
-		android:layout_marginEnd="8dp"
-		android:importantForAccessibility="no"
-		app:layout_constraintBottom_toBottomOf="@+id/speed_text"
-		app:layout_constraintEnd_toStartOf="@+id/speed_text"
-		app:layout_constraintTop_toTopOf="@+id/speed_text"
-		app:srcCompat="@drawable/speed" />
-
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/speed_text"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
-		app:layout_constraintEnd_toStartOf="@+id/middle"
-		app:layout_constraintTop_toBottomOf="@id/title"
-		tool:text="10 Vl" />
-
-	<ImageView
-		android:id="@+id/congestion_icon"
-		android:layout_width="16dp"
-		android:layout_height="16dp"
-		android:layout_marginStart="8dp"
-		android:layout_marginEnd="8dp"
-		android:importantForAccessibility="no"
-		app:layout_constraintBottom_toBottomOf="@+id/congestion_text"
-		app:layout_constraintStart_toStartOf="@+id/middle"
-		app:layout_constraintTop_toTopOf="@+id/congestion_text"
-		app:srcCompat="@drawable/traffic" />
-
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/congestion_text"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
-		app:layout_constraintStart_toEndOf="@id/congestion_icon"
-		app:layout_constraintTop_toBottomOf="@id/title"
-		tool:text="smooth traffic" />
-
-	<ImageView
-		android:id="@+id/occupancy_icon"
-		android:layout_width="16dp"
-		android:layout_height="16dp"
-		android:layout_marginStart="8dp"
-		android:layout_marginEnd="8dp"
-		android:importantForAccessibility="no"
-		app:layout_constraintBottom_toBottomOf="@+id/occupancy_text"
-		app:layout_constraintStart_toStartOf="@+id/middle"
-		app:layout_constraintTop_toTopOf="@+id/occupancy_text"
-		app:srcCompat="@drawable/crowd" />
-
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/occupancy_text"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
-		app:layout_constraintStart_toEndOf="@id/occupancy_icon"
-		app:layout_constraintTop_toBottomOf="@id/congestion_text"
-		tool:text="empty vehicle" />
-
-	<androidx.constraintlayout.widget.Guideline
-		android:id="@+id/middle"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:orientation="vertical"
-		app:layout_constraintGuide_percent=".5" />
-
-	<androidx.constraintlayout.helper.widget.Flow
-		android:layout_width="0dp"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="16dp"
-		android:layout_marginEnd="8dp"
-		app:constraint_referenced_ids="ac,bike,voice,ticket,usb"
-		app:flow_horizontalGap="4dp"
-		app:flow_horizontalStyle="packed"
-		app:flow_verticalGap="4dp"
-		app:flow_wrapMode="chain"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/occupancy_text" />
-
-	<ImageView
-		android:id="@+id/ac"
-		android:layout_width="24dp"
-		android:layout_height="24dp"
-		android:contentDescription="@string/air_condition_content_description"
-		app:srcCompat="@drawable/ac"
-		tool:ignore="MissingConstraints" />
-
-	<ImageView
-		android:id="@+id/bike"
-		android:layout_width="24dp"
-		android:layout_height="24dp"
-		android:contentDescription="@string/bicycles_allowed_content_description"
-		app:srcCompat="@drawable/bike"
-		tool:ignore="MissingConstraints" />
-
-	<ImageView
-		android:id="@+id/voice"
-		android:layout_width="24dp"
-		android:layout_height="24dp"
-		android:contentDescription="@string/voice_announcements_content_description"
-		app:srcCompat="@drawable/voice"
-		tool:ignore="MissingConstraints" />
-
-	<ImageView
-		android:id="@+id/ticket"
-		android:layout_width="24dp"
-		android:layout_height="24dp"
-		android:contentDescription="@string/tickets_sold_content_description"
-		app:srcCompat="@drawable/ticket"
-		tool:ignore="MissingConstraints" />
-
-	<ImageView
-		android:id="@+id/usb"
-		android:layout_width="24dp"
-		android:layout_height="24dp"
-		android:contentDescription="@string/usb_charging_content_description"
-		app:srcCompat="@drawable/usb"
-		tool:ignore="MissingConstraints" />
-
-
-</androidx.constraintlayout.widget.ConstraintLayout>




diff --git a/app/src/main/res/layout/stop_bottom_sheet.xml b/app/src/main/res/layout/stop_bottom_sheet.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8b7419d9a9dfa5803e59a1c207e287a826656b2b
--- /dev/null
+++ b/app/src/main/res/layout/stop_bottom_sheet.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+SPDX-FileCopyrightText: Adam Evyčędo
+
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	android:layout_width="match_parent"
+	android:layout_height="wrap_content"
+	android:paddingBottom="16dp">
+
+	<com.google.android.material.bottomsheet.BottomSheetDragHandleView
+		android:id="@+id/drag_handle"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		app:layout_constraintTop_toTopOf="parent" />
+
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/stop_name"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="48dp"
+		android:layout_marginEnd="8dp"
+		android:textAlignment="center"
+		android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/change_options"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_margin="16dp"
+		android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/stop_name" />
+
+	<Button
+		android:id="@+id/departures_button"
+		style="@style/Widget.Material3.Button.TextButton.Icon"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_margin="4dp"
+		android:text="@string/show_departures"
+		app:icon="@drawable/departure"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/change_options" />
+
+	<Button
+		android:id="@+id/navigation_button"
+		style="@style/Widget.Material3.Button.TextButton.Icon"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_margin="4dp"
+		android:text="@string/open_in_maps_app"
+		app:icon="@drawable/open_outside"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/departures_button" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>




diff --git a/app/src/main/res/layout-land/activity_about.xml b/app/src/main/res/layout-land/activity_about.xml
new file mode 100644
index 0000000000000000000000000000000000000000..6e1131b7366a7fa3430448c54fc8f7ad38b78368
--- /dev/null
+++ b/app/src/main/res/layout-land/activity_about.xml
@@ -0,0 +1,135 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+SPDX-FileCopyrightText: Adam Evyčędo
+
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent"
+	tools:context=".AboutActivity">
+
+	<com.google.android.material.imageview.ShapeableImageView
+		android:id="@+id/logo"
+		android:layout_width="100dp"
+		android:layout_height="100dp"
+		android:layout_marginTop="32dp"
+		android:background="@color/bimba_grey"
+		app:layout_constraintEnd_toStartOf="@+id/middle"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"
+		app:shapeAppearanceOverlay="@style/roundedImageView"
+		app:srcCompat="@drawable/ic_launcher_foreground" />
+
+	<TextView
+		android:id="@+id/app_name"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginTop="16dp"
+		android:text="@string/app_name"
+		android:textAppearance="@style/TextAppearance.AppCompat.Display1"
+		app:layout_constraintEnd_toEndOf="@+id/logo"
+		app:layout_constraintStart_toStartOf="@+id/logo"
+		app:layout_constraintTop_toBottomOf="@+id/logo" />
+
+	<TextView
+		android:id="@+id/version"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginTop="8dp"
+		android:text="@string/versionName"
+		app:layout_constraintEnd_toEndOf="@+id/app_name"
+		app:layout_constraintStart_toStartOf="@+id/app_name"
+		app:layout_constraintTop_toBottomOf="@+id/app_name" />
+
+	<TextView
+		android:id="@+id/description"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="48dp"
+		android:layout_marginEnd="8dp"
+		android:text="@string/app_description"
+		android:textAppearance="@style/TextAppearance.AppCompat.Body1"
+		app:layout_constraintEnd_toEndOf="@id/middle"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/app_name" />
+
+	<androidx.constraintlayout.helper.widget.Flow
+		android:id="@+id/links"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="32dp"
+		android:layout_marginEnd="8dp"
+		app:constraint_referenced_ids="website,code,translate,mastodon"
+		app:flow_horizontalGap="16dp"
+		app:flow_horizontalStyle="packed"
+		app:flow_verticalGap="4dp"
+		app:flow_wrapMode="chain"
+		app:layout_constraintEnd_toEndOf="@id/middle"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/description" />
+
+	<Button
+		android:contentDescription="@string/website_button_description"
+		android:id="@+id/website"
+		style="@style/Widget.Material3.Button.IconButton"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		tools:ignore="MissingConstraints"
+		app:icon="@drawable/website" />
+
+	<Button
+		android:contentDescription="@string/code_button_description"
+		android:id="@+id/code"
+		style="@style/Widget.Material3.Button.IconButton"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		tools:ignore="MissingConstraints"
+		app:icon="@drawable/code" />
+
+	<Button
+		android:contentDescription="@string/mastodon_button_description"
+		android:id="@+id/mastodon"
+		style="@style/Widget.Material3.Button.IconButton"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		tools:ignore="MissingConstraints"
+		app:icon="@drawable/mastodon" />
+
+	<Button
+		android:contentDescription="@string/translation_button_description"
+		android:id="@+id/translate"
+		style="@style/Widget.Material3.Button.IconButton"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		tools:ignore="MissingConstraints"
+		app:icon="@drawable/translate" />
+
+	<androidx.constraintlayout.widget.Guideline
+		android:id="@+id/middle"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:orientation="vertical"
+		app:layout_constraintGuide_percent=".5" />
+
+	<TextView
+		android:id="@+id/credits"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="32dp"
+		android:layout_marginEnd="8dp"
+		android:autoLink="web"
+		android:text="@string/credits"
+		android:textAppearance="@style/TextAppearance.AppCompat.Body1"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="@id/middle"
+		app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>




diff --git a/app/src/main/res/menu/departures_menu.xml b/app/src/main/res/menu/departures_menu.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2a4d18f7c839b628af7e21dcae639ed287fb05e5
--- /dev/null
+++ b/app/src/main/res/menu/departures_menu.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<!--
+SPDX-FileCopyrightText: Adam Evyčędo
+
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto">
+	<item
+		android:id="@+id/departures_filter"
+		android:icon="@drawable/filter"
+		app:showAsAction="ifRoom"
+		android:contentDescription="@string/title_filter"
+		android:title="@string/title_filter" >
+		<menu>
+			<item
+				android:id="@+id/departures_filter_byline"
+				app:showAsAction="never"
+				android:contentDescription="@string/title_filter_byline"
+				android:title="@string/title_filter_byline" />
+			<item
+				android:id="@+id/departures_filter_bytime"
+				app:showAsAction="never"
+				android:contentDescription="@string/title_filter_bytime"
+				android:title="@string/title_filter_bytime" />
+		</menu>
+	</item>
+	<item
+		android:id="@+id/departures_calendar"
+		android:icon="@drawable/calendar"
+		app:showAsAction="ifRoom"
+		android:contentDescription="@string/title_select_date"
+		android:title="@string/title_select_date"/>
+</menu>




diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 338d4237c2978af3fe102d073f412fb1d23d9c81..6f5f27b1f333372ff398c544e20ae0630f122041 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -16,6 +16,8 @@ 	#197f00
 	<color name="bimba_orange">#be7e3e</color>
 	<color name="black">#FF000000</color>
 	<color name="white">#FFFFFFFF</color>
+	<color name="safety">#eeD202</color>
+	<color name="link">#0000ff</color>
 
 	<color name="seed">#54af39</color>
 	<color name="md_theme_light_primary">#1A6D00</color> <!-- 40 -->




diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8b0524c6126083cb284dacaa638ca95ba4efe2b3..7c8b6eb99c2812fbdceb7a75eec84c1f2c7bb2c2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -103,7 +103,7 @@ 	» %1$s
 	<string name="credits">Font yellowcircle8 (https://git.apiote.xyz/yellowcircle8.git) based on Railway Sans © Greg Fleming, OFL-1.1 https://github.com/davelab6/Railway-Sans\n\n Mastodon icon (https://github.com/mastodon/joinmastodon) © Mastodon contributors, AGPL-3.0-or-later\n\n Bimba logo created by https://github.com/tebriz159\n\n Material icons © Google, Apache-2.0\n\n Map data © OpenStreetMap contributors, ODbL-1.0</string>
 	<string name="title_about">About</string>
 	<string name="translation_button_description">link to translations service</string>
-	<string name="app_description">FLOSS public transport passenger companion; a timetable in your pocket.</string>
+	<string name="app_description">FLOSS public transport passenger companion; a timetable in your pocket.</string>
 	<string name="website_button_description">link to website</string>
 	<string name="code_button_description">link to source code</string>
 	<string name="mastodon_button_description">link to Mastodon</string>
@@ -114,5 +114,15 @@ 	App version is not compatible with the server
 	<string name="filter_localities">filter localities</string>
 	<string name="error_41">This locality is not supported by the server</string>
 	<string name="stop_from_qr_code">QR code stop</string>
-    <string name="departures_snackbar">Last update: %1$s</string>
-</resources>
\ No newline at end of file
+	<string name="departures_snackbar">Last update: %1$s</string>
+	<string name="title_select_date">Select day of departures</string>
+	<string name="title_select_line">Select line</string>
+	<string name="clear_date_selection">Clear</string>
+	<string name="title_filter">Filter</string>
+	<string name="title_filter_byline">Filter by line</string>
+	<string name="title_filter_bytime">Filter by time</string>
+	<string name="title_select_time_start">Select start time</string>
+	<string name="title_select_time_end">Select end time</string>
+	<string name="more">More</string>
+	<string name="alert_header">Status updates</string>
+</resources>




diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml
index a29ad3303af3a290263427ff3950ee82602572bf..68de08ea5bcbaf161327f182bc53ee5445d4beb6 100644
--- a/app/src/main/res/values-en-rGB/strings.xml
+++ b/app/src/main/res/values-en-rGB/strings.xml
@@ -1,9 +1,14 @@
 <?xml version="1.0" encoding="utf-8"?>
-
 <!--
 SPDX-FileCopyrightText: Adam Evyčędo and contributors using Weblate
 
 SPDX-License-Identifier: GPL-3.0-or-later
--->
-
-<resources></resources>
+--><resources>
+    <string name="app_name">Bimba</string>
+    <string name="title_home">Home</string>
+    <string name="title_map">Map</string>
+    <string name="title_voyage">Journey</string>
+    <string name="title_activity_results">Results</string>
+    <string name="cont">Continue</string>
+    <string name="save">Save</string>
+</resources>
\ No newline at end of file




diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index a5365ed11a1f0e220bc23586d5ac812c8d39d5f5..06a8812c2b57108762c4e9d8f5ff0308501f0996 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -1,120 +1,127 @@
 <?xml version="1.0" encoding="utf-8"?>
-
 <!--
 SPDX-FileCopyrightText: Adam Evyčędo
 
 SPDX-License-Identifier: GPL-3.0-or-later
--->
-
-<resources>
-	<string name="app_name">Bimba</string>
-	<string name="title_home">Casa</string>
-	<string name="title_map">Cartina</string>
-	<string name="title_voyage">Viaggio</string>
-	<string name="home_fab_description">icona GPS</string>
-	<string name="search_placeholder">fermate, linee o codici OLC</string>
-	<string name="title_activity_results">Risultati</string>
-	<string name="cont">Continua</string>
-	<string name="save">Salva</string>
-	<string name="error_400">L’app ha fatto una richiesta malformata</string>
-	<string name="error_401">Un gettone è necessario per usare questo server</string>
-	<string name="error_403">Il gettone è sbagliato</string>
-	<string name="error_404">Non trovato</string>
-	<string name="error_429">Superato il limite di frequenza. Prova di nuovo più tardi</string>
-	<string name="error_50x">C’era un errore sul sever. Prova di nuovo più tardi</string>
-	<string name="error_unknown">Un errrore sconosciutto è successo</string>
-	<string name="error_connecting">Errore di collegamento al server. Prova di nuovo più tardi</string>
-	<string name="error_offline">Sei offline. Collega al’Internet</string>
-	<string name="error_gps">Non è possibile ottenere la posizione corrente</string>
-	<string name="no_departures">Nessune partenze</string>
-	<string name="waiting_position">In attesa della posizione</string>
-	<string name="vehicle_headsign">%1$s » %2$s</string>
-	<string name="vehicle_headsign_content_description">%1$s verso %2$s</string>
-	<string name="speed_in_km_per_h">%1$.3f km/h</string>
-	<string name="congestion_unknown">sconosciuta</string>
-	<string name="congestion_smooth">fluido</string>
-	<string name="congestion_stop_and_go">fermarsi e andare</string>
-	<string name="congestion_congestion">congestione</string>
-	<string name="congestion_jams">ingorghi</string>
-	<string name="occupancy_unknown">sconosciuta</string>
-	<string name="occupancy_empty">vouto</string>
-	<string name="occupancy_many_seats">molte sedie</string>
-	<string name="occupancy_few_seats">pochi sedie</string>
-	<string name="occupancy_standing_only">solo in piedi</string>
-	<string name="occupancy_crowded">affollato</string>
-	<string name="occupancy_full">pieno</string>
-	<string name="occupancy_wont_let">non fa entrare</string>
-	<string name="no_map_app">Nessuna app cartine</string>
-	<string name="departure_headsign">» %1$s</string>
-	<string name="departure_headsign_content_description">verso %1$s</string>
-	<string name="departure_momentarily">presto</string>
-	<string name="departure_departed">partito</string>
-	<string name="departure_now">adesso</string>
-	<string name="at_time">alle %1$02d:%2$02d</string>
-	<string name="at_time_realtime">alle %1$02d:%2$02d:%3$02d</string>
-	<string name="on_demand">su richiesta</string>
-	<string name="no_boarding">nessun imbarco</string>
-	<string name="on_boarding">salire</string>
-	<string name="off_boarding">scendere</string>
-	<string name="boarding">imbarco</string>
-	<string name="line_headsign">» %1$s</string>
-	<string name="line_headsign_content_description">verso %1$s</string>
-	<string name="line_headsigns">%1$s «» %2$s</string>
-	<string name="line_headsigns_content_description">tra %1$s e %2$s</string>
-	<string name="stops_nearby">Fermate vicine</string>
-	<string name="results_for">Risultati per «%1$s»</string>
-	<string name="bimba_server_address_hint">Server</string>
-	<string name="bimba_server_token_hint">Gettone</string>
-	<string name="bimba_server_continue_button">Continua</string>
-	<string name="realtime_content_description">la partenza è in tempo reale</string>
-	<string name="wheelchair_content_description">il veicolo è accessibile alle sedie a rotelle</string>
-	<string name="air_condition_content_description">climatizzazione</string>
-	<string name="bicycles_allowed_content_description">bici permesse</string>
-	<string name="voice_announcements_content_description">avvisi vocali</string>
-	<string name="tickets_sold_content_description">biglietti venduti a bordo</string>
-	<string name="usb_charging_content_description">ricarica USB</string>
-	<string name="show_departures">Mostra le partenze</string>
-	<string name="open_in_maps_app">Apri nell’app cartine</string>
-	<string name="stop_content_description">fermata</string>
-	<string name="seatbelts_everyone">Allacciate le cinture!</string>
-	<string name="onboarding_question">Come comminciamo?</string>
-	<string name="onboarding_simple">Semplicemente</string>
-	<string name="onboarding_simple_action">scegli località</string>
-	<string name="onboarding_advanced">Avanzato</string>
-	<string name="onboarding_advanced_action">scegli server</string>
-	<string name="cancel">Anulla</string>
-	<string name="error">Errore</string>
-	<string name="rate_limit">Limite di frequenza</string>
-	<string name="server_rate_limited_question">Il server è a frequenza limitata e non è stato fornito alcun gettone. Vuoi continuare?</string>
-	<string name="server_private_question">Il server è privato e non è stato fornito alcun gettone</string>
-	<string name="last_update">Aggiornamento più recente: %1$s</string>
-	<string name="title_feeds">Orari</string>
-	<string name="title_servers">Server</string>
-	<string name="title_cities">Località</string>
-	<string name="error_url">URL malformato fornito</string>
-	<string name="error_traffic_spec">Impossibile verificare il server</string>
-	<string name="stops_near_code">Fermate vicino a %1$s</string>
-	<string name="code_is_not_full">Il codice non è pieno</string>
-	<string name="choose_server">Sceglii la varietà del server</string>
-	<string name="ok">OK</string>
-	<string name="no_location_access">Accesso alla posizione non fornito</string>
-	<string name="no_location_message">È necessaria l’autorizzazione all’uso della posizione per trovare le fermate vicine e per mostrare la posizione corrente sulla mappa. Le altre funzioni funzionano anche senza l’autorizzazione. Può essere attivata e disattivata in qualsiasi momento nelle impostazioni di sistema.</string>
-	<string name="stop_stub_on_demand_in_zone">Fermata su richiesta nella zona %1$s</string>
-	<string name="stop_stub_on_demand">Fermata su richiesta</string>
-	<string name="stop_stub_in_zone">Fermata nella zona %1$s</string>
-	<string name="title_about">Che cos\'è</string>
-	<string name="translation_button_description">link al servizio di traduzioni</string>
-	<string name="app_description">Compagno FLOSS di passeggero di trasport pubblico; un orario nella tasca</string>
-	<string name="website_button_description">link al sito web</string>
-	<string name="code_button_description">link al codice sorgente</string>
-	<string name="mastodon_button_description">link a Mastodon</string>
-	<string name="use_online_feed">Usa online</string>
-	<string name="information_may_be_outdated">Le informazioni possono essere obsolete</string>
-	<string name="current_timetable_validity">Orario attuale valido: %1$s fino alla %1$s</string>
-	<string name="error_406">La versione dell’app non è compatibile con il server</string>
-	<string name="filter_localities">filtra le località</string>
-	<string name="error_41">Questa località non è supportata dal server</string>
-	<string name="credits">Font yellowcircle8 (https://git.apiote.xyz/yellowcircle8.git) basato su Railway Sans © Greg Fleming, OFL-1.1 https://github.com/davelab6/Railway-Sans\n\n L’icona Mastodona (https://github.com/mastodon/joinmastodon) © Mastodon contributors, AGPL-3.0-or-later\n\n Logo Bimby stworzone przez https://github.com/tebriz159\n\n Material icons © Google, Apache-2.0\n\n Dane mapy © kontrybutorzy OpenStreetMap, ODbL-1.0</string>
-	<string name="stop_from_qr_code">Stop del codice QR</string>
-	<string name="departures_snackbar">L’ultimo aggiornamento: %1$s</string>
+--><resources>
+    <string name="app_name">Bimba</string>
+    <string name="title_home">Casa</string>
+    <string name="title_map">Cartina</string>
+    <string name="title_voyage">Viaggio</string>
+    <string name="home_fab_description">icona GPS</string>
+    <string name="search_placeholder">fermate, linee o codici OLC</string>
+    <string name="title_activity_results">Risultati</string>
+    <string name="cont">Continua</string>
+    <string name="save">Salva</string>
+    <string name="error_400">L’app ha fatto una richiesta malformata</string>
+    <string name="error_401">Un gettone è necessario per usare questo server</string>
+    <string name="error_403">Il gettone è sbagliato</string>
+    <string name="error_404">Non trovato</string>
+    <string name="error_429">Superato il limite di frequenza. Prova di nuovo più tardi</string>
+    <string name="error_50x">C’era un errore sul sever. Prova di nuovo più tardi</string>
+    <string name="error_unknown">Un errrore sconosciutto è successo</string>
+    <string name="error_connecting">Errore di collegamento al server. Prova di nuovo più tardi</string>
+    <string name="error_offline">Sei offline. Collega al’Internet</string>
+    <string name="error_gps">Non è possibile ottenere la posizione corrente</string>
+    <string name="no_departures">Nessune partenze</string>
+    <string name="waiting_position">In attesa della posizione</string>
+    <string name="vehicle_headsign">%1$s » %2$s</string>
+    <string name="vehicle_headsign_content_description">%1$s verso %2$s</string>
+    <string name="speed_in_km_per_h">%1$.3f km/h</string>
+    <string name="congestion_unknown">sconosciuta</string>
+    <string name="congestion_smooth">fluido</string>
+    <string name="congestion_stop_and_go">fermarsi e andare</string>
+    <string name="congestion_congestion">congestione</string>
+    <string name="congestion_jams">ingorghi</string>
+    <string name="occupancy_unknown">sconosciuta</string>
+    <string name="occupancy_empty">vouto</string>
+    <string name="occupancy_many_seats">molte sedie</string>
+    <string name="occupancy_few_seats">pochi sedie</string>
+    <string name="occupancy_standing_only">solo in piedi</string>
+    <string name="occupancy_crowded">affollato</string>
+    <string name="occupancy_full">pieno</string>
+    <string name="occupancy_wont_let">non fa entrare</string>
+    <string name="no_map_app">Nessuna app cartine</string>
+    <string name="departure_headsign">» %1$s</string>
+    <string name="departure_headsign_content_description">verso %1$s</string>
+    <string name="departure_momentarily">presto</string>
+    <string name="departure_departed">partito</string>
+    <string name="departure_now">adesso</string>
+    <string name="at_time">alle %1$02d:%2$02d</string>
+    <string name="at_time_realtime">alle %1$02d:%2$02d:%3$02d</string>
+    <string name="on_demand">su richiesta</string>
+    <string name="no_boarding">nessun imbarco</string>
+    <string name="on_boarding">salire</string>
+    <string name="off_boarding">scendere</string>
+    <string name="boarding">imbarco</string>
+    <string name="line_headsign">» %1$s</string>
+    <string name="line_headsign_content_description">verso %1$s</string>
+    <string name="line_headsigns">%1$s «» %2$s</string>
+    <string name="line_headsigns_content_description">tra %1$s e %2$s</string>
+    <string name="stops_nearby">Fermate vicine</string>
+    <string name="results_for">Risultati per «%1$s»</string>
+    <string name="bimba_server_address_hint">Server</string>
+    <string name="bimba_server_token_hint">Gettone</string>
+    <string name="bimba_server_continue_button">Continua</string>
+    <string name="realtime_content_description">la partenza è in tempo reale</string>
+    <string name="wheelchair_content_description">il veicolo è accessibile alle sedie a rotelle</string>
+    <string name="air_condition_content_description">climatizzazione</string>
+    <string name="bicycles_allowed_content_description">bici permesse</string>
+    <string name="voice_announcements_content_description">avvisi vocali</string>
+    <string name="tickets_sold_content_description">biglietti venduti a bordo</string>
+    <string name="usb_charging_content_description">ricarica USB</string>
+    <string name="show_departures">Mostra le partenze</string>
+    <string name="open_in_maps_app">Apri nell’app cartine</string>
+    <string name="stop_content_description">fermata</string>
+    <string name="seatbelts_everyone">Allacciate le cinture!</string>
+    <string name="onboarding_question">Come comminciamo?</string>
+    <string name="onboarding_simple">Semplicemente</string>
+    <string name="onboarding_simple_action">scegli località</string>
+    <string name="onboarding_advanced">Avanzato</string>
+    <string name="onboarding_advanced_action">scegli server</string>
+    <string name="cancel">Anulla</string>
+    <string name="error">Errore</string>
+    <string name="rate_limit">Limite di frequenza</string>
+    <string name="server_rate_limited_question">Il server è a frequenza limitata e non è stato fornito alcun gettone. Vuoi continuare?</string>
+    <string name="server_private_question">Il server è privato e non è stato fornito alcun gettone</string>
+    <string name="last_update">Aggiornamento più recente: %1$s</string>
+    <string name="title_feeds">Orari</string>
+    <string name="title_servers">Server</string>
+    <string name="title_cities">Località</string>
+    <string name="error_url">URL malformato fornito</string>
+    <string name="error_traffic_spec">Impossibile verificare il server</string>
+    <string name="stops_near_code">Fermate vicino a %1$s</string>
+    <string name="code_is_not_full">Il codice non è pieno</string>
+    <string name="choose_server">Scegli la varietà del server</string>
+    <string name="ok">OK</string>
+    <string name="no_location_access">Accesso alla posizione non fornito</string>
+    <string name="no_location_message">È necessaria l’autorizzazione all’uso della posizione per trovare le fermate vicine e per mostrare la posizione corrente sulla mappa. Le altre funzioni funzionano anche senza l’autorizzazione. Può essere attivata e disattivata in qualsiasi momento nelle impostazioni di sistema.</string>
+    <string name="stop_stub_on_demand_in_zone">Fermata su richiesta nella zona %1$s</string>
+    <string name="stop_stub_on_demand">Fermata su richiesta</string>
+    <string name="stop_stub_in_zone">Fermata nella zona %1$s</string>
+    <string name="title_about">Che cos\'è</string>
+    <string name="translation_button_description">link al servizio di traduzioni</string>
+    <string name="app_description">Compagno FLOSS di passeggero di trasport pubblico; un orario nella tasca</string>
+    <string name="website_button_description">link al sito web</string>
+    <string name="code_button_description">link al codice sorgente</string>
+    <string name="mastodon_button_description">link a Mastodon</string>
+    <string name="use_online_feed">Usa online</string>
+    <string name="information_may_be_outdated">Le informazioni possono essere obsolete</string>
+    <string name="current_timetable_validity">Orario attuale valido: %1$s fino alla %2$s</string>
+    <string name="error_406">La versione dell’app non è compatibile con il server</string>
+    <string name="filter_localities">filtra le località</string>
+    <string name="error_41">Questa località non è supportata dal server</string>
+    <string name="credits">Font yellowcircle8 (https://git.apiote.xyz/yellowcircle8.git) basato su Railway Sans © Greg Fleming, OFL-1.1 https://github.com/davelab6/Railway-Sans\n\n L’icona Mastodona (https://github.com/mastodon/joinmastodon) © Mastodon contributors, AGPL-3.0-or-later\n\n Logo Bimby stworzone przez https://github.com/tebriz159\n\n Material icons © Google, Apache-2.0\n\n Dane mapy © kontrybutorzy OpenStreetMap, ODbL-1.0</string>
+    <string name="stop_from_qr_code">Stop del codice QR</string>
+    <string name="departures_snackbar">L’ultimo aggiornamento: %1$s</string>
+    <string name="title_select_date">Scegli il giorno della partenza</string>
+    <string name="title_select_line">Scegli la linea</string>
+    <string name="clear_date_selection">Azzera</string>
+    <string name="title_filter">Fìltra</string>
+    <string name="title_filter_byline">Filtra per linee</string>
+    <string name="title_filter_bytime">Filtra per orari</string>
+    <string name="title_select_time_start">Scegli l’inizio</string>
+    <string name="title_select_time_end">Scegli il fine</string>
+    <string name="more">Più</string>
+    <string name="alert_header">Informazioni tempestive</string>
 </resources>
\ No newline at end of file




diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml
index a29ad3303af3a290263427ff3950ee82602572bf..4c87604b47f664e0663bbc07cdf7d3db1016c94b 100644
--- a/app/src/main/res/values-nb-rNO/strings.xml
+++ b/app/src/main/res/values-nb-rNO/strings.xml
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 
 <!--
-SPDX-FileCopyrightText: Adam Evyčędo and contributors using Weblate
+SPDX-FileCopyrightText: Adam Evyčędo
 
 SPDX-License-Identifier: GPL-3.0-or-later
 -->




diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index ce4ad730166f84d7c71819866e2c96ae4393295d..4bcbeaae1cf21379a118eedf1fbe3a10b98db137 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -1,120 +1,127 @@
 <?xml version="1.0" encoding="utf-8"?>
-
 <!--
 SPDX-FileCopyrightText: Adam Evyčędo
 
 SPDX-License-Identifier: GPL-3.0-or-later
--->
-
-<resources>
-	<string name="app_name">Bimba</string>
-	<string name="title_home">Dom</string>
-	<string name="title_map">Mapa</string>
-	<string name="title_voyage">Podróż</string>
-	<string name="home_fab_description">ikona lokalizacji</string>
-	<string name="search_placeholder">przystanki, linie lub kody OLC</string>
-	<string name="title_activity_results">Wyniki</string>
-	<string name="cont">Kontynuuj</string>
-	<string name="save">Zapisz</string>
-	<string name="error_400">Aplikacja wykonała niepoprawne żądanie</string>
-	<string name="error_401">Żeton jest wymagany, aby używać tego serwera</string>
-	<string name="error_403">Żeton jest niepoprawny</string>
-	<string name="error_404">Nie znaleziono</string>
-	<string name="error_429">Przekroczono limit prób. Spróbuj ponownie później</string>
-	<string name="error_50x">Błąd serwera. Spróbuj ponownie później</string>
-	<string name="error_unknown">Wystąpił nieznany błąd</string>
-	<string name="error_connecting">Błąd połączenia z serwerem. Spróbuj ponownie później</string>
-	<string name="error_offline">Brak połączenia z Internetem</string>
-	<string name="error_gps">Nie można uzyskać bierzącej pozycji</string>
-	<string name="no_departures">Brak odjazdów</string>
-	<string name="waiting_position">oczekiwanie na pozycję</string>
-	<string name="vehicle_headsign">%1$s » %2$s</string>
-	<string name="vehicle_headsign_content_description">%1$s w kierunku przystanku %2$s</string>
-	<string name="speed_in_km_per_h">%1$.3f km/h</string>
-	<string name="congestion_unknown">nieznane</string>
-	<string name="congestion_smooth">płynne</string>
-	<string name="congestion_stop_and_go">przestoje</string>
-	<string name="congestion_congestion">korki</string>
-	<string name="congestion_jams">zatory</string>
-	<string name="occupancy_unknown">nieznana</string>
-	<string name="occupancy_empty">pusty</string>
-	<string name="occupancy_many_seats">wiele miejsc</string>
-	<string name="occupancy_few_seats">kilka miejsc</string>
-	<string name="occupancy_standing_only">tylko stojące</string>
-	<string name="occupancy_crowded">zatłoczony</string>
-	<string name="occupancy_full">pełny</string>
-	<string name="occupancy_wont_let">nie wpuszcza</string>
-	<string name="no_map_app">Brak aplikacji map</string>
-	<string name="departure_headsign">» %1$s</string>
-	<string name="departure_headsign_content_description">w kierunku przystanku %1$s</string>
-	<string name="departure_momentarily">za moment</string>
-	<string name="departure_departed">odjechał</string>
-	<string name="departure_now">teraz</string>
-	<string name="at_time">o %1$02d:%2$02d</string>
-	<string name="at_time_realtime">o %1$02d:%2$02d:%3$02d</string>
-	<string name="on_demand">na żądanie</string>
-	<string name="no_boarding">brak</string>
-	<string name="on_boarding">wsiadanie</string>
-	<string name="off_boarding">wysiadanie</string>
-	<string name="boarding">standard</string>
-	<string name="line_headsign">» %1$s</string>
-	<string name="line_headsign_content_description">w kierunku przystanku %1$s</string>
-	<string name="line_headsigns">%1$s «» %2$s</string>
-	<string name="line_headsigns_content_description">pomiędzy przystankami %1$s i %2$s</string>
-	<string name="stops_nearby">Przystanki w pobliżu</string>
-	<string name="results_for">Wyniki dla „%1$s”</string>
-	<string name="bimba_server_address_hint">Serwer</string>
-	<string name="bimba_server_token_hint">Żeton</string>
-	<string name="bimba_server_continue_button">Kontynuuj</string>
-	<string name="realtime_content_description">odjazd w czasie rzeczywistym</string>
-	<string name="wheelchair_content_description">pojazd ma niską podłogę</string>
-	<string name="air_condition_content_description">klimatyzacja</string>
-	<string name="bicycles_allowed_content_description">przewóz rowerów dozwolony</string>
-	<string name="voice_announcements_content_description">komunikaty głosowe</string>
-	<string name="tickets_sold_content_description">możliwość kupienia biletów na pokładzie</string>
-	<string name="usb_charging_content_description">ładowarki USB</string>
-	<string name="show_departures">Pokaż odjazdy</string>
-	<string name="open_in_maps_app">Otwórz w aplikacji map</string>
-	<string name="stop_content_description">przystanek</string>
-	<string name="seatbelts_everyone">Zajmujcie miejsca!</string>
-	<string name="onboarding_question">Jak zaczynamy?</string>
-	<string name="onboarding_simple">Prosto</string>
-	<string name="onboarding_simple_action">wybór lokalizacji</string>
-	<string name="onboarding_advanced">Zaawansowane</string>
-	<string name="onboarding_advanced_action">wybór serwerów</string>
-	<string name="cancel">Anuluj</string>
-	<string name="error">Błąd</string>
-	<string name="rate_limit">Limit żądań</string>
-	<string name="server_rate_limited_question">Nie podano żetona, a serwer limituje żądania. Czy chcesz kontynuować?</string>
-	<string name="server_private_question">Nie podano żetona, a serwer jest prywatny</string>
-	<string name="last_update">Ostatnia aktualizacja: %1$s</string>
-	<string name="title_feeds">Rozkłady</string>
-	<string name="title_servers">Serwery</string>
-	<string name="title_cities">Lokalizacje</string>
-	<string name="error_url">Podano błędny URL</string>
-	<string name="error_traffic_spec">Nie można zweryfikować serwera</string>
-	<string name="stops_near_code">Przystanki w pobliżu %1$s</string>
-	<string name="code_is_not_full">Kod nie jest pełen</string>
-	<string name="choose_server">Wybierz rodzaj serwera</string>
-	<string name="ok">OK</string>
-	<string name="no_location_access">Brak uprawnień do lokalizacji</string>
-	<string name="no_location_message">Uprawnienia do używania lokalizacji są wymagane, aby znaleźć przystanki w pobliżu i pokazać aktualną pozycję na mapie. Pozostałe funkcje będą działały bez tych uprawnień. Mogą być one w każdym momencie nadane i odebrane w ustawieniach systemowych.</string>
-	<string name="stop_stub_on_demand_in_zone">Przystanek na żądanie w strefie %1$s</string>
-	<string name="stop_stub_on_demand">Przystanek na żądanie</string>
-	<string name="stop_stub_in_zone">Przystanek w strefie %1$s</string>
-	<string name="credits">Font yellowcircle8 (https://git.apiote.xyz/yellowcircle8.git) na podstawie Railway Sans © Greg Fleming, OFL-1.1 https://github.com/davelab6/Railway-Sans\n\n Ikona Mastodona (https://github.com/mastodon/joinmastodon) © Mastodon contributors, AGPL-3.0-or-later\n\n Logo Bimby stworzone przez https://github.com/tebriz159\n\n Material icons © Google, Apache-2.0\n\n Dane mapy © kontrybutorzy OpenStreetMap, ODbL-1.0</string>
-	<string name="title_about">O Bimbie</string>
-	<string name="translation_button_description">link do narzędzia do tłumaczeń</string>
-	<string name="app_description">Wolny i otwarty kompan pasażerów transportu publicznego; rozkład jazdy w twojej kieszeni</string>
-	<string name="website_button_description">link do strony internetowej</string>
-	<string name="code_button_description">link do kodu źródłowego</string>
-	<string name="mastodon_button_description">link do Mastodona</string>
-	<string name="use_online_feed">Używaj online</string>
-	<string name="information_may_be_outdated">Informacje mogą być nieaktualne</string>
-	<string name="current_timetable_validity">Aktualny rozkła ważny: %1$s do %2$s</string>
-	<string name="error_406">Wersja aplikacji jest niekompatybilna z serwerem</string>
-	<string name="filter_localities">filtruj lokalizacje</string>
-	<string name="error_41">Ta lokalizacja nie jest obsługiwana przez serwer</string>
-	<string name="stop_from_qr_code">Przystanek z kodem QR</string>
-	<string name="departures_snackbar">Ostatnia aktualizacja: %1$s</string>
+--><resources>
+    <string name="app_name">Bimba</string>
+    <string name="title_home">Dom</string>
+    <string name="title_map">Mapa</string>
+    <string name="title_voyage">Podróż</string>
+    <string name="home_fab_description">ikona lokalizacji</string>
+    <string name="search_placeholder">przystanki, linie lub kody OLC</string>
+    <string name="title_activity_results">Wyniki</string>
+    <string name="cont">Kontynuuj</string>
+    <string name="save">Zapisz</string>
+    <string name="error_400">Aplikacja wykonała niepoprawne żądanie</string>
+    <string name="error_401">Żeton jest wymagany, aby używać tego serwera</string>
+    <string name="error_403">Żeton jest niepoprawny</string>
+    <string name="error_404">Nie znaleziono</string>
+    <string name="error_429">Przekroczono limit prób. Spróbuj ponownie później</string>
+    <string name="error_50x">Błąd serwera. Spróbuj ponownie później</string>
+    <string name="error_unknown">Wystąpił nieznany błąd</string>
+    <string name="error_connecting">Błąd połączenia z serwerem. Spróbuj ponownie później</string>
+    <string name="error_offline">Brak połączenia z Internetem</string>
+    <string name="error_gps">Nie można uzyskać bierzącej pozycji</string>
+    <string name="no_departures">Brak odjazdów</string>
+    <string name="waiting_position">oczekiwanie na pozycję</string>
+    <string name="vehicle_headsign">%1$s » %2$s</string>
+    <string name="vehicle_headsign_content_description">%1$s w kierunku przystanku %2$s</string>
+    <string name="speed_in_km_per_h">%1$.3f km/h</string>
+    <string name="congestion_unknown">nieznane</string>
+    <string name="congestion_smooth">płynne</string>
+    <string name="congestion_stop_and_go">przestoje</string>
+    <string name="congestion_congestion">korki</string>
+    <string name="congestion_jams">zatory</string>
+    <string name="occupancy_unknown">nieznana</string>
+    <string name="occupancy_empty">pusty</string>
+    <string name="occupancy_many_seats">wiele miejsc</string>
+    <string name="occupancy_few_seats">kilka miejsc</string>
+    <string name="occupancy_standing_only">tylko stojące</string>
+    <string name="occupancy_crowded">zatłoczony</string>
+    <string name="occupancy_full">pełny</string>
+    <string name="occupancy_wont_let">nie wpuszcza</string>
+    <string name="no_map_app">Brak aplikacji map</string>
+    <string name="departure_headsign">» %1$s</string>
+    <string name="departure_headsign_content_description">w kierunku przystanku %1$s</string>
+    <string name="departure_momentarily">za moment</string>
+    <string name="departure_departed">odjechał</string>
+    <string name="departure_now">teraz</string>
+    <string name="at_time">o %1$02d:%2$02d</string>
+    <string name="at_time_realtime">o %1$02d:%2$02d:%3$02d</string>
+    <string name="on_demand">na żądanie</string>
+    <string name="no_boarding">brak</string>
+    <string name="on_boarding">wsiadanie</string>
+    <string name="off_boarding">wysiadanie</string>
+    <string name="boarding">standard</string>
+    <string name="line_headsign">» %1$s</string>
+    <string name="line_headsign_content_description">w kierunku przystanku %1$s</string>
+    <string name="line_headsigns">%1$s «» %2$s</string>
+    <string name="line_headsigns_content_description">pomiędzy przystankami %1$s i %2$s</string>
+    <string name="stops_nearby">Przystanki w pobliżu</string>
+    <string name="results_for">Wyniki dla „%1$s”</string>
+    <string name="bimba_server_address_hint">Serwer</string>
+    <string name="bimba_server_token_hint">Żeton</string>
+    <string name="bimba_server_continue_button">Kontynuuj</string>
+    <string name="realtime_content_description">odjazd w czasie rzeczywistym</string>
+    <string name="wheelchair_content_description">pojazd ma niską podłogę</string>
+    <string name="air_condition_content_description">klimatyzacja</string>
+    <string name="bicycles_allowed_content_description">przewóz rowerów dozwolony</string>
+    <string name="voice_announcements_content_description">komunikaty głosowe</string>
+    <string name="tickets_sold_content_description">możliwość kupienia biletów na pokładzie</string>
+    <string name="usb_charging_content_description">ładowarki USB</string>
+    <string name="show_departures">Pokaż odjazdy</string>
+    <string name="open_in_maps_app">Otwórz w aplikacji map</string>
+    <string name="stop_content_description">przystanek</string>
+    <string name="seatbelts_everyone">Zajmujcie miejsca!</string>
+    <string name="onboarding_question">Jak zaczynamy?</string>
+    <string name="onboarding_simple">Prosto</string>
+    <string name="onboarding_simple_action">wybór lokalizacji</string>
+    <string name="onboarding_advanced">Zaawansowane</string>
+    <string name="onboarding_advanced_action">wybór serwerów</string>
+    <string name="cancel">Anuluj</string>
+    <string name="error">Błąd</string>
+    <string name="rate_limit">Limit żądań</string>
+    <string name="server_rate_limited_question">Nie podano żetona, a serwer limituje żądania. Czy chcesz kontynuować?</string>
+    <string name="server_private_question">Nie podano żetona, a serwer jest prywatny</string>
+    <string name="last_update">Ostatnia aktualizacja: %1$s</string>
+    <string name="title_feeds">Rozkłady</string>
+    <string name="title_servers">Serwery</string>
+    <string name="title_cities">Lokalizacje</string>
+    <string name="error_url">Podano błędny URL</string>
+    <string name="error_traffic_spec">Nie można zweryfikować serwera</string>
+    <string name="stops_near_code">Przystanki w pobliżu %1$s</string>
+    <string name="code_is_not_full">Kod nie jest pełen</string>
+    <string name="choose_server">Wybierz rodzaj serwera</string>
+    <string name="ok">OK</string>
+    <string name="no_location_access">Brak uprawnień do lokalizacji</string>
+    <string name="no_location_message">Uprawnienia do używania lokalizacji są wymagane, aby znaleźć przystanki w pobliżu i pokazać aktualną pozycję na mapie. Pozostałe funkcje będą działały bez tych uprawnień. Mogą być one w każdym momencie nadane i odebrane w ustawieniach systemowych.</string>
+    <string name="stop_stub_on_demand_in_zone">Przystanek na żądanie w strefie %1$s</string>
+    <string name="stop_stub_on_demand">Przystanek na żądanie</string>
+    <string name="stop_stub_in_zone">Przystanek w strefie %1$s</string>
+    <string name="credits">Font yellowcircle8 (https://git.apiote.xyz/yellowcircle8.git) na podstawie Railway Sans © Greg Fleming, OFL-1.1 https://github.com/davelab6/Railway-Sans\n\n Ikona Mastodona (https://github.com/mastodon/joinmastodon) © Mastodon contributors, AGPL-3.0-or-later\n\n Logo Bimby stworzone przez https://github.com/tebriz159\n\n Material icons © Google, Apache-2.0\n\n Dane mapy © kontrybutorzy OpenStreetMap, ODbL-1.0</string>
+    <string name="title_about">O Bimbie</string>
+    <string name="translation_button_description">link do narzędzia do tłumaczeń</string>
+    <string name="app_description">Wolny i otwarty kompan pasażerów transportu publicznego; rozkład jazdy w twojej kieszeni</string>
+    <string name="website_button_description">link do strony internetowej</string>
+    <string name="code_button_description">link do kodu źródłowego</string>
+    <string name="mastodon_button_description">link do Mastodona</string>
+    <string name="use_online_feed">Używaj online</string>
+    <string name="information_may_be_outdated">Informacje mogą być nieaktualne</string>
+    <string name="current_timetable_validity">Aktualny rozkład ważny: %1$s do %2$s</string>
+    <string name="error_406">Wersja aplikacji jest niekompatybilna z serwerem</string>
+    <string name="filter_localities">filtruj lokalizacje</string>
+    <string name="error_41">Ta lokalizacja nie jest obsługiwana przez serwer</string>
+    <string name="stop_from_qr_code">Przystanek z kodem QR</string>
+    <string name="departures_snackbar">Ostatnia aktualizacja: %1$s</string>
+    <string name="title_select_date">Wybierz dzień odjazdów</string>
+    <string name="title_select_line">Wybierz linię</string>
+    <string name="clear_date_selection">Wyczyść</string>
+    <string name="title_filter">Filter</string>
+    <string name="title_filter_byline">Filtrowanie po liniach</string>
+    <string name="title_filter_bytime">Filtrowanie po czasie</string>
+    <string name="title_select_time_start">Wybierz początkowy czas</string>
+    <string name="title_select_time_end">Wybierz końcowy czas</string>
+    <string name="more">Więcej</string>
+    <string name="alert_header">Komunikaty</string>
 </resources>
\ No newline at end of file




diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml
deleted file mode 100644
index 7d619e622b1039ed7082483a13c74300c254de7e..0000000000000000000000000000000000000000
--- a/app/src/main/res/xml/locales_config.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-
-<!--
-SPDX-FileCopyrightText: Adam Evyčędo
-
-SPDX-License-Identifier: GPL-3.0-or-later
--->
-
-<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
-	<locale android:name="en" />
-	<locale android:name="pl" />
-	<locale android:name="it" />
-</locale-config>
\ No newline at end of file




diff --git a/app/src/release/java/xyz/apiote/bimba/czwek/api/responses/DevResponses.kt b/app/src/release/java/xyz/apiote/bimba/czwek/api/responses/DevResponses.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8290582612b62aedbdcfdc18846921dd774b2d60
--- /dev/null
+++ b/app/src/release/java/xyz/apiote/bimba/czwek/api/responses/DevResponses.kt
@@ -0,0 +1,69 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.bimba.czwek.api.responses
+
+import xyz.apiote.bimba.czwek.api.AlertV1
+import xyz.apiote.bimba.czwek.api.DepartureV4
+import xyz.apiote.bimba.czwek.api.LineV3
+import xyz.apiote.bimba.czwek.api.ColourV1
+import xyz.apiote.bimba.czwek.api.LineTypeV3
+import xyz.apiote.bimba.czwek.api.LocatableV3
+import xyz.apiote.bimba.czwek.api.QueryableV4
+import xyz.apiote.bimba.czwek.api.StopV2
+import xyz.apiote.bimba.czwek.api.PositionV1
+import xyz.apiote.bimba.czwek.api.UnknownResourceVersionException
+import xyz.apiote.bimba.czwek.api.VehicleV3
+import xyz.apiote.bimba.czwek.api.structs.FeedInfoV2
+import xyz.apiote.fruchtfleisch.Reader
+import java.io.InputStream
+
+data class DeparturesResponseDev(
+	val alerts: List<AlertV1>,
+	val departures: List<DepartureV4>,
+	val stop: StopV2
+) : DeparturesResponse {
+	companion object {
+		private fun unmarshal(stream: InputStream): DeparturesResponseDev {
+			return DeparturesResponseDev(listOf(), listOf(), StopV2("","","","","", PositionV1(0.0, 0.0), listOf()))
+		}
+	}
+}
+
+data class FeedsResponseDev(
+	val feeds: List<FeedInfoV2>
+) : FeedsResponse {
+	companion object {
+		private fun unmarshal(stream: InputStream): FeedsResponseDev {
+			return FeedsResponseDev(listOf())
+		}
+	}
+}
+
+data class LineResponseDev(
+	val line: LineV3
+) : LineResponse {
+	companion object {
+		private fun unmarshal(stream: InputStream): LineResponseDev {
+			return LineResponseDev(LineV3("","",ColourV1(0u,0u,0u),LineTypeV3.UNKNOWN,"",listOf(),listOf()))
+		}
+	}
+}
+
+data class LocatablesResponseDev(val locatables: List<LocatableV3>) : LocatablesResponse {
+	companion object {
+		private fun unmarshal(stream: InputStream): LocatablesResponseDev {
+			return LocatablesResponseDev(listOf())
+		}
+	}
+}
+
+
+data class QueryablesResponseDev(val queryables: List<QueryableV4>) : QueryablesResponse {
+	companion object {
+		private fun unmarshal(stream: InputStream): QueryablesResponseDev {
+			return QueryablesResponseDev(listOf())
+		}
+	}
+}




diff --git a/build.gradle b/build.gradle
index ec17768e78cd5c3819e1d1a71950aed04334e679..279f2c1cfcf841d408996f85e4d1efb1ffa12ef0 100644
--- a/build.gradle
+++ b/build.gradle
@@ -4,14 +4,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 // Top-level build file where you can add configuration options common to all sub-projects/modules.
 plugins {
-    id 'com.android.application' version '8.3.0' apply false
-    id 'com.android.library' version '8.3.0' apply false
+    id 'com.android.application' version '8.3.2' apply false
+    id 'com.android.library' version '8.3.2' apply false
     id 'org.jetbrains.kotlin.android' version '1.7.10' apply false
     id 'org.jetbrains.kotlin.jvm' version '1.7.20' apply false
     id "org.jetbrains.kotlin.plugin.parcelize" version "1.8.20" apply false
     id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' apply false
 }
-
-task clean(type: Delete) {
-    delete rootProject.buildDir
-}
\ No newline at end of file




diff --git a/fruchtfleisch/build.gradle b/fruchtfleisch/build.gradle
index a510340b4c57eef3e6a539c90d057d9dd62b44f1..85c32f76546645efcd429747057f155ce9b51955 100644
--- a/fruchtfleisch/build.gradle
+++ b/fruchtfleisch/build.gradle
@@ -8,9 +8,15 @@     id 'org.jetbrains.kotlin.jvm'
 }
 
 dependencies {
+    testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'
+    testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'
+
     //implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.10'
 }
 java {
     sourceCompatibility = JavaVersion.VERSION_17
     targetCompatibility = JavaVersion.VERSION_17
+}
+test {
+    useJUnitPlatform()
 }
\ No newline at end of file




diff --git a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt
index a336c9f136e40ff0896895110071eb81a23f8ba3..3b29727d880991e2d5a8cd58f7e91bd2e34401f4 100644
--- a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt
+++ b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt
@@ -9,17 +9,10 @@ import java.io.InputStream
 import java.lang.Double.longBitsToDouble
 import java.lang.Float.intBitsToFloat
 
-data class IntVar(private val v: Long) {
-	fun toLong() = v
-}
-data class UIntVar(private val v: ULong) {
-	fun toULong() = v
-}
-
 @Suppress("MemberVisibilityCanBePrivate", "unused", "BooleanMethodIsAlwaysInverted")
 class Reader(private val stream: InputStream) {
 	fun readUInt(): UIntVar {
-		var result: ULong = 0UL
+		var result = 0UL
 		var i = 0
 		var s = 0
 		while (true) {




diff --git a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/UInt.kt b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/UInt.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d058ee473c7bc0bf6bc200dfc5aca6ac8da2ed84
--- /dev/null
+++ b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/UInt.kt
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.fruchtfleisch
+
+data class IntVar(private val v: Long) {
+	fun toLong() = v
+}
+data class UIntVar(private val v: ULong) {
+	fun toULong() = v
+}
\ No newline at end of file




diff --git a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Writer.kt b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Writer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7fe8ebe1fe76d0dbda1f0af4bb65cf8f69e673fe
--- /dev/null
+++ b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Writer.kt
@@ -0,0 +1,35 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.fruchtfleisch
+
+import java.io.OutputStream
+
+@Suppress("MemberVisibilityCanBePrivate")
+class Writer(private val stream: OutputStream) {
+	@OptIn(ExperimentalUnsignedTypes::class)
+	fun writeUInt(v: ULong) {
+		var value = v
+		val bytes = mutableListOf<UByte>()
+		while (value >= 0x80u) {
+			bytes.add(value.toUByte() or 0x80u)
+			value = value.shr(7)
+		}
+		bytes.add(value.toUByte())
+		stream.write(bytes.toUByteArray().toByteArray())
+	}
+
+	fun writeFixedData(v: ByteArray) {
+		stream.write(v)
+	}
+
+	fun writeData(v: ByteArray) {
+		writeUInt(v.size.toULong())
+		writeFixedData(v)
+	}
+
+	fun writeString(v: String) {
+		writeData(v.encodeToByteArray())
+	}
+}
\ No newline at end of file




diff --git a/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/ReaderTest.kt b/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/ReaderTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..210beec41cea3377f8cd050f2091bb4f4c1bb652
--- /dev/null
+++ b/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/ReaderTest.kt
@@ -0,0 +1,96 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.fruchtfleisch
+
+import org.junit.jupiter.api.Test
+
+
+@OptIn(ExperimentalUnsignedTypes::class)
+class ReaderTest {
+	@Test
+	fun readUInt17() {
+		val stream = byteArrayOf(0x11).inputStream()
+		val reader = Reader(stream)
+		assert(reader.readUInt().toULong().toInt() == 17)
+	}
+	@Test
+	fun readUInt23() {
+		val stream = byteArrayOf(0x17).inputStream()
+		val reader = Reader(stream)
+		assert(reader.readUInt().toULong().toInt() == 23)
+	}
+	@Test
+	fun readUInt999() {
+		val stream = ubyteArrayOf(0xe7u, 0x7u).toByteArray().inputStream()
+		val reader = Reader(stream)
+		assert(reader.readUInt().toULong().toInt() == 999)
+	}
+
+	@Test
+	fun readInt() {
+	}
+
+	@Test
+	fun readU8() {
+	}
+
+	@Test
+	fun readU16() {
+	}
+
+	@Test
+	fun readU32() {
+	}
+
+	@Test
+	fun readU64() {
+	}
+
+	@Test
+	fun readI8() {
+	}
+
+	@Test
+	fun readI16() {
+	}
+
+	@Test
+	fun readI32() {
+	}
+
+	@Test
+	fun readI64() {
+	}
+
+	@Test
+	fun readFloat32() {
+	}
+
+	@Test
+	fun readFloat64() {
+	}
+
+	@Test
+	fun readData() {
+	}
+
+	@Test
+	fun readStringAscii() {
+		val stream = byteArrayOf(0x24, 0x4d, 0x72, 0x2e, 0x20, 0x4a, 0x6f, 0x63, 0x6b, 0x2c, 0x20, 0x54, 0x56, 0x20, 0x71, 0x75, 0x69, 0x7a, 0x20, 0x50, 0x68, 0x44, 0x2c, 0x20, 0x62, 0x61, 0x67, 0x73, 0x20, 0x66, 0x65, 0x77, 0x20, 0x6c, 0x79, 0x6e, 0x78).inputStream()
+		val reader = Reader(stream)
+		assert(reader.readString() == "Mr. Jock, TV quiz PhD, bags few lynx")
+	}
+
+	@Test
+	fun readStringUnicode() {
+		val stream = ubyteArrayOf(0x34u, 0x53u, 0x74u, 0x72u, 0xc3u, 0xb3u, 0xc5u, 0xbcu, 0x20u, 0x70u, 0x63u, 0x68u, 0x6eu, 0xc4u, 0x85u, 0xc5u, 0x82u, 0x20u, 0x6bu, 0x6fu, 0xc5u, 0x9bu, 0xc4u, 0x87u, 0x20u, 0x77u, 0x20u, 0x71u, 0x75u, 0x69u, 0x7au, 0x20u, 0x67u, 0xc4u, 0x99u, 0x64u, 0xc5u, 0xbau, 0x62u, 0x20u, 0x76u, 0x65u, 0x6cu, 0x20u, 0x66u, 0x61u, 0x78u, 0x20u, 0x6du, 0x79u, 0x6au, 0xc5u, 0x84u).toByteArray().inputStream()
+		val reader = Reader(stream)
+		assert(reader.readString() == "Stróż pchnął kość w quiz gędźb vel fax myjń")
+	}
+
+	@Test
+	fun readBoolean() {
+	}
+}
\ No newline at end of file




diff --git a/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/WriterTest.kt b/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/WriterTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..cad8ee16bbcd6c8082e51e99b0302636c75913c1
--- /dev/null
+++ b/fruchtfleisch/src/test/java/xyz/apiote/fruchtfleisch/WriterTest.kt
@@ -0,0 +1,48 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.fruchtfleisch
+
+import org.junit.jupiter.api.Test
+import java.io.ByteArrayOutputStream
+
+@OptIn(ExperimentalUnsignedTypes::class)
+class WriterTest {
+
+	@Test
+	fun writeUInt17() {
+		val stream = ByteArrayOutputStream()
+		val writer = Writer(stream)
+		writer.writeUInt(17u)
+		val bytes = stream.toByteArray()
+		assert(bytes.contentEquals(byteArrayOf(0x11)))
+	}
+
+	@Test
+	fun writeUInt23() {
+		val stream = ByteArrayOutputStream()
+		val writer = Writer(stream)
+		writer.writeUInt(23u)
+		val bytes = stream.toByteArray()
+		assert(bytes.contentEquals(byteArrayOf(0x17)))
+	}
+
+	@Test
+	fun writeUInt999() {
+		val stream = ByteArrayOutputStream()
+		val writer = Writer(stream)
+		writer.writeUInt(999u)
+		val bytes = stream.toByteArray().toUByteArray()
+		assert(bytes.contentEquals(ubyteArrayOf(0xe7u, 0x7u)))
+	}
+
+	@Test
+	fun writeStringAscii() {
+		val stream = ByteArrayOutputStream()
+		val writer = Writer(stream)
+		writer.writeString("Mr. Jock, TV quiz PhD, bags few lynx")
+		val bytes = stream.toByteArray()
+		assert(bytes.contentEquals(byteArrayOf(0x24, 0x4d, 0x72, 0x2e, 0x20, 0x4a, 0x6f, 0x63, 0x6b, 0x2c, 0x20, 0x54, 0x56, 0x20, 0x71, 0x75, 0x69, 0x7a, 0x20, 0x50, 0x68, 0x44, 0x2c, 0x20, 0x62, 0x61, 0x67, 0x73, 0x20, 0x66, 0x65, 0x77, 0x20, 0x6c, 0x79, 0x6e, 0x78)))
+	}
+}
\ No newline at end of file




diff --git a/metadata/en-GB/changelogs/25.txt b/metadata/en-GB/changelogs/25.txt
new file mode 100644
index 0000000000000000000000000000000000000000..c8c2609f0a17d73980acfcf8d11fa13d9411c4ee
--- /dev/null
+++ b/metadata/en-GB/changelogs/25.txt
@@ -0,0 +1,11 @@
+Changes in version 3.3
+* Added selecting date and filtering by time
+* Added filtering departures by line
+* Added alerts shown in stop and departures
+* Fixed landscape version of ‘about’ screen
+* Fixed capabilities for vehicles on map
+* Changed cached localities to lower contrast
+* Fixed showing line directions for other than 2
+* Fixed saving localities cache
+* Updated dependencies
+* Updated deprecated methods in code




diff --git a/metadata/en-US/changelogs/25.txt b/metadata/en-US/changelogs/25.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0629d1dd250bc5eacdf49792617e42e57bc0ee02
--- /dev/null
+++ b/metadata/en-US/changelogs/25.txt
@@ -0,0 +1,11 @@
+Changes in version 3.3
+* Added selecting date and filtering by time
+* Added filtering departures by line
+* Added alerts shown in stop and departures
+* Fixed landscape version of “about” screen
+* Fixed capabilities for vehicles on map
+* Changed cached localities to lower contrast
+* Fixed showing line directions for other than 2
+* Fixed saving localities cache
+* Updated dependencies
+* Updated deprecated methods in code




diff --git a/metadata/pl-PL/changelogs/25.txt b/metadata/pl-PL/changelogs/25.txt
new file mode 100644
index 0000000000000000000000000000000000000000..098bde1fbac4f2486da1f3a5b4e78fa9ec92cc9c
--- /dev/null
+++ b/metadata/pl-PL/changelogs/25.txt
@@ -0,0 +1,11 @@
+Zmiany w wersji 3.3
+* Dodano wybieranie daty i filtrowanie po czasie
+* Dodano filtrowanie po linii
+* Dodany alerty przy przystankach i odjazdach
+* Poprawiono horyzontalną wersję ekranu o Bimbie
+* Poprawiono cechy pojazdów na mapie
+* Zmieniono zapisane lokalizacje na mniejszy kontrast
+* Poprawiono kierunki linii dla innej liczby niż 2
+* Poprawiono zapisuwanie lokalizacji
+* Zaktualizowano zależności
+* Zaktualizowano przestarzałe metody w kodzie




diff --git a/release.sh b/release.sh
index 3911f00b6ba62512d5e54885dcbc99f2a73de5be..3d8e82bbf0884ed1f50f229fd1afe6bc67c81556 100755
--- a/release.sh
+++ b/release.sh
@@ -4,13 +4,21 @@ # SPDX-FileCopyrightText: Adam Evyčędo
 #
 # SPDX-License-Identifier: GPL-3.0-or-later
 
+set -e
+
 releaseType=""
 phase=0
 case "$1" in
 	major|minor|patch) releaseType=$1 ;;
 	-c) phase=1 ;;
+	-t) phase=2 ;;
+	-h)
+		echo "release.sh (major|minor|patch)"
+		echo "release.sh (-c|-t)"
+		exit 0
+		;;
 	*)
-		echo "no release type given or -c given"
+		echo "no release type, -c, or -t given"
 		exit 1
 		;;
 esac
@@ -95,9 +103,24 @@ 	fi
 	git add app/build.gradle
 	git add metadata/
 	git commit -S -m "release version $newVersionName ($newVersionCode)"
+	echo 'pushing …'
 	git push
 	git switch master
 	git merge -S --no-ff -m "merge develop into master for version $newVersionName" develop
+	echo 'pushing …'
+	git push
+	echo 'tag and push tag?'
+	read -r yn
+	if [ "$yn" != 'y' ] && [ "$yn" != 'Y' ]; then
+		exit 1
+	fi
+	git tag -s -m "v${newVersionName}" "v${newVersionName}"
+	git push origin --tags
+	git switch develop
+	git merge master
+elif [ $phase -eq 2 ]
+then
+	newVersionName=$(grep 'versionName' app/build.gradle | tr -s ' ' | cut -d ' ' -f3 | tr -d '"')
 	git tag -s -m "v${newVersionName}" "v${newVersionName}"
 	git push origin --tags
 	git switch develop