Bimba.git

commit c6594b28ee7bff7c8c338de68a7fa9fa6209f63f

Author: Adam <git@apiote.xyz>

merge develop into master for version 3.1.0

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


diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 11c7d19ec62053693595e5cbae536762f008e19f..0000000000000000000000000000000000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-language: android
-jdk: oraclejdk8
-sudo: false
-
-android:
-  components:
-    - platform-tools
-    - tools
-    - android-25
-    - build-tools-25.0.3
-    - tools
-    - build-tools-25.0.3
-    - android-25
-    - extra-android-m2repository
-
-install:
-    - yes | $ANDROID_HOME/tools/bin/sdkmanager "tools"
-    - yes | $ANDROID_HOME/tools/bin/sdkmanager "platform-tools"
-
-script: ./gradlew build || (cat /home/travis/build/apiote/Bimba/app/build/reports/lint-results.html; false)




diff --git a/README.adoc b/README.adoc
index afbd4fa417b2548febc28e4c00e9ba7e4970c5bb..73144e3c9057739c1b3998818b9a74b210f90ee4 100644
--- a/README.adoc
+++ b/README.adoc
@@ -1,6 +1,6 @@
 = Bimba
-apiote <me@apiote.xyz>
-v3.0 2023-04-11
+Adam Evyčędo <me@apiote.xyz>
+v3.1 2023-06-27
 :toc:
 
 Bimba is a FLOSS public transport passenger companion; a timetable in your pocket.
@@ -36,7 +36,7 @@
 == License
 
 ----
-Bimba Copyright (c) apiote
+Bimba Copyright (c) Adam Evyčędo
 
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by




diff --git a/app/build.gradle b/app/build.gradle
index ec6159fa7307a50723f1f71c85373d627595572a..472d072832920aa455a25e1685457cdbcc7755f6 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -16,10 +16,11 @@     defaultConfig {
         applicationId "xyz.apiote.bimba.czwek"
         minSdk 21
         targetSdk 33
-        versionCode 21
-        versionName "3.0.1"
+        versionCode 22
+        versionName "3.1.0"
 
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        resourceConfigurations += ["en", "pl", "it"]
     }
 
     buildTypes {
@@ -29,13 +30,10 @@             proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
         }
     }
     compileOptions {
-        sourceCompatibility JavaVersion.VERSION_1_8
-        targetCompatibility JavaVersion.VERSION_1_8
+        sourceCompatibility = 17
+        targetCompatibility = 17
         coreLibraryDesugaringEnabled true
     }
-    kotlinOptions {
-        jvmTarget = '1.8'
-    }
     buildFeatures {
         viewBinding true
     }
@@ -44,22 +42,28 @@     buildToolsVersion '33.0.1'
 }
 
 dependencies {
-    implementation 'androidx.core:core-ktx:1.9.0'
+    implementation 'androidx.core:core-ktx:1.10.1'
     implementation 'androidx.appcompat:appcompat:1.6.1'
-    implementation 'com.google.android.material:material:1.8.0'
+    implementation 'com.google.android.material:material:1.9.0'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
-    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
-    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
-    implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
-    implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
-    implementation 'com.github.mancj:MaterialSearchBar:0.8.5'
+    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
+    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
+    implementation 'androidx.navigation:navigation-fragment-ktx:2.6.0'
+    implementation 'androidx.navigation:navigation-ui-ktx:2.6.0'
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
-    implementation 'androidx.core:core-splashscreen:1.0.0'
+    implementation 'androidx.core:core-splashscreen:1.0.1'
     implementation 'com.google.openlocationcode:openlocationcode:1.0.4'
-    implementation 'org.osmdroid:osmdroid-android:6.1.14'
-    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
+    implementation 'org.osmdroid:osmdroid-android:6.1.16'
     implementation 'org.yaml:snakeyaml:2.0'
+    implementation 'androidx.activity:activity:1.7.2'
+    implementation 'com.google.openlocationcode:openlocationcode:1.0.4'
+    implementation 'com.otaliastudios:zoomlayout:1.9.0'
+    implementation 'dev.bandb.graphview:graphview:0.8.1'
+
     implementation project(path: ':fruchtfleisch')
+
+    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
+
     testImplementation 'junit:junit:4.13.2'
     androidTestImplementation 'androidx.test.ext:junit:1.1.5'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'




diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ba54cbb104e4c1c2a15a22f4172846243d9d6206..7052fb8d22eb570199fb066418e6551e7bb45410 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,31 +8,35 @@ 	
 	<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
 
 	<application
-		android:name="xyz.apiote.bimba.czwek.Bimba"
+		android:name=".Bimba"
 		android:allowBackup="true"
 		android:dataExtractionRules="@xml/data_extraction_rules"
+		android:enableOnBackInvokedCallback="true"
 		android:fullBackupContent="@xml/backup_rules"
 		android:icon="@mipmap/ic_launcher"
 		android:label="@string/app_name"
+		android:localeConfig="@xml/locales_config"
 		android:roundIcon="@mipmap/ic_launcher_round"
 		android:supportsRtl="true"
 		android:theme="@style/Theme.Bimba.Style"
-		tool:targetApi="31">
+		tool:targetApi="33">
+		<activity
+			android:name=".search.LineGraphActivity"
+			android:exported="false"
+			android:theme="@style/Theme.Bimba.Style.NoActionBar" />
 		<activity
-			android:name="xyz.apiote.bimba.czwek.settings.ServerChooserActivity"
+			android:name=".settings.ServerChooserActivity"
 			android:exported="false">
 			<meta-data
 				android:name="android.app.lib_name"
 				android:value="" />
 		</activity>
+		<activity android:name=".onboarding.OnboardingActivity" />
 		<activity
-			android:name="xyz.apiote.bimba.czwek.onboarding.OnboardingActivity">
-		</activity>
-		<activity
-			android:name="xyz.apiote.bimba.czwek.settings.feeds.FeedChooserActivity"
+			android:name=".settings.feeds.FeedChooserActivity"
 			android:exported="false" />
 		<activity
-			android:name="xyz.apiote.bimba.czwek.onboarding.FirstRunActivity"
+			android:name=".onboarding.FirstRunActivity"
 			android:exported="true"
 			android:theme="@style/Theme.Bimba.Splash">
 			<intent-filter>
@@ -42,7 +46,7 @@ 				
 			</intent-filter>
 		</activity>
 		<activity
-			android:name="xyz.apiote.bimba.czwek.departures.DeparturesActivity"
+			android:name=".departures.DeparturesActivity"
 			android:exported="true">
 			<intent-filter>
 				<action android:name="android.intent.action.VIEW" />
@@ -55,13 +59,24 @@ 				
 				<data android:host="www.peka.poznan.pl" />
 				<data android:pathPrefix="/vm" />
 			</intent-filter>
+			<intent-filter>
+				<action android:name="android.intent.action.VIEW" />
+
+				<category android:name="android.intent.category.DEFAULT" />
+				<category android:name="android.intent.category.BROWSABLE" />
+
+				<data android:scheme="http" />
+				<data android:scheme="https" />
+				<data android:host="rj.metropoliaztm.pl" />
+				<data android:pathPrefix="/redir/stop" />
+			</intent-filter>
 		</activity>
 		<activity
-			android:name="xyz.apiote.bimba.czwek.search.ResultsActivity"
+			android:name=".search.ResultsActivity"
 			android:exported="false"
 			android:label="@string/title_activity_results" />
 		<activity
-			android:name="xyz.apiote.bimba.czwek.dashboard.MainActivity"
+			android:name=".dashboard.MainActivity"
 			android:exported="false"
 			android:windowSoftInputMode="adjustPan" />
 	</application>




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt b/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt
index 34a162856469ff0e7d78c1277a3e48375f4e35e4..f32fbfbb49ce718e677036fbd07d223fb01f1f35 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/Bimba.kt
@@ -3,8 +3,6 @@
 import org.osmdroid.config.Configuration
 import java.io.File
 
-// todo [3.1] style
-
 class Bimba : android.app.Application() {
 	override fun onCreate() {
 		super.onCreate()




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 b0ead8725723cdf1579ad1ff8cb9148ffeef32ab..91b191f7296b03dbcedf0726ab8ba89fefd714fd 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
@@ -13,20 +13,22 @@ import java.net.MalformedURLException
 import java.net.URL
 import java.net.URLEncoder
 
-// todo [3.1] constants
-
-// todo [3.1] create Repository between models and api/fs
-// todo [3.1] in Repository check if responses are BARE or HTML
+// todo [3.2] constants
+// todo [3.2] split api files to classes files
 
 data class Server(val host: String, val token: String, val feeds: String, val apiPath: String) {
 	companion object {
 		fun get(context: Context): Server {
 			val preferences = context.getSharedPreferences("shp", MODE_PRIVATE)
+			val apiPath = preferences.getString("apiPath", "")!!
+			val feeds = context.getSharedPreferences(
+				URLEncoder.encode(apiPath, "utf-8"),
+				MODE_PRIVATE
+			).all.filter { it.value as Boolean }.keys.joinToString(",")
 			val host = preferences.getString("host", "bimba.apiote.xyz")!!
 			return Server(
 				host, preferences.getString("token", "")!!,
-				preferences.getString("${host}_feeds", "")!!,
-				preferences.getString("apiPath", "")!!,
+				feeds, apiPath
 			)
 		}
 	}
@@ -63,29 +65,39 @@ 	val params = mutableMapOf("q" to query)
 	if (limit != null) {
 		params["limit"] = limit.toString()
 	}
-	return request(server, "queryables", params, cm, arrayOf(1u))
+	return request(server, "queryables", null, params, cm, arrayOf(1u, 2u), null)
 }
 
 suspend fun locateQueryables(cm: ConnectivityManager, server: Server, near: PositionV1): Result {
-	return request(server, "queryables", mapOf("near" to near.toString()), cm, arrayOf(1u))
+	return request(server, "queryables", null, mapOf("near" to near.toString()), cm, arrayOf(1u, 2u), null)
 }
 
 suspend fun getLocatablesIn(
 	cm: ConnectivityManager, server: Server, bl: PositionV1, tr: PositionV1
 ): Result {
 	return request(
-		server, "locatables", mapOf("lb" to bl.toString(), "rt" to tr.toString()), cm, arrayOf(1u)
+		server,
+		"locatables",
+		null,
+		mapOf("lb" to bl.toString(), "rt" to tr.toString()),
+		cm,
+		arrayOf(1u, 2u),
+		null
 	)
 }
 
+suspend fun getLine(cm: ConnectivityManager, server: Server, feedID: String, line: String): Result {
+	return request(server, "lines", line, mapOf(), cm, arrayOf(1u), feedID)
+}
+
 suspend fun getDepartures(
-	cm: ConnectivityManager, server: Server, stop: String, line: String? = null
+	cm: ConnectivityManager, server: Server, feedID: String, stop: String, line: String? = null
 ): Result {
 	val params = mutableMapOf("code" to stop)
 	if (line != null) {
 		params["line"] = line
 	}
-	return request(server, "departures", params, cm, arrayOf(1u))
+	return request(server, "departures", null, params, cm, arrayOf(1u, 2u), feedID)
 }
 
 suspend fun rawRequest(
@@ -127,13 +139,21 @@
 suspend fun request(
 	server: Server,
 	resource: String,
+	item: String?,
 	params: Map<String, String>,
 	cm: ConnectivityManager,
-	responseVersion: Array<UInt>
+	responseVersion: Array<UInt>,
+	feeds: String?
 ): Result {
 	return withContext(Dispatchers.IO) {
-		val url = URL( // todo [3.1] scheme, host, path, constructed query
-			"${server.apiPath}/${server.feeds}/$resource${
+		val url = URL( // todo [3.2] scheme, host, path, constructed query
+			"${server.apiPath}/${feeds?.ifEmpty { server.feeds } ?: server.feeds}/$resource${
+				if (item == null) {
+					""
+				} else {
+					"/$item"
+				}
+			}${
 				params.map {
 					"${it.key}=${
 						URLEncoder.encode(




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
new file mode 100644
index 0000000000000000000000000000000000000000..17e5533d91f6f911a98be67bc0613f21e7d4541d
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Interfaces.kt
@@ -0,0 +1,6 @@
+package xyz.apiote.bimba.czwek.api
+
+interface QueryableV1
+interface QueryableV2
+interface LocatableV1
+interface LocatableV2
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Responses.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Responses.kt
index e8dd16c9f3c4b61219bd49d49bee82ed08978bd4..4946e573114915e4a9f6bfa963cbf71f2040e447 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/api/Responses.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Responses.kt
@@ -3,23 +3,79 @@
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
 
-class UnknownResponseVersion(val resource: String, val version: ULong) : Exception()
+class UnknownResponseVersion(resource: String, version: ULong) :
+	Exception("Unknown resource $resource in version $version")
+
+interface LineResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): LineResponse {
+			val reader = Reader(stream)
+			return when (val v = reader.readUInt().toULong()) {
+				0UL -> LineResponseDev.unmarshal(stream)
+				1UL -> LineResponseV1.unmarshal(stream)
+				else -> throw UnknownResponseVersion("Line", v)
+			}
+		}
+	}
+}
+
+data class LineResponseDev(
+	val line: LineV1
+) : LineResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): LineResponseDev {
+			return LineResponseDev(LineV1.unmarshal(stream))
+		}
+	}
+}
+
+data class LineResponseV1(
+	val line: LineV1
+) : LineResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): LineResponseV1 {
+			return LineResponseV1(LineV1.unmarshal(stream))
+		}
+	}
+}
 
 interface DeparturesResponse {
 	companion object {
 		fun unmarshal(stream: InputStream): DeparturesResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> {
-					DeparturesResponseDev.unmarshal(stream)
-				}
-				1UL -> {
-					DeparturesResponseV1.unmarshal(stream)
-				}
-				else -> {
-					throw UnknownResponseVersion("Departures", v)
-				}
+				0UL -> DeparturesResponseDev.unmarshal(stream)
+				1UL -> DeparturesResponseV1.unmarshal(stream)
+				2UL -> DeparturesResponseV2.unmarshal(stream)
+				else -> throw UnknownResponseVersion("Departures", v)
+			}
+		}
+	}
+}
+
+data class DeparturesResponseDev(
+	val alerts: List<AlertV1>,
+	val departures: List<DepartureV2>,
+	val stop: StopV2
+) : DeparturesResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): DeparturesResponseDev {
+			val alerts = mutableListOf<AlertV1>()
+			val departures = mutableListOf<DepartureV2>()
+
+			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 = DepartureV2.unmarshal(stream)
+				departures.add(departure)
 			}
+
+			return DeparturesResponseDev(alerts, departures, StopV2.unmarshal(stream))
 		}
 	}
 }
@@ -51,15 +107,15 @@ 		}
 	}
 }
 
-data class DeparturesResponseDev(
+data class DeparturesResponseV2(
 	val alerts: List<AlertV1>,
-	val departures: List<DepartureV1>,
-	val stop: StopV1
+	val departures: List<DepartureV2>,
+	val stop: StopV2
 ) : DeparturesResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): DeparturesResponseDev {
+		fun unmarshal(stream: InputStream): DeparturesResponseV2 {
 			val alerts = mutableListOf<AlertV1>()
-			val departures = mutableListOf<DepartureV1>()
+			val departures = mutableListOf<DepartureV2>()
 
 			val reader = Reader(stream)
 			val alertsNum = reader.readUInt().toULong()
@@ -69,11 +125,11 @@ 				alerts.add(alert)
 			}
 			val departuresNum = reader.readUInt().toULong()
 			for (i in 0UL until departuresNum) {
-				val departure = DepartureV1.unmarshal(stream)
+				val departure = DepartureV2.unmarshal(stream)
 				departures.add(departure)
 			}
 
-			return DeparturesResponseDev(alerts, departures, StopV1.unmarshal(stream))
+			return DeparturesResponseV2(alerts, departures, StopV2.unmarshal(stream))
 		}
 	}
 }
@@ -83,16 +139,37 @@ 	companion object {
 		fun unmarshal(stream: InputStream): QueryablesResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> {
-					QueryablesResponseDev.unmarshal(stream)
-				}
-				1UL -> {
-					QueryablesResponseV1.unmarshal(stream)
-				}
-				else -> {
-					throw UnknownResponseVersion("Queryables", v)
+				0UL -> QueryablesResponseDev.unmarshal(stream)
+				1UL -> QueryablesResponseV1.unmarshal(stream)
+				2UL -> QueryablesResponseV2.unmarshal(stream)
+				else -> throw UnknownResponseVersion("Queryables", v)
+			}
+		}
+	}
+}
+
+data class QueryablesResponseDev(val queryables: List<QueryableV2>) : QueryablesResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): QueryablesResponseDev {
+			val queryables = mutableListOf<QueryableV2>()
+			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(LineV1.unmarshal(stream))
+					}
+
+					else -> {
+						throw UnknownResourceVersionException("Queryable/$r", 1u)
+					}
 				}
 			}
+			return QueryablesResponseDev(queryables)
 		}
 	}
 }
@@ -108,11 +185,9 @@ 				when (val r = reader.readUInt().toULong()) {
 					0UL -> {
 						queryables.add(StopV1.unmarshal(stream))
 					}
-					/*1UL -> {
-						queryables.add(Line.unmarshal(stream))
-					}*/
+
 					else -> {
-						throw UnknownResourceVersion("Queryable/$r", 1u)
+						throw UnknownResourceVersionException("Queryable/$r", 1u)
 					}
 				}
 			}
@@ -121,26 +196,28 @@ 		}
 	}
 }
 
-data class QueryablesResponseDev(val queryables: List<QueryableV1>) : QueryablesResponse {
+data class QueryablesResponseV2(val queryables: List<QueryableV2>) : QueryablesResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): QueryablesResponseDev {
-			val queryables = mutableListOf<QueryableV1>()
+		fun unmarshal(stream: InputStream): QueryablesResponseV2 {
+			val queryables = mutableListOf<QueryableV2>()
 			val reader = Reader(stream)
 			val n = reader.readUInt().toULong()
 			for (i in 0UL until n) {
 				when (val r = reader.readUInt().toULong()) {
 					0UL -> {
-						queryables.add(StopV1.unmarshal(stream))
+						queryables.add(StopV2.unmarshal(stream))
 					}
-					/*1UL -> {
-						queryables.add(Line.unmarshal(stream))
-					}*/
+
+					1UL -> {
+						queryables.add(LineV1.unmarshal(stream))
+					}
+
 					else -> {
-						throw UnknownResourceVersion("Queryable/$r", 1u)
+						throw UnknownResourceVersionException("Queryable/$r", 1u)
 					}
 				}
 			}
-			return QueryablesResponseDev(queryables)
+			return QueryablesResponseV2(queryables)
 		}
 	}
 }
@@ -150,15 +227,9 @@ 	companion object {
 		fun unmarshal(stream: InputStream): FeedsResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> {
-					FeedsResponseDev.unmarshal(stream)
-				}
-				1UL -> {
-					FeedsResponseV1.unmarshal(stream)
-				}
-				else -> {
-					throw UnknownResponseVersion("Feeds", v)
-				}
+				0UL -> FeedsResponseDev.unmarshal(stream)
+				1UL -> FeedsResponseV1.unmarshal(stream)
+				else -> throw UnknownResponseVersion("Feeds", v)
 			}
 		}
 	}
@@ -201,47 +272,45 @@ 	companion object {
 		fun unmarshal(stream: InputStream): LocatablesResponse {
 			val reader = Reader(stream)
 			return when (val v = reader.readUInt().toULong()) {
-				0UL -> {
-					LocatablesResponseDev.unmarshal(stream)
-				}
-				1UL -> {
-					LocatablesResponseV1.unmarshal(stream)
-				}
-				else -> {
-					throw UnknownResponseVersion("Locatables", v)
-				}
+				0UL -> LocatablesResponseDev.unmarshal(stream)
+				1UL -> LocatablesResponseV1.unmarshal(stream)
+				2UL -> LocatablesResponseV2.unmarshal(stream)
+				else -> throw UnknownResponseVersion("Locatables", v)
 			}
 		}
 	}
 }
 
-data class LocatablesResponseV1(val locatables: List<Locatable>) : LocatablesResponse {
+data class LocatablesResponseDev(val locatables: List<LocatableV2>) : LocatablesResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): LocatablesResponseV1 {
-			val locatables = mutableListOf<Locatable>()
+		fun unmarshal(stream: InputStream): LocatablesResponseDev {
+			val locatables = mutableListOf<LocatableV2>()
 			val reader = Reader(stream)
 			val n = reader.readUInt().toULong()
 			for (i in 0UL until n) {
 				when (val r = reader.readUInt().toULong()) {
 					0UL -> {
-						locatables.add(StopV1.unmarshal(stream))
+						locatables.add(StopV2.unmarshal(stream))
 					}
+
 					1UL -> {
-						locatables.add(VehicleV1.unmarshal(stream))
+						locatables.add(VehicleV2.unmarshal(stream))
 					}
+
 					else -> {
-						throw UnknownResourceVersion("Locatable/$r", 1u)
+						throw UnknownResourceVersionException("Locatable/$r", 1u)
 					}
 				}
 			}
-			return LocatablesResponseV1(locatables)
+			return LocatablesResponseDev(locatables)
 		}
 	}
 }
-data class LocatablesResponseDev(val locatables: List<Locatable>) : LocatablesResponse {
+
+data class LocatablesResponseV1(val locatables: List<LocatableV1>) : LocatablesResponse {
 	companion object {
-		fun unmarshal(stream: InputStream): LocatablesResponseDev {
-			val locatables = mutableListOf<Locatable>()
+		fun unmarshal(stream: InputStream): LocatablesResponseV1 {
+			val locatables = mutableListOf<LocatableV1>()
 			val reader = Reader(stream)
 			val n = reader.readUInt().toULong()
 			for (i in 0UL until n) {
@@ -249,15 +318,35 @@ 				when (val r = reader.readUInt().toULong()) {
 					0UL -> {
 						locatables.add(StopV1.unmarshal(stream))
 					}
+
 					1UL -> {
 						locatables.add(VehicleV1.unmarshal(stream))
 					}
+
 					else -> {
-						throw UnknownResourceVersion("Locatable/$r", 1u)
+						throw UnknownResourceVersionException("Locatable/$r", 1u)
 					}
 				}
 			}
-			return LocatablesResponseDev(locatables)
+			return LocatablesResponseV1(locatables)
+		}
+	}
+}
+
+data class LocatablesResponseV2(val locatables: List<LocatableV2>) : LocatablesResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): LocatablesResponseV2 {
+			val locatables = mutableListOf<LocatableV2>()
+			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(VehicleV2.unmarshal(stream))
+					else -> throw UnknownResourceVersionException("Locatable/$r", 1u)
+				}
+			}
+			return LocatablesResponseV2(locatables)
 		}
 	}
 }




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 cf7adc236dc9653c04b5173789855dd53e10d317..25298232b91abb5e997c6d68aab5d87aac285a51 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
@@ -1,36 +1,24 @@
 package xyz.apiote.bimba.czwek.api
 
-import android.content.Context
 import android.graphics.*
-import android.graphics.drawable.BitmapDrawable
-import android.graphics.drawable.Drawable
-import android.graphics.drawable.LayerDrawable
 import android.os.Parcelable
-import android.text.format.DateUtils
 import android.util.Log
-import androidx.appcompat.content.res.AppCompatResources
-import androidx.core.graphics.ColorUtils.HSLToColor
-import androidx.core.graphics.drawable.toBitmap
 import kotlinx.parcelize.Parcelize
 import org.yaml.snakeyaml.Yaml
-import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.dpToPixel
-import xyz.apiote.bimba.czwek.dpToPixelI
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
-import java.time.Instant
-import java.time.ZoneId
 import java.time.ZonedDateTime
 import java.time.format.DateTimeFormatter
 import java.time.format.FormatStyle
-import java.time.temporal.ChronoUnit
 import java.util.*
-import java.util.zip.Adler32
-import kotlin.math.abs
-import kotlin.math.pow
+import kotlin.reflect.KClass
 
 class TrafficFormatException(override val message: String) : IllegalArgumentException()
-class UnknownResourceVersion(val resource: String, val version: ULong) : Exception()
+class UnknownResourceVersionException(resource: String, val version: ULong) :
+	Exception("unknown version $version of $resource")
+
+class UnknownResourceException(resource: String, cls: KClass<*>) :
+	Exception("unknown class $cls for $resource")
 
 data class BimbaInfo(
 	val contact: Map<String, String>,
@@ -108,9 +96,6 @@ @Parcelize
 data class PositionV1(
 	val latitude: Double, val longitude: Double
 ) : Parcelable {
-	fun isZero(): Boolean {
-		return latitude == 0.0 && longitude == 0.0
-	}
 
 	override fun toString(): String = "$latitude,$longitude"
 
@@ -152,22 +137,72 @@ 		)
 	}
 }
 
+enum class AlertCauseV1 {
+	UNKNOWN, OTHER, TECHNICAL_PROBLEM, STRIKE, DEMONSTRATION, ACCIDENT, HOLIDAY, WEATHER, MAINTENANCE,
+	CONSTRUCTION, POLICE_ACTIVITY, MEDICAL_EMERGENCY;
+
+	companion object {
+		fun of(type: UInt): AlertCauseV1 {
+			return when (type) {
+				0u -> valueOf("UNKNOWN")
+				1u -> valueOf("OTHER")
+				2u -> valueOf("TECHNICAL_PROBLEM")
+				3u -> valueOf("STRIKE")
+				4u -> valueOf("DEMONSTRATION")
+				5u -> valueOf("ACCIDENT")
+				6u -> valueOf("HOLIDAY")
+				7u -> valueOf("WEATHER")
+				8u -> valueOf("MAINTENANCE")
+				9u -> valueOf("CONSTRUCTION")
+				10u -> valueOf("POLICE_ACTIVITY")
+				11u -> valueOf("MEDICAL_EMERGENCY")
+				else -> throw UnknownResourceVersionException("AlertCause/$type", 1u)
+			}
+		}
+	}
+}
+
+enum class AlertEffectV1 {
+	UNKNOWN, OTHER, NO_SERVICE, REDUCED_SERVICE, SIGNIFICANT_DELAYS, DETOUR, ADDITIONAL_SERVICE,
+	MODIFIED_SERVICE, STOP_MOVED, NONE, ACCESSIBILITY_ISSUE;
+
+	companion object {
+		fun of(type: UInt): AlertEffectV1 {
+			return when (type) {
+				0u -> valueOf("UNKNOWN")
+				1u -> valueOf("OTHER")
+				2u -> valueOf("NO_SERVICE")
+				3u -> valueOf("REDUCED_SERVICE")
+				4u -> valueOf("SIGNIFICANT_DELAYS")
+				5u -> valueOf("DETOUR")
+				6u -> valueOf("ADDITIONAL_SERVICE")
+				7u -> valueOf("MODIFIED_SERVICE")
+				8u -> valueOf("STOP_MOVED")
+				9u -> valueOf("NONE")
+				10u -> valueOf("ACCESSIBILITY_ISSUE")
+				else -> throw UnknownResourceVersionException("AlertEffect/$type", 1u)
+			}
+		}
+	}
+}
+
 data class AlertV1(
 	val header: String,
 	val Description: String,
 	val Url: String,
-	val Cause: ULong,  // todo [3.1] enum
-	val Effect: ULong  // todo [3.1] enum
+	val Cause: AlertCauseV1,
+	val Effect: AlertEffectV1
 ) {
 	companion object {
 		fun unmarshal(stream: InputStream): AlertV1 {
 			val reader = Reader(stream)
-			val header = reader.readString()
-			val description = reader.readString()
-			val url = reader.readString()
-			val cause = reader.readUInt().toULong()
-			val effect = reader.readUInt().toULong()
-			return AlertV1(header, description, url, cause, effect)
+			return AlertV1(
+				reader.readString(),
+				reader.readString(),
+				reader.readString(),
+				AlertCauseV1.of(reader.readUInt().toULong().toUInt()),
+				AlertEffectV1.of(reader.readUInt().toULong().toUInt())
+			)
 		}
 	}
 }
@@ -198,13 +233,42 @@ 				reader.readU8(), reader.readU8(), reader.readU8()
 			)
 		}
 	}
+}
 
-	fun toInt(): Int {
-		var rgb = 0xff
-		rgb = (rgb shl 8) + R.toInt()
-		rgb = (rgb shl 8) + G.toInt()
-		rgb = (rgb shl 8) + B.toInt()
-		return rgb
+enum class CongestionLevelV1 {
+	UNKNOWN, SMOOTH, STOP_AND_GO, SIGNIFICANT, SEVERE;
+
+	companion object {
+		fun of(type: UInt): CongestionLevelV1 {
+			return when (type) {
+				0u -> valueOf("UNKNOWN")
+				1u -> valueOf("SMOOTH")
+				2u -> valueOf("STOP_AND_GO")
+				3u -> valueOf("SIGNIFICANT")
+				4u -> valueOf("SEVERE")
+				else -> throw UnknownResourceVersionException("CongestionLevel/$type", 1u)
+			}
+		}
+	}
+}
+
+enum class OccupancyStatusV1 {
+	UNKNOWN, EMPTY, MANY_AVAILABLE, FEW_AVAILABLE, STANDING_ONLY, CRUSHED, FULL, NOT_ACCEPTING;
+
+	companion object {
+		fun of(type: UInt): OccupancyStatusV1 {
+			return when (type) {
+				0u -> valueOf("UNKNOWN")
+				1u -> valueOf("EMPTY")
+				2u -> valueOf("MANY_AVAILABLE")
+				3u -> valueOf("FEW_AVAILABLE")
+				4u -> valueOf("STANDING_ONLY")
+				5u -> valueOf("CRUSHED")
+				6u -> valueOf("FULL")
+				7u -> valueOf("NOT_ACCEPTING")
+				else -> throw UnknownResourceVersionException("OccupancyStatus/$type", 1u)
+			}
+		}
 	}
 }
 
@@ -215,49 +279,9 @@ 	val Capabilities: UShort,
 	val Speed: Float,
 	val Line: LineStubV1,
 	val Headsign: String,
-	val CongestionLevel: ULong,
-	val OccupancyStatus: ULong
-) : Locatable {
-	enum class Capability(val bit: UShort) {
-		RAMP(0b0001u), LOW_FLOOR(0b0010u), LOW_ENTRY(0b0001_0000_0000u), AC(0b0100u), BIKE(0b1000u), VOICE(
-			0b0001_0000u
-		),
-		TICKET_MACHINE(0b0010_0000u), TICKET_DRIVER(0b0100_0000u), USB_CHARGING(0b1000_0000u)
-	}
-
-	override fun id(): String = ID
-
-	override fun icon(context: Context, scale: Float): Drawable {
-		return BitmapDrawable(context.resources, Line.icon(context, scale))
-	}
-
-	override fun location(): PositionV1 = Position
-
-	fun congestion(context: Context): String {
-		return when (val r = CongestionLevel.toUInt()) { // todo [3.1] enum
-			0u -> context.getString(R.string.congestion_unknown)
-			1u -> context.getString(R.string.congestion_smooth)
-			2u -> context.getString(R.string.congestion_stop_and_go)
-			3u -> context.getString(R.string.congestion_congestion)
-			4u -> context.getString(R.string.congestion_jams)
-			else -> throw UnknownResourceVersion("Congestion/$r", 1u)
-		}
-	}
-
-	fun occupancy(context: Context): String {
-		return when (val r = OccupancyStatus.toUInt()) { // todo [3.1] enum
-			0u -> context.getString(R.string.occupancy_unknown)
-			1u -> context.getString(R.string.occupancy_empty)
-			2u -> context.getString(R.string.occupancy_many_seats)
-			3u -> context.getString(R.string.occupancy_few_seats)
-			4u -> context.getString(R.string.occupancy_standing_only)
-			5u -> context.getString(R.string.occupancy_crowded)
-			6u -> context.getString(R.string.occupancy_full)
-			7u -> context.getString(R.string.occupancy_wont_let)
-			else -> throw UnknownResourceVersion("Occupancy/$r", 1u)
-		}
-	}
-
+	val CongestionLevel: CongestionLevelV1,
+	val OccupancyStatus: OccupancyStatusV1
+) : LocatableV1 {
 	companion object {
 		fun unmarshal(stream: InputStream): VehicleV1 {
 			val reader = Reader(stream)
@@ -268,20 +292,43 @@ 				reader.readU16(),
 				reader.readFloat32(),
 				LineStubV1.unmarshal(stream),
 				reader.readString(),
-				reader.readUInt().toULong(),
-				reader.readUInt().toULong()
+				CongestionLevelV1.of(reader.readUInt().toULong().toUInt()),
+				OccupancyStatusV1.of(reader.readUInt().toULong().toUInt())
 			)
 		}
 	}
+}
 
-	fun getCapability(field: Capability): Boolean {
-		return Capabilities.and(field.bit) != (0).toUShort()
+data class VehicleV2(
+	val ID: String,
+	val Position: PositionV1,
+	val Capabilities: UShort,
+	val Speed: Float,
+	val Line: LineStubV2,
+	val Headsign: String,
+	val CongestionLevel: CongestionLevelV1,
+	val OccupancyStatus: OccupancyStatusV1
+) : LocatableV2 {
+	companion object {
+		fun unmarshal(stream: InputStream): VehicleV2 {
+			val reader = Reader(stream)
+			return VehicleV2(
+				reader.readString(),
+				PositionV1.unmarshal(stream),
+				reader.readU16(),
+				reader.readFloat32(),
+				LineStubV2.unmarshal(stream),
+				reader.readString(),
+				CongestionLevelV1.of(reader.readUInt().toULong().toUInt()),
+				OccupancyStatusV1.of(reader.readUInt().toULong().toUInt())
+			)
+		}
 	}
 }
 
 data class LineStubV1(
 	val name: String, val kind: LineTypeV1, val colour: ColourV1
-) : LineAbstract {
+) {
 	companion object {
 		fun unmarshal(stream: InputStream): LineStubV1 {
 			val reader = Reader(stream)
@@ -292,9 +339,20 @@ 				ColourV1.unmarshal(stream)
 			)
 		}
 	}
+}
 
-	fun icon(context: Context, scale: Float = 1f): Bitmap {
-		return super.icon(context, kind, colour, scale)
+data class LineStubV2(
+	val name: String, val kind: LineTypeV2, val colour: ColourV1
+) {
+	companion object {
+		fun unmarshal(stream: InputStream): LineStubV2 {
+			val reader = Reader(stream)
+			return LineStubV2(
+				reader.readString(),
+				LineTypeV2.of(reader.readUInt().toULong().toUInt()),
+				ColourV1.unmarshal(stream)
+			)
+		}
 	}
 }
 
@@ -307,47 +365,6 @@ 	val vehicle: VehicleV1,
 	val boarding: UByte
 ) {
 
-	fun statusText(context: Context?): String {
-		val now = 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)
-		return when (val r = status.toUInt()) {
-			0u -> DateUtils.getRelativeTimeSpanString(
-				departureTime.toEpochSecond() * 1000,
-				now.toEpochSecond() * 1000,
-				DateUtils.MINUTE_IN_MILLIS,
-				DateUtils.FORMAT_ABBREV_RELATIVE
-			).toString()
-			1u -> context?.getString(R.string.departure_momentarily) ?: "momentarily"
-			2u -> context?.getString(R.string.departure_now) ?: "now"
-			3u -> context?.getString(R.string.departure_departed) ?: "departed"
-			else -> throw UnknownResourceVersion("VehicleStatus/$r", 1u)
-		}
-	}
-
-	fun timeString(context: Context): String {
-		return if (isRealtime) {
-			context.getString(
-				R.string.at_time_realtime, time.Hour.toInt(), time.Minute.toInt(), time.Second.toInt()
-			)
-		} else {
-			context.getString(R.string.at_time, time.Hour.toInt(), time.Minute.toInt())
-		}
-	}
-
-	fun boardingText(context: Context): String {
-		// todo [3.x] probably should take into account (on|off)-boarding only, on demand
-		return when {
-			boarding == (0b0000_0000).toUByte() -> context.getString(R.string.no_boarding)
-			boarding.and(0b0011_0011u) == (0b0000_0001).toUByte() -> context.getString(R.string.on_boarding)
-			boarding.and(0b0011_0011u) == (0b0001_0000).toUByte() -> context.getString(R.string.off_boarding)
-			boarding.and(0b0011_0011u) == (0b0001_0001).toUByte() -> context.getString(R.string.boarding)
-			else -> context.getString(R.string.on_demand)
-		}
-	}
-
 	companion object {
 		fun unmarshal(stream: InputStream): DepartureV1 {
 			val reader = Reader(stream)
@@ -362,84 +379,74 @@ 		}
 	}
 }
 
-interface QueryableV1
-interface Locatable {
-	fun icon(context: Context, scale: Float = 1f): Drawable
-	fun location(): PositionV1
-	fun id(): String
-}
-
-class ErrorLocatable(val stringResource: Int) : Locatable {
-	override fun icon(context: Context, scale: Float): Drawable {
-		return AppCompatResources.getDrawable(context, R.drawable.error_other)!!
-	}
-
-	override fun location(): PositionV1 {
-		return PositionV1(0.0, 0.0)
-	}
+data class DepartureV2(
+	val ID: String,
+	val time: Time,
+	val status: ULong,
+	val isRealtime: Boolean,
+	val vehicle: VehicleV2,
+	val boarding: UByte
+) {
 
-	override fun id(): String {
-		return "ERROR"
+	companion object {
+		fun unmarshal(stream: InputStream): DepartureV2 {
+			val reader = Reader(stream)
+			val id = reader.readString()
+			val time = Time.unmarshal(stream)
+			val status = reader.readUInt().toULong()
+			val isRealtime = reader.readBoolean()
+			val vehicle = VehicleV2.unmarshal(stream)
+			val boarding = reader.readU8()
+			return DepartureV2(id, time, status, isRealtime, vehicle, boarding)
+		}
 	}
 }
 
 @Parcelize
-data class StopV1(
+data class StopV2(
 	val code: String,
 	val name: String,
+	val nodeName: String,
 	val zone: String,
+	val feedID: String,
 	val position: PositionV1,
 	val changeOptions: List<ChangeOptionV1>
-) : QueryableV1, Locatable, Parcelable {
-
-	override fun icon(context: Context, scale: Float): Drawable {
-		val saturationArray = arrayOf(0.5f, 0.65f, 0.8f)
-		val sal = saturationArray.size
-		val lightnessArray = arrayOf(.5f)
-		val lal = lightnessArray.size
-		val md = Adler32().let {
-			it.update(name.toByteArray())
-			it.value
+) : QueryableV2, Parcelable, LocatableV2 {
+	companion object {
+		fun unmarshal(stream: InputStream): StopV2 {
+			val reader = Reader(stream)
+			val code = reader.readString()
+			val name = reader.readString()
+			val nodeName = reader.readString()
+			val zone = reader.readString()
+			val feedID = reader.readString()
+			val position = PositionV1.unmarshal(stream)
+			val chOptionsNum = reader.readUInt().toULong()
+			val changeOptions = mutableListOf<ChangeOptionV1>()
+			for (i in 0UL until chOptionsNum) {
+				changeOptions.add(ChangeOptionV1.unmarshal(stream))
+			}
+			return StopV2(
+				name = name,
+				nodeName = nodeName,
+				code = code,
+				zone = zone,
+				position = position,
+				feedID = feedID,
+				changeOptions = changeOptions
+			)
 		}
-		val h = md % 359f
-		val s = saturationArray[(md / 360 % sal).toInt()]
-		val l = lightnessArray[(md / 360 / sal % lal).toInt()]
-		val fg = AppCompatResources.getDrawable(context, R.drawable.stop)
-		val bg = AppCompatResources.getDrawable(context, R.drawable.stop_bg)!!.mutate().apply {
-			setTint(HSLToColor(arrayOf(h, s, l).toFloatArray()))
-		}
-		return BitmapDrawable(
-			context.resources,
-			LayerDrawable(arrayOf(bg, fg)).mutate()
-				.toBitmap(dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888)
-		)
 	}
-
-	override fun id(): String = code
+}
 
-	override fun location(): PositionV1 = position
-
-	override fun toString(): String {
-		var result = "$name ($code) [$zone] $position\n"
-		for (chOpt in changeOptions) result += "${chOpt.line} → ${chOpt.headsign}\n"
-		return result
-	}
-
-	fun changeOptions(context: Context): Pair<String, String> {
-		return Pair(changeOptions.groupBy { it.line }
-			.map { Pair(it.key, it.value.joinToString { co -> co.headsign }) }.joinToString {
-				context.getString(
-					R.string.vehicle_headsign, it.first, it.second
-				)
-			},
-			changeOptions.groupBy { it.line }
-				.map { Pair(it.key, it.value.joinToString { co -> co.headsign }) }.joinToString {
-					context.getString(
-						R.string.vehicle_headsign_content_description, it.first, it.second
-					)
-				})
-	}
-
+@Parcelize
+data class StopV1(
+	val code: String,
+	val name: String,
+	val zone: String,
+	val position: PositionV1,
+	val changeOptions: List<ChangeOptionV1>
+) : QueryableV1, Parcelable, LocatableV1 {
 	companion object {
 		fun unmarshal(stream: InputStream): StopV1 {
 			val reader = Reader(stream)
@@ -459,123 +466,46 @@ 		}
 	}
 }
 
-interface LineAbstract {
-	fun textColour(c: ColourV1): Int {
-		val black = relativeLuminance(ColourV1(0u, 0u, 0u)) + .05
-		val white = relativeLuminance(ColourV1(255u, 255u, 255u)) + .05
-		val colour = relativeLuminance(c) + .05
-		return if ((white / colour) > (colour / black)) {
-			Color.WHITE
-		} else {
-			Color.BLACK
-		}
-	}
-
-	private fun relativeLuminance(colour: ColourV1): Double {
-		val r = fromSRGB(colour.R.toDouble() / 0xff)
-		val g = fromSRGB(colour.G.toDouble() / 0xff)
-		val b = fromSRGB(colour.B.toDouble() / 0xff)
-		return 0.2126 * r + 0.7152 * g + 0.0722 * b
-	}
-
-	private fun fromSRGB(part: Double): Double {
-		return if (part <= 0.03928) {
-			part / 12.92
-		} else {
-			((part + 0.055) / 1.055).pow(2.4)
-		}
-	}
-
-	fun icon(context: Context, type: LineTypeV1, colour: ColourV1, scale: Float): Bitmap {
-		val drawingBitmap = Bitmap.createBitmap(
-			dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888
-		)
-		val canvas = Canvas(drawingBitmap)
-
-		canvas.drawPath(getSquirclePath(
-			dpToPixel(.8f / scale), dpToPixel(.8f / scale), dpToPixelI(11.2f / scale)
-		), Paint().apply { color = textColour(colour) })
-		canvas.drawPath(getSquirclePath(
-			dpToPixel(1.6f / scale), dpToPixel(1.6f / scale), dpToPixelI(10.4f / scale)
-		), Paint().apply { color = colour.toInt() })
-
-		val iconID = when (type) {
-			LineTypeV1.BUS -> R.drawable.bus_black
-			LineTypeV1.TRAM -> R.drawable.tram_black
-			LineTypeV1.UNKNOWN -> R.drawable.vehicle_black
-		}
-		val icon = AppCompatResources.getDrawable(context, iconID)?.mutate()?.apply {
-			setTint(textColour(colour))
-		}?.toBitmap(dpToPixelI(19.2f / scale), dpToPixelI(19.2f / scale), Bitmap.Config.ARGB_8888)
-		canvas.drawBitmap(
-			icon!!, dpToPixel(2.4f / scale), dpToPixel(2.4f / scale), Paint()
-		)
-		return drawingBitmap
-	}
-
-	private fun getSquirclePath(
-		left: Float, top: Float, radius: Int
-	): Path {
-		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()
-		)
-		for (x in radius downTo -radius) path.lineTo(
-			x.toFloat(), -Math.cbrt(radiusToPow - abs(x * x * x)).toFloat()
-		)
-		path.close()
-		val matrix = Matrix()
-		matrix.postTranslate((left + radius), (top + radius))
-		path.transform(matrix)
-		return path
-	}
-}
-
-data class Line(
+data class LineV1(
+	val name: String,
 	val colour: ColourV1,
-	val type: LineTypeV1,
-	val headsignsThere: List<String>,
-	val headsignsBack: List<String>,
-	val graphThere: LineGraph,
-	val graphBack: LineGraph,
-	val name: String
-) : QueryableV1, LineAbstract {
+	val type: LineTypeV2,
+	val feedID: String,
+	val headsigns: List<List<String>>,
+	val graphs: List<LineGraph>,
+) : QueryableV2 {
 	override fun toString(): String {
-		return "$name ($type) [$colour]\n→ [${headsignsThere.joinToString()}]\n→ [${headsignsBack.joinToString()}]\n"
-	}
-
-	fun icon(context: Context, scale: Float = 1f): Bitmap {
-		return super.icon(context, type, colour, scale)
+		return "$name ($type) [$colour]\n${headsigns.map { "-> ${it.joinToString()}" }}"
 	}
 
 	companion object {
-		fun unmarshal(stream: InputStream): Line {
+		fun unmarshal(stream: InputStream): LineV1 {
 			val reader = Reader(stream)
+			val name = reader.readString()
 			val colour = ColourV1.unmarshal(stream)
 			val type = reader.readUInt()
-			val headsignsThereNum = reader.readUInt().toULong()
-			val headsignsThere = mutableListOf<String>()
-			for (i in 0UL until headsignsThereNum) {
-				headsignsThere.add(reader.readString())
+			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
 			}
-			val headsignsBackNum = reader.readUInt().toULong()
-			val headsignsBack = mutableListOf<String>()
-			for (i in 0UL until headsignsBackNum) {
-				headsignsBack.add(reader.readString())
+			directionsNum = reader.readUInt().toULong()
+			val graphs = mutableListOf<LineGraph>()
+			for (i in 0UL until directionsNum) {
+				graphs.add(LineGraph.unmarshal(stream))
 			}
-			val graphThere = LineGraph.unmarshal(stream)
-			val graphBack = LineGraph.unmarshal(stream)
-			val name = reader.readString()
-			return Line(
+			return LineV1(
 				name = name,
 				colour = colour,
-				type = LineTypeV1.of(type.toULong().toUInt()),
-				headsignsThere = headsignsThere,
-				headsignsBack = headsignsBack,
-				graphThere = graphThere,
-				graphBack = graphBack
+				type = LineTypeV2.of(type.toULong().toUInt()),
+				feedID = feedID,
+				headsigns = headsigns,
+				graphs = graphs
 			)
 		}
 	}
@@ -590,14 +520,30 @@ 			return when (type) {
 				0u -> valueOf("UNKNOWN")
 				1u -> valueOf("TRAM")
 				2u -> valueOf("BUS")
-				else -> throw UnknownResourceVersion("LineType/$type", 1u)
+				else -> throw UnknownResourceVersionException("LineType/$type", 1u)
+			}
+		}
+	}
+}
+
+enum class LineTypeV2 {
+	UNKNOWN, TRAM, BUS, TROLLEYBUS;
+
+	companion object {
+		fun of(type: UInt): LineTypeV2 {
+			return when (type) {
+				0u -> valueOf("UNKNOWN")
+				1u -> valueOf("TRAM")
+				2u -> valueOf("BUS")
+				3u -> valueOf("TROLLEYBUS")
+				else -> throw UnknownResourceVersionException("LineType/$type", 1u)
 			}
 		}
 	}
 }
 
 @Parcelize
-data class ChangeOptionV1(val line: String, val headsign: String):Parcelable {
+data class ChangeOptionV1(val line: String, val headsign: String) : Parcelable {
 	companion object {
 		fun unmarshal(stream: InputStream): ChangeOptionV1 {
 			val reader = Reader(stream)
@@ -607,9 +553,7 @@ 	}
 }
 
 data class LineGraph(
-	val stops: List<StopStub>,
-	val nextNodes: Map<Long, List<Long>>,
-	val prevNodes: Map<Long, List<Long>>
+	val stops: List<StopStub>, val nextNodes: Map<Long, List<Long>>
 ) {
 	companion object {
 		fun unmarshal(stream: InputStream): LineGraph {
@@ -630,30 +574,22 @@ 					to.add(reader.readInt().toLong())
 				}
 				nextNodes[from] = to
 			}
-			val prevNodesNum = reader.readUInt().toULong()
-			val prevNodes = mutableMapOf<Long, List<Long>>()
-			for (i in 0UL until prevNodesNum) {
-				val from = reader.readInt().toLong()
-				val toNum = reader.readUInt().toULong()
-				val to = mutableListOf<Long>()
-				for (j in 0UL until toNum) {
-					to.add(reader.readInt().toLong())
-				}
-				prevNodes[from] = to
-			}
 
-			return LineGraph(stops = stops, nextNodes = nextNodes, prevNodes = prevNodes)
+			return LineGraph(stops = stops, nextNodes = nextNodes)
 		}
 	}
 }
 
-data class StopStub(val name: String, val code: String, val zone: String, val onDemand: Boolean) {
+data class StopStub(
+	val code: String, val name: String, val nodeName: String, val zone: String, val onDemand: Boolean
+) {
 	companion object {
 		fun unmarshal(stream: InputStream): StopStub {
 			val reader = Reader(stream)
 			return StopStub(
 				code = reader.readString(),
 				name = reader.readString(),
+				nodeName = reader.readString(),
 				zone = reader.readString(),
 				onDemand = reader.readBoolean()
 			)




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 d9948e433a7a53bcfc8b905ad9771c7e84603820..eb7c07ea9d76e95d21a3a00f8807ae024d9113f0 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
@@ -6,28 +6,29 @@ import android.content.pm.PackageManager
 import android.os.Bundle
 import android.view.View
 import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
 import androidx.activity.result.ActivityResultLauncher
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.content.ContextCompat
 import androidx.core.content.edit
 import androidx.core.view.WindowCompat
 import androidx.core.view.get
+import androidx.drawerlayout.widget.DrawerLayout.DrawerListener
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.FragmentManager
 import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks
 import androidx.navigation.fragment.NavHostFragment
 import androidx.navigation.ui.setupWithNavController
 import com.google.android.material.bottomnavigation.BottomNavigationView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.openlocationcode.OpenLocationCode
 import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.Line
-import xyz.apiote.bimba.czwek.api.QueryableV1
-import xyz.apiote.bimba.czwek.api.StopV1
 import xyz.apiote.bimba.czwek.dashboard.ui.home.HomeFragment
 import xyz.apiote.bimba.czwek.dashboard.ui.map.MapFragment
 import xyz.apiote.bimba.czwek.dashboard.ui.voyage.VoyageFragment
 import xyz.apiote.bimba.czwek.databinding.ActivityMainBinding
-import xyz.apiote.bimba.czwek.departures.DeparturesActivity
 import xyz.apiote.bimba.czwek.search.ResultsActivity
 import xyz.apiote.bimba.czwek.settings.ServerChooserActivity
 import xyz.apiote.bimba.czwek.settings.feeds.FeedChooserActivity
@@ -38,6 +39,7 @@ 	private lateinit var binding: ActivityMainBinding
 	private lateinit var locationPermissionRequest: ActivityResultLauncher<Array<String>>
 
 	private lateinit var permissionAsker: Fragment
+	var locationPermissionDialogShown = false
 
 	override fun onCreate(savedInstanceState: Bundle?) {
 		super.onCreate(savedInstanceState)
@@ -59,11 +61,34 @@ 				}
 			}, true
 		)
 
+		val onBackPressedCallback =
+			object : OnBackPressedCallback(binding.container.isDrawerOpen(binding.navigationDrawer)) {
+				override fun handleOnBackPressed() {
+					binding.container.closeDrawer(binding.navigationDrawer)
+				}
+			}
+		onBackPressedDispatcher.addCallback(onBackPressedCallback)
+
+		binding.container.addDrawerListener(object : DrawerListener {
+			override fun onDrawerSlide(drawerView: View, slideOffset: Float) {}
+
+			override fun onDrawerOpened(drawerView: View) {
+				onBackPressedCallback.isEnabled = true
+			}
+
+			override fun onDrawerClosed(drawerView: View) {
+				onBackPressedCallback.isEnabled = false
+			}
+
+			override fun onDrawerStateChanged(newState: Int) {}
+		})
+
 		binding.navigationDrawer.setNavigationItemSelectedListener {
 			when (it.itemId) {
 				R.id.drawer_servers -> {
 					startActivity(Intent(this, ServerChooserActivity::class.java))
 				}
+
 				R.id.drawer_cities -> {
 					startActivity(Intent(this, FeedChooserActivity::class.java))
 				}
@@ -88,31 +113,28 @@ 					when (permissionAsker) {
 						is HomeFragment -> {
 							showResults(ResultsActivity.Mode.MODE_LOCATION)
 						}
+
 						is MapFragment -> {
 							(permissionAsker as MapFragment).showLocation()
 						}
 					}
 				}
+
 				else -> {
-					// todo(ux,ui) dialog
-					Toast.makeText(this, "No location access given", Toast.LENGTH_SHORT).show()
+					if (locationPermissionDialogShown) {
+						return@registerForActivityResult
+					}
+					MaterialAlertDialogBuilder(this).setIcon(AppCompatResources.getDrawable(this, R.drawable.error_gps))
+						.setTitle(getString(R.string.no_location_access))
+						.setMessage(getString(R.string.no_location_message))
+						.setPositiveButton(resources.getString(R.string.ok)) { _, _ ->}
+						.show()
+					locationPermissionDialogShown = true
 				}
 			}
 		}
 	}
 
-	@Suppress(
-		"OVERRIDE_DEPRECATION",
-		"DEPRECATION"
-	)  // fixme later https://developer.android.com/reference/androidx/activity/OnBackPressedDispatcher
-	override fun onBackPressed() {
-		if (binding.container.isDrawerOpen(binding.navigationDrawer)) {
-			binding.container.closeDrawer(binding.navigationDrawer)
-		} else {
-			super.onBackPressed()
-		}
-	}
-
 	fun onNavigationClicked() {
 		if (binding.container.isDrawerOpen(binding.navigationDrawer)) {
 			binding.container.closeDrawer(binding.navigationDrawer)
@@ -131,11 +153,13 @@ 				when (fragment) {
 					is HomeFragment -> {
 						showResults(ResultsActivity.Mode.MODE_LOCATION)
 					}
+
 					is MapFragment -> {
 						fragment.showLocation()
 					}
 				}
 			}
+
 			else -> {
 				permissionAsker = fragment
 				locationPermissionRequest.launch(
@@ -148,27 +172,36 @@ 			}
 		}
 	}
 
-	fun onSuggestionClicked(queryable: QueryableV1) {
-		when (queryable) {
-			is StopV1 -> {
-				val intent = Intent(this, DeparturesActivity::class.java).apply {
-					putExtra("code", queryable.code)
-					putExtra("name", queryable.name)
-				}
-				startActivity(intent)
-			}
-			is Line -> {
-				TODO("[3.1] start line graph activity")
+	fun onSearchClicked(text: CharSequence?) {
+		if (OpenLocationCode.isValidCode(text.toString())) {
+			val olc = OpenLocationCode(text.toString())
+			if (!olc.isFull) {
+				Toast.makeText(this, getString(R.string.code_is_not_full), Toast.LENGTH_LONG).show()
+				return
 			}
+			val area = olc.decode()
+			showResults(olc.code, area.centerLatitude, area.centerLongitude)
+		} else {
+			showResults(ResultsActivity.Mode.MODE_SEARCH, text.toString())
 		}
 	}
 
-	fun onSearchClicked(text: CharSequence?) {
-		showResults(ResultsActivity.Mode.MODE_SEARCH, text.toString())
+	private fun showResults(query: String, centerLatitude: Double, centerLongitude: Double) {
+		/* todo [3.2] (ux,low) animation
+			https://developer.android.com/guide/fragments/animate
+			https://github.com/raheemadamboev/fab-explosion-animation-app
+		*/
+		val intent = Intent(this, ResultsActivity::class.java).apply {
+			putExtra("mode", ResultsActivity.Mode.MODE_POSITION)
+			putExtra("query", query)
+			putExtra("lat", centerLatitude)
+			putExtra("lon", centerLongitude)
+		}
+		startActivity(intent)
 	}
 
 	private fun showResults(mode: ResultsActivity.Mode, query: String = "") {
-		/* todo [3.1] (ux,low) animation
+		/* todo [3.2] (ux,low) animation
 			https://developer.android.com/guide/fragments/animate
 			https://github.com/raheemadamboev/fab-explosion-animation-app
 		*/
@@ -188,12 +221,15 @@ 		when (f) {
 			is HomeFragment -> {
 				binding.bottomNavigation.menu[1].setIcon(R.drawable.home_black)
 			}
+
 			is VoyageFragment -> {
 				binding.bottomNavigation.menu[2].setIcon(R.drawable.voyage_black)
 			}
+
 			is MapFragment -> {
 				binding.bottomNavigation.menu[0].setIcon(R.drawable.map_black)
 			}
+
 			else -> {
 				binding.bottomNavigation.menu[1].setIcon(R.drawable.home_black)
 			}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt
index 816339a9a11fdffc30fa1df3a6b2de20c054aefc..4c35c12f9aa56f10dc394cc10fb670ba153b3676 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt
@@ -3,28 +3,28 @@
 import android.content.Context
 import android.net.ConnectivityManager
 import android.os.Bundle
+import android.view.KeyEvent
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.activity.OnBackPressedCallback
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
-import com.mancj.materialsearchbar.MaterialSearchBar
-import com.mancj.materialsearchbar.MaterialSearchBar.BUTTON_NAVIGATION
-import xyz.apiote.bimba.czwek.api.QueryableV1
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.search.SearchView
 import xyz.apiote.bimba.czwek.dashboard.MainActivity
 import xyz.apiote.bimba.czwek.databinding.FragmentHomeBinding
-import xyz.apiote.bimba.czwek.search.BimbaSuggestionsAdapter
-
-// todo [3.1] search: https://github.com/material-components/material-components-android/blob/master/docs/components/Search.md
+import xyz.apiote.bimba.czwek.search.BimbaResultsAdapter
 
 class HomeFragment : Fragment() {
 	private var _binding: FragmentHomeBinding? = null
 	private val binding get() = _binding!!
 
-	private var lastSuggestions = listOf<QueryableV1>()
+	private lateinit var adapter: BimbaResultsAdapter
 
 	override fun onCreateView(
 		inflater: LayoutInflater,
@@ -36,7 +36,7 @@
 		val homeViewModel =
 			ViewModelProvider(this)[HomeViewModel::class.java]
 		homeViewModel.queryables.observe(viewLifecycleOwner) {
-			binding.searchBar.updateLastSuggestions(it)
+			adapter.update(it)
 		}
 
 		val root = binding.root
@@ -48,39 +48,53 @@ 			}
 			WindowInsetsCompat.CONSUMED
 		}
 
-		binding.searchBar.lastSuggestions = lastSuggestions
+		val onBackPressedCallback = object :
+			OnBackPressedCallback(binding.searchView.currentTransitionState == SearchView.TransitionState.SHOWN) {
+			override fun handleOnBackPressed() {
+				binding.searchView.hide()
+			}
+		}
+		activity?.onBackPressedDispatcher?.addCallback(onBackPressedCallback)
+		binding.searchView.addTransitionListener { _, _, newState ->
+			onBackPressedCallback.isEnabled = when (newState) {
+				SearchView.TransitionState.SHOWN -> true
+				SearchView.TransitionState.HIDDEN -> false
+				else -> false
+			}
+		}
+
+		binding.searchBar.setNavigationOnClickListener {
+			(context as MainActivity).onNavigationClicked()
+		}
+		binding.suggestionsRecycler.layoutManager = LinearLayoutManager(activity)
+		adapter = BimbaResultsAdapter(layoutInflater, activity, listOf())
+		binding.suggestionsRecycler.adapter = adapter
+
 		val cm = requireContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
-		binding.searchBar.addTextChangeListener(
-			homeViewModel.SearchBarWatcher(requireContext(), cm)
+		binding.searchView.editText.addTextChangedListener(
+			homeViewModel.SearchBarWatcher(
+				requireContext(),
+				cm
+			)
 		)
-		binding.searchBar.setOnSearchActionListener(object : MaterialSearchBar.OnSearchActionListener {
-			override fun onButtonClicked(buttonCode: Int) {
-				when (buttonCode) {
-					BUTTON_NAVIGATION -> {
-						(context as MainActivity).onNavigationClicked()
+		binding.searchView.editText.setOnKeyListener { v, keyCode, event ->
+			when (keyCode) {
+				KeyEvent.KEYCODE_ENTER -> {
+					if (event.action == KeyEvent.ACTION_UP) {
+						(context as MainActivity).onSearchClicked((v as TextView).text)
+						true
+					} else {
+						false
 					}
 				}
-			}
 
-			override fun onSearchStateChanged(enabled: Boolean) {
+				else -> false
 			}
-
-			override fun onSearchConfirmed(text: CharSequence?) {
-				binding.searchBar.clearSuggestions()
-				(context as MainActivity).onSearchClicked(text)
-			}
-		})
-		binding.searchBar.setCardViewElevation(0)
-		binding.searchBar.setCustomSuggestionAdapter(BimbaSuggestionsAdapter(layoutInflater, context) {
-			binding.searchBar.clearSuggestions()
-			(context as MainActivity).onSuggestionClicked(it)
-		})
+		}
 
 		binding.floatingActionButton.setOnClickListener {
-			binding.searchBar.clearSuggestions()
 			(context as MainActivity).onGpsClicked(this)
 		}
-		// todo [3.1] (ux,low) on searchbar focus && if != '' -> populate suggestions
 
 		return binding.root
 	}




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 72f6efd2bb9b254905f1172adef1f9ac98a07ba0..4120a5f52b1467770aa4e7fee92f54bbe6c6d5d7 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
@@ -12,55 +12,29 @@ import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import kotlinx.coroutines.launch
-import xyz.apiote.bimba.czwek.api.*
+import xyz.apiote.bimba.czwek.repo.TrafficResponseException
+import xyz.apiote.bimba.czwek.repo.OnlineRepository
+import xyz.apiote.bimba.czwek.repo.Queryable
 
 class HomeViewModel : ViewModel() {
-	private val mutableQueryables = MutableLiveData<List<QueryableV1>>()
-	val queryables: LiveData<List<QueryableV1>> = mutableQueryables
+	private val mutableQueryables = MutableLiveData<List<Queryable>>()
+	val queryables: LiveData<List<Queryable>> = mutableQueryables
 
-	fun getQueryables(cm: ConnectivityManager, server: Server, query: String) {
+	fun getQueryables(cm: ConnectivityManager, query: String, context: Context) {
 		viewModelScope.launch {
-			val result = queryQueryables(cm, server, query, limit = 6)
-			if (result.error != null) {
-				// note intentionally no error showing in suggestions
-				if (result.stream != null) {
-					Log.e("Suggestion", "${b2s(result.stream.readBytes())}")
-					return@launch
-					Log.e(
-						"Suggestion",
-						"${result.error.statusCode}, ${ErrorResponse.unmarshal(result.stream).message}"
-					)
-				} else {
-					Log.e("Suggestion", "${result.error.statusCode}")
-				}
-			} else {
-				mutableQueryables.value =
-					when (val response = QueryablesResponse.unmarshal(result.stream!!)) {
-						is QueryablesResponseDev -> response.queryables
-						is QueryablesResponseV1 -> response.queryables
-						else -> null
-					}
-			}
-		}
-	}
-
-	private fun b2s(b: ByteArray): String {
-		var s = ""
-		b.forEach {
-			if (it in 32..127) {
-				s += Char(it.toInt())
-			} else {
-				s += "\\x$it"
+			try {
+				val repository = OnlineRepository()
+				mutableQueryables.value = repository.queryQueryables(cm, query, context) ?: emptyList()
+			} catch (e: TrafficResponseException) {
+				// xxx intentionally no error showing in suggestions
+				Log.e("Suggestion", "$e")
 			}
 		}
-		return s
 	}
 
 	inner class SearchBarWatcher(
-		private val context: Context,
-		private val cm: ConnectivityManager
-	) :
-		TextWatcher {
+		private val context: Context, private val cm: ConnectivityManager
+	) : TextWatcher {
 		private val handler = Handler(Looper.getMainLooper())
 		private var workRunnable = Runnable {}
 
@@ -74,15 +48,9 @@ 		override fun afterTextChanged(s: Editable?) {
 			handler.removeCallbacks(workRunnable)
 			workRunnable = Runnable {
 				val text = s.toString()
-				getQueryables(
-					cm,
-					Server.get(context), text
-				)
+				getQueryables(cm, text, context)
 			}
-			handler.postDelayed(
-				workRunnable,
-				750
-			) // todo(ux,low) make good time (probably between 500, 1000ms)
+			handler.postDelayed(workRunnable, 750)
 		}
 	}
 }
\ No newline at end of file




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 b688de9e98fd5a9be8c9e142a98faa029b3c5ca8..b3bab9bfc53bcb305783f44410b49de895f51adb 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
@@ -19,13 +19,6 @@ import androidx.core.graphics.drawable.toBitmap
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
 import com.google.android.material.snackbar.Snackbar
-import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.ErrorLocatable
-import xyz.apiote.bimba.czwek.api.PositionV1
-import xyz.apiote.bimba.czwek.api.Server
-import xyz.apiote.bimba.czwek.dashboard.MainActivity
-import xyz.apiote.bimba.czwek.databinding.FragmentMapBinding
-import xyz.apiote.bimba.czwek.dpToPixelI
 import org.osmdroid.config.Configuration
 import org.osmdroid.events.MapListener
 import org.osmdroid.events.ScrollEvent
@@ -38,8 +31,12 @@ import org.osmdroid.views.overlay.TilesOverlay
 import org.osmdroid.views.overlay.gestures.RotationGestureOverlay
 import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider
 import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
-
-// todo[3.1] empty state on no network
+import xyz.apiote.bimba.czwek.R
+import xyz.apiote.bimba.czwek.dashboard.MainActivity
+import xyz.apiote.bimba.czwek.databinding.FragmentMapBinding
+import xyz.apiote.bimba.czwek.dpToPixelI
+import xyz.apiote.bimba.czwek.repo.ErrorLocatable
+import xyz.apiote.bimba.czwek.repo.Position
 
 class MapFragment : Fragment() {
 
@@ -137,7 +134,8 @@ 				marker is Marker
 			}
 
 			if (it.size == 1 && it[0] is ErrorLocatable) {
-				Snackbar.make(binding.root, (it[0] as ErrorLocatable).stringResource, Snackbar.LENGTH_LONG).show()
+				Snackbar.make(binding.root, (it[0] as ErrorLocatable).stringResource, Snackbar.LENGTH_LONG)
+					.show()
 				return@observe
 			}
 
@@ -183,16 +181,13 @@ 	private fun getLocatables() {
 		maybeBinding?.let { binding ->
 			val (bl, tr) = binding.map.boundingBox.let {
 				Pair(
-					PositionV1(it.latSouth, it.lonWest),
-					PositionV1(it.latNorth, it.lonEast)
+					Position(it.latSouth, it.lonWest),
+					Position(it.latNorth, it.lonEast)
 				)
 			}
 			context?.let {
 				val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
-				mapViewModel.getLocatablesIn(
-					cm,
-					Server.get(it), bl, tr
-				)
+				mapViewModel.getLocatablesIn(cm, 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 3a22298fe73acead5ecb23f43b9c171fe5c6deee..bacd215fbb6a60598adbcc17ffb0d8696b05c5d0 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
@@ -1,6 +1,7 @@
 package xyz.apiote.bimba.czwek.dashboard.ui.map
 
 import android.content.ActivityNotFoundException
+import android.content.Context
 import android.content.Intent
 import android.net.ConnectivityManager
 import android.net.Uri
@@ -20,33 +21,27 @@ import androidx.lifecycle.viewModelScope
 import com.google.android.material.bottomsheet.BottomSheetDialogFragment
 import kotlinx.coroutines.launch
 import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.*
 import xyz.apiote.bimba.czwek.departures.DeparturesActivity
+import xyz.apiote.bimba.czwek.repo.TrafficResponseException
+import xyz.apiote.bimba.czwek.repo.Locatable
+import xyz.apiote.bimba.czwek.repo.OnlineRepository
+import xyz.apiote.bimba.czwek.repo.Position
+import xyz.apiote.bimba.czwek.repo.Stop
+import xyz.apiote.bimba.czwek.repo.Vehicle
 
 class MapViewModel : ViewModel() {
 
 	private val _locatables = MutableLiveData<List<Locatable>>()
 	val locatables: MutableLiveData<List<Locatable>> = _locatables
 
-	fun getLocatablesIn(cm: ConnectivityManager, server: Server, bl: PositionV1, tr: PositionV1) {
+	fun getLocatablesIn(cm: ConnectivityManager, bl: Position, tr: Position, context: Context) {
 		viewModelScope.launch {
-			val result = xyz.apiote.bimba.czwek.api.getLocatablesIn(cm, server, bl, tr)
-			if (result.error != null) {
-				_locatables.value = listOf(ErrorLocatable(result.error.stringResource))
-				if (result.stream != null) {
-					Log.w(
-						"Map",
-						"${result.error.statusCode}, ${ErrorResponse.unmarshal(result.stream).message}"
-					)
-				} else {
-					Log.w("Map", "${result.error.statusCode}")
-				}
-				return@launch
-			} else {
-				_locatables.value = when (val response = LocatablesResponse.unmarshal(result.stream!!)) {
-					is LocatablesResponseDev -> response.locatables
-					is LocatablesResponseV1 -> response.locatables
-					else -> null
+			viewModelScope.launch {
+				try {
+					val repository = OnlineRepository()
+					_locatables.value = repository.getLocatablesIn(cm, bl, tr, context) ?: emptyList()
+				} catch (e: TrafficResponseException) {
+					Log.w("Map", "$e")
 				}
 			}
 		}
@@ -58,7 +53,7 @@ 	companion object {
 		const val TAG = "MapBottomSheet"
 	}
 
-	private fun showVehicle(content: View, vehicle: VehicleV1) {
+	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
 
@@ -71,39 +66,39 @@ 					vehicle.Line.name,
 					vehicle.Headsign
 				)
 			}
-			// todo units -- [3.1] settings or system-based
+			// 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<TextView>(R.id.congestion_text).text = vehicle.congestion(ctx)
 			content.findViewById<TextView>(R.id.occupancy_text).text = vehicle.occupancy(ctx)
 			content.findViewById<ImageView>(R.id.ac).visibility =
-				if (vehicle.getCapability(VehicleV1.Capability.AC)) {
+				if (vehicle.getCapability(Vehicle.Capability.AC)) {
 					View.VISIBLE
 				} else {
 					View.GONE
 				}
 			content.findViewById<ImageView>(R.id.bike).visibility =
-				if (vehicle.getCapability(VehicleV1.Capability.BIKE)) {
+				if (vehicle.getCapability(Vehicle.Capability.BIKE)) {
 					View.VISIBLE
 				} else {
 					View.GONE
 				}
 			content.findViewById<ImageView>(R.id.voice).visibility =
-				if (vehicle.getCapability(VehicleV1.Capability.VOICE)) {
+				if (vehicle.getCapability(Vehicle.Capability.VOICE)) {
 					View.VISIBLE
 				} else {
 					View.GONE
 				}
 			content.findViewById<ImageView>(R.id.ticket).visibility =
 				if (vehicle.let {
-						it.getCapability(VehicleV1.Capability.TICKET_DRIVER) || it.getCapability(VehicleV1.Capability.TICKET_MACHINE)
+						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(VehicleV1.Capability.USB_CHARGING)) {
+				if (vehicle.getCapability(Vehicle.Capability.USB_CHARGING)) {
 					View.VISIBLE
 				} else {
 					View.GONE
@@ -111,16 +106,16 @@ 				}
 		}
 	}
 
-	private fun showStop(content: View, stop: StopV1) {
+	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 =
-				context?.getString(R.string.stop_title, stop.name, stop.code)
+			content.findViewById<TextView>(R.id.title).text = stop.name
 			content.findViewById<Button>(R.id.departures_button).setOnClickListener {
 				val intent = Intent(ctx, DeparturesActivity::class.java).apply {
 					putExtra("code", stop.code)
 					putExtra("name", stop.name)
+					putExtra("feedID", stop.feedID)
 				}
 				startActivity(intent)
 			}
@@ -155,10 +150,11 @@ 	): View {
 		val content = inflater.inflate(R.layout.map_bottom_sheet, container, false)
 		content.apply {
 			when (locatable) {
-				is VehicleV1 -> {
+				is Vehicle -> {
 					showVehicle(this, locatable)
 				}
-				is StopV1 -> {
+
+				is Stop -> {
 					showStop(this, locatable)
 				}
 			}




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 35f871c5fc86be2a7acbee7525b3d45496e97c2e..2b1f5c69ea8581714518c6abb9d3bf7a56ff6f99 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
@@ -13,10 +13,6 @@ import android.widget.TextView
 import androidx.recyclerview.widget.RecyclerView
 import com.google.android.material.bottomsheet.BottomSheetDialog
 import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.DepartureV1
-import xyz.apiote.bimba.czwek.api.VehicleV1
-import xyz.apiote.bimba.czwek.dpToPixelI
 import org.osmdroid.tileprovider.tilesource.TileSourceFactory
 import org.osmdroid.util.GeoPoint
 import org.osmdroid.views.CustomZoomButtonsController
@@ -24,6 +20,10 @@ import org.osmdroid.views.MapView
 import org.osmdroid.views.overlay.Marker
 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.Departure
+import xyz.apiote.bimba.czwek.repo.Vehicle
 import java.util.*
 
 
@@ -36,20 +36,24 @@ 	val headsign: TextView = itemView.findViewById(R.id.departure_headsign)
 
 	companion object {
 		fun bind(
-			departure: DepartureV1,
+			departure: Departure,
 			holder: BimbaDepartureViewHolder?,
 			context: Context?,
-			onClickListener: (DepartureV1) -> Unit
+			onClickListener: (Departure) -> Unit
 		) {
 			holder?.root?.setOnClickListener {
 				onClickListener(departure)
 			}
-			holder?.lineIcon?.setImageBitmap(departure.vehicle.Line.icon(context!!))
+			holder?.lineIcon?.setImageDrawable(departure.vehicle.Line.icon(context!!))
 			holder?.lineIcon?.contentDescription = departure.vehicle.Line.kind.name
 			holder?.lineName?.text = departure.vehicle.Line.name
-			holder?.headsign?.text = context?.getString(R.string.departure_headsign, departure.vehicle.Headsign)
+			holder?.headsign?.text =
+				context?.getString(R.string.departure_headsign, departure.vehicle.Headsign)
 			holder?.headsign?.contentDescription =
-				context?.getString(R.string.departure_headsign_content_description, departure.vehicle.Headsign)
+				context?.getString(
+					R.string.departure_headsign_content_description,
+					departure.vehicle.Headsign
+				)
 
 			holder?.departureTime?.text = departure.statusText(context)
 		}
@@ -59,8 +63,8 @@
 class BimbaDeparturesAdapter(
 	private val inflater: LayoutInflater,
 	private val context: Context?,
-	private var departures: List<DepartureV1>,
-	private val onClickListener: ((DepartureV1) -> Unit)
+	private var departures: List<Departure>,
+	private val onClickListener: ((Departure) -> Unit)
 ) :
 	RecyclerView.Adapter<BimbaDepartureViewHolder>() {
 
@@ -83,7 +87,7 @@ 	}
 
 	override fun getItemCount(): Int = departures.size
 
-	fun get(ID: String): DepartureV1? {
+	fun get(ID: String): Departure? {
 		val position = departuresPositions[ID]
 		return if (position == null) {
 			null
@@ -92,8 +96,8 @@ 			departures[position]
 		}
 	}
 
-	@SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil
-	fun update(departures: List<DepartureV1>) {
+	@SuppressLint("NotifyDataSetChanged") // todo [3.2] DiffUtil
+	fun update(departures: List<Departure>) {
 		val newPositions: MutableMap<String, Int> = HashMap()
 		departures.forEachIndexed { i, departure ->
 			newPositions[departure.ID] = i
@@ -105,7 +109,7 @@ 		notifyDataSetChanged()
 	}
 }
 
-class DepartureBottomSheet(private var departure: DepartureV1) : BottomSheetDialogFragment() {
+class DepartureBottomSheet(private var departure: Departure) : BottomSheetDialogFragment() {
 	companion object {
 		const val TAG = "DepartureBottomSheet"
 	}
@@ -125,12 +129,12 @@ 	fun departureID(): String {
 		return departure.ID
 	}
 
-	fun update(departure: DepartureV1) {
+	fun update(departure: Departure) {
 		this.departure = departure
-		this.view?.let { context?.let { ctx -> setContent(it, ctx) } }
+		this.view?.let { context?.let { ctx -> setContent(it, ctx, true) } }
 	}
 
-	private fun setContent(view: View, ctx: Context) {
+	private fun setContent(view: View, ctx: Context, updating: Boolean = false) {
 		view.apply {
 			findViewById<TextView>(R.id.time).text = departure.timeString(ctx)
 
@@ -143,8 +147,8 @@ 				}
 			}
 			findViewById<ImageView>(R.id.wheelchair_icon).apply {
 				visibility = if (departure.vehicle.let {
-						it.getCapability(VehicleV1.Capability.LOW_FLOOR) || it.getCapability(VehicleV1.Capability.LOW_ENTRY) || it.getCapability(
-							VehicleV1.Capability.RAMP
+						it.getCapability(Vehicle.Capability.LOW_FLOOR) || it.getCapability(Vehicle.Capability.LOW_ENTRY) || it.getCapability(
+							Vehicle.Capability.RAMP
 						)
 					}) {
 					View.VISIBLE
@@ -159,44 +163,48 @@ 					R.string.vehicle_headsign_content_description,
 					departure.vehicle.Line.name,
 					departure.vehicle.Headsign
 				)
-				text = getString(R.string.vehicle_headsign, departure.vehicle.Line.name, departure.vehicle.Headsign)
+				text = getString(
+					R.string.vehicle_headsign,
+					departure.vehicle.Line.name,
+					departure.vehicle.Headsign
+				)
 			}
 
 			findViewById<TextView>(R.id.boarding_text).text = departure.boardingText(ctx)
-			// todo units -- [3.1] settings or system-based
+			// 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<TextView>(R.id.congestion_text).text = departure.vehicle.congestion(ctx)
 			findViewById<TextView>(R.id.occupancy_text).text = departure.vehicle.occupancy(ctx)
 
 			findViewById<ImageView>(R.id.ac).visibility =
-				if (departure.vehicle.getCapability(VehicleV1.Capability.AC)) {
+				if (departure.vehicle.getCapability(Vehicle.Capability.AC)) {
 					View.VISIBLE
 				} else {
 					View.GONE
 				}
 			findViewById<ImageView>(R.id.bike).visibility =
-				if (departure.vehicle.getCapability(VehicleV1.Capability.BIKE)) {
+				if (departure.vehicle.getCapability(Vehicle.Capability.BIKE)) {
 					View.VISIBLE
 				} else {
 					View.GONE
 				}
 			findViewById<ImageView>(R.id.voice).visibility =
-				if (departure.vehicle.getCapability(VehicleV1.Capability.VOICE)) {
+				if (departure.vehicle.getCapability(Vehicle.Capability.VOICE)) {
 					View.VISIBLE
 				} else {
 					View.GONE
 				}
 			findViewById<ImageView>(R.id.ticket).visibility =
 				if (departure.vehicle.let {
-						it.getCapability(VehicleV1.Capability.TICKET_DRIVER) || it.getCapability(VehicleV1.Capability.TICKET_MACHINE)
+						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(VehicleV1.Capability.USB_CHARGING)) {
+				if (departure.vehicle.getCapability(Vehicle.Capability.USB_CHARGING)) {
 					View.VISIBLE
 				} else {
 					View.GONE
@@ -206,14 +214,20 @@ 				if (departure.vehicle.Position.isZero()) {
 					map.visibility = View.GONE
 					return@let
 				}
-				map.controller.apply { // todo[3.1] glide to centre, not jump
-					setZoom(19.0f.toDouble())
-					setCenter(
-						GeoPoint(
-							departure.vehicle.location().latitude,
-							departure.vehicle.location().longitude
-						)
-					)
+				map.controller.apply {
+					GeoPoint(
+						departure.vehicle.location().latitude,
+						departure.vehicle.location().longitude
+					).let {
+						if (updating) {
+							animateTo(
+								it, 19.0f.toDouble(), 3 * 1000
+							)
+						} else {
+							setCenter(it)
+							setZoom(19f.toDouble())
+						}
+					}
 				}
 
 				map.overlays.removeAll { marker ->
@@ -221,7 +235,10 @@ 					marker is Marker
 				}
 				val marker = Marker(map).apply {
 					position =
-						GeoPoint(departure.vehicle.location().latitude, departure.vehicle.location().longitude)
+						GeoPoint(
+							departure.vehicle.location().latitude,
+							departure.vehicle.location().longitude
+						)
 					setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
 					icon = context?.let { ctx -> departure.vehicle.icon(ctx, 2f) }
 					setOnClickListener {}




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 a8813189a98bdebfaccbc5705bd3c0a1863d8049..b5d8fa38ecb1f8bd03fc66e5799258041b5cd1d8 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
@@ -17,6 +17,10 @@ import kotlinx.coroutines.*
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.api.*
 import xyz.apiote.bimba.czwek.databinding.ActivityDeparturesBinding
+import xyz.apiote.bimba.czwek.repo.Departure
+import xyz.apiote.bimba.czwek.repo.TrafficResponseException
+import xyz.apiote.bimba.czwek.repo.OnlineRepository
+import xyz.apiote.bimba.czwek.repo.Stop
 
 class DeparturesActivity : AppCompatActivity() {
 	private var _binding: ActivityDeparturesBinding? = null
@@ -72,42 +76,59 @@ 		}
 	}
 
 	private fun getCode(): String {
-		@Suppress("SpellCheckingInspection")
-		return when (intent?.action) {
-			Intent.ACTION_VIEW -> intent?.data?.getQueryParameter("przystanek") ?: ""
+		@Suppress("SpellCheckingInspection") return when (intent?.action) {
+			Intent.ACTION_VIEW -> {
+				when (intent?.data?.host) {
+					"www.peka.poznan.pl" -> intent?.data?.getQueryParameter("przystanek") ?: ""
+					"rj.metropoliaztm.pl" -> intent?.data?.lastPathSegment ?: ""
+					else -> ""
+				}
+
+			}
+
 			null -> intent?.extras?.getString("code") ?: ""
 			else -> ""
+		}
+	}
+
+	private fun getFeedID(): String {
+		return when (intent?.action) {
+			Intent.ACTION_VIEW -> {
+				return when (intent?.data?.host) {
+					"www.peka.poznan.pl" -> "poznan_ztm"
+					"rj.metropoliaztm.pl" -> "gzm_ztm"
+					else -> ""
+				}
+			}
+
+			null -> {
+				intent?.extras?.getString("feedID") ?: ""
+			}
+
+			else -> ""
+		}
+	}
+
+	private fun getLine(): String? {
+		return when (intent?.action) {
+			null -> intent?.extras?.getString("line")
+			else -> null
 		}
 	}
 
 	private fun getDepartures() {
 		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		MainScope().launch {
-			val result = getDepartures(
-				cm,
-				Server.get(this@DeparturesActivity), getCode()
-			)
-
-			if (result.error != null) {
-				showError(result.error)
-				if (result.stream != null) {
-					val response = ErrorResponse.unmarshal(result.stream)
-					Log.w("Departures", "${result.error.statusCode}, ${response.message}")
-				} else {
-					Log.w(
-						"Departures",
-						"${result.error.statusCode}, ${getString(result.error.stringResource)}"
-					)
-				}
-				return@launch
-			}
-			val (departures, stop) = when (val response = DeparturesResponse.unmarshal(result.stream!!)) {
-				is DeparturesResponseDev -> Pair(response.departures, response.stop)
-				is DeparturesResponseV1 -> Pair(response.departures, response.stop)
-				else -> Pair(null, null)
+			try {
+				val repository = OnlineRepository()
+				val stopDepartures =
+					repository.getDepartures(cm, getFeedID(), getCode(), getLine(), this@DeparturesActivity)
+				updateItems(stopDepartures!!.departures, stopDepartures.stop)
+				openBottomSheet?.departureID()?.let { adapter.get(it) }?.let { openBottomSheet?.update(it) }
+			} catch (e: TrafficResponseException) {
+				showError(e.error)
+				Log.w("Departures", "$e")
 			}
-			updateItems(departures!!, stop!!)
-			openBottomSheet?.departureID()?.let { adapter.get(it) }?.let { openBottomSheet?.update(it) }
 		}
 		handler.removeCallbacks(runnable)
 		runnable = Runnable { getDepartures() }
@@ -124,7 +145,7 @@ 		binding.errorText.text = getString(error.stringResource)
 		binding.errorImage.setImageDrawable(AppCompatResources.getDrawable(this, error.imageResource))
 	}
 
-	private fun updateItems(departures: List<DepartureV1>, stop: StopV1) {
+	private fun updateItems(departures: List<Departure>, stop: Stop) {
 		binding.departuresProgress.visibility = View.GONE
 		adapter.update(departures)
 		binding.collapsingLayout.apply {
@@ -138,8 +159,7 @@
 			binding.errorText.text = getString(R.string.no_departures)
 			binding.errorImage.setImageDrawable(
 				AppCompatResources.getDrawable(
-					this,
-					R.drawable.error_search
+					this, R.drawable.error_search
 				)
 			)
 		} else {
@@ -148,7 +168,7 @@ 			binding.errorImage.visibility = View.GONE
 			binding.errorText.visibility = View.GONE
 			binding.departuresRecycler.visibility = View.VISIBLE
 		}
-		// todo [3.1] alerts
-		// todo [3.1] stop info
+		// 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/repo/ChangeOption.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/ChangeOption.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9eafff01ab5ad63ea8140c49f0cb592c44f94d44
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/ChangeOption.kt
@@ -0,0 +1,7 @@
+package xyz.apiote.bimba.czwek.repo
+
+import xyz.apiote.bimba.czwek.api.ChangeOptionV1
+
+data class ChangeOption(val line: String, val headsign: String) {
+	constructor(c: ChangeOptionV1) : this(c.line, c.headsign)
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0c90453e769df3232fc0f92d00194446117f8b44
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Colour.kt
@@ -0,0 +1,15 @@
+package xyz.apiote.bimba.czwek.repo
+
+import xyz.apiote.bimba.czwek.api.ColourV1
+
+data class Colour(val R: UByte, val G: UByte, val B: UByte) {
+	constructor(c: ColourV1) : this(c.R, c.G, c.B)
+
+	fun toInt(): Int {
+		var rgb = 0xff
+		rgb = (rgb shl 8) + R.toInt()
+		rgb = (rgb shl 8) + G.toInt()
+		rgb = (rgb shl 8) + B.toInt()
+		return rgb
+	}
+}
\ No newline at end of file




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
new file mode 100644
index 0000000000000000000000000000000000000000..c640b83234c78c51f8cf6ad09565d8e6e6c5f605
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt
@@ -0,0 +1,149 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.text.format.DateUtils
+import xyz.apiote.bimba.czwek.R
+import xyz.apiote.bimba.czwek.api.AlertCauseV1
+import xyz.apiote.bimba.czwek.api.AlertEffectV1
+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.Time
+import xyz.apiote.bimba.czwek.api.UnknownResourceVersionException
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.time.temporal.ChronoUnit
+
+enum class AlertCause {
+	UNKNOWN, OTHER, TECHNICAL_PROBLEM, STRIKE, DEMONSTRATION, ACCIDENT, HOLIDAY, WEATHER, MAINTENANCE,
+	CONSTRUCTION, POLICE_ACTIVITY, MEDICAL_EMERGENCY;
+
+	companion object {
+		fun of(type: AlertCauseV1): AlertCause {
+			return when (type) {
+				AlertCauseV1.UNKNOWN -> valueOf("UNKNOWN")
+				AlertCauseV1.OTHER -> valueOf("OTHER")
+				AlertCauseV1.TECHNICAL_PROBLEM -> valueOf("TECHNICAL_PROBLEM")
+				AlertCauseV1.STRIKE -> valueOf("STRIKE")
+				AlertCauseV1.DEMONSTRATION -> valueOf("DEMONSTRATION")
+				AlertCauseV1.ACCIDENT -> valueOf("ACCIDENT")
+				AlertCauseV1.HOLIDAY -> valueOf("HOLIDAY")
+				AlertCauseV1.WEATHER -> valueOf("WEATHER")
+				AlertCauseV1.MAINTENANCE -> valueOf("MAINTENANCE")
+				AlertCauseV1.CONSTRUCTION -> valueOf("CONSTRUCTION")
+				AlertCauseV1.POLICE_ACTIVITY -> valueOf("POLICE_ACTIVITY")
+				AlertCauseV1.MEDICAL_EMERGENCY -> valueOf("MEDICAL_EMERGENCY")
+			}
+		}
+	}
+}
+
+enum class AlertEffect {
+	UNKNOWN, OTHER, NO_SERVICE, REDUCED_SERVICE, SIGNIFICANT_DELAYS, DETOUR, ADDITIONAL_SERVICE,
+	MODIFIED_SERVICE, STOP_MOVED, NONE, ACCESSIBILITY_ISSUE;
+
+	companion object {
+		fun of(type: AlertEffectV1): AlertEffect {
+			return when (type) {
+				AlertEffectV1.UNKNOWN -> valueOf("UNKNOWN")
+				AlertEffectV1.OTHER -> valueOf("OTHER")
+				AlertEffectV1.NO_SERVICE -> valueOf("NO_SERVICE")
+				AlertEffectV1.REDUCED_SERVICE -> valueOf("REDUCED_SERVICE")
+				AlertEffectV1.SIGNIFICANT_DELAYS -> valueOf("SIGNIFICANT_DELAYS")
+				AlertEffectV1.DETOUR -> valueOf("DETOUR")
+				AlertEffectV1.ADDITIONAL_SERVICE -> valueOf("ADDITIONAL_SERVICE")
+				AlertEffectV1.MODIFIED_SERVICE -> valueOf("MODIFIED_SERVICE")
+				AlertEffectV1.STOP_MOVED  -> valueOf("STOP_MOVED")
+				AlertEffectV1.NONE -> valueOf("NONE")
+				AlertEffectV1.ACCESSIBILITY_ISSUE -> valueOf("ACCESSIBILITY_ISSUE")
+			}
+		}
+	}
+}
+
+data class Alert(
+	val header: String,
+	val description: String,
+	val url: String,
+	val cause: AlertCause,
+	val effect: AlertEffect
+) {
+	constructor(a: AlertV1) : this(a.header, a.Description, a.Url, AlertCause.of(a.Cause), AlertEffect.of(a.Effect))
+}
+
+data class StopDepartures(
+	val departures: List<Departure>,
+	val stop: Stop,
+	val alerts: List<Alert>
+)
+
+data class Departure(
+	val ID: String,
+	val time: Time,
+	val status: ULong,
+	val isRealtime: Boolean,
+	val vehicle: Vehicle,
+	val boarding: UByte
+) {
+
+	constructor(d: DepartureV1) : this(
+		d.ID,
+		d.time,
+		d.status,
+		d.isRealtime,
+		Vehicle(d.vehicle),
+		d.boarding
+	)
+
+	constructor(d: DepartureV2) : this(
+		d.ID,
+		d.time,
+		d.status,
+		d.isRealtime,
+		Vehicle(d.vehicle),
+		d.boarding
+	)
+
+	fun statusText(context: Context?): String {
+		val now = 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)
+		return when (val r = status.toUInt()) {
+			0u -> DateUtils.getRelativeTimeSpanString(
+				departureTime.toEpochSecond() * 1000,
+				now.toEpochSecond() * 1000,
+				DateUtils.MINUTE_IN_MILLIS,
+				DateUtils.FORMAT_ABBREV_RELATIVE
+			).toString()
+
+			1u -> context?.getString(R.string.departure_momentarily) ?: "momentarily"
+			2u -> context?.getString(R.string.departure_now) ?: "now"
+			3u -> context?.getString(R.string.departure_departed) ?: "departed"
+			else -> throw UnknownResourceVersionException("VehicleStatus/$r", 1u)
+		}
+	}
+
+	fun timeString(context: Context): String {
+		return if (isRealtime) {
+			context.getString(
+				R.string.at_time_realtime, time.Hour.toInt(), time.Minute.toInt(), time.Second.toInt()
+			)
+		} else {
+			context.getString(R.string.at_time, time.Hour.toInt(), time.Minute.toInt())
+		}
+	}
+
+	fun boardingText(context: Context): String {
+		// todo [3.x] probably should take into account (on|off)-boarding only, on demand
+		return when {
+			boarding == (0b0000_0000).toUByte() -> context.getString(R.string.no_boarding)
+			boarding.and(0b0011_0011u) == (0b0000_0001).toUByte() -> context.getString(R.string.on_boarding)
+			boarding.and(0b0011_0011u) == (0b0001_0000).toUByte() -> context.getString(R.string.off_boarding)
+			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/ErrorLocatable.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/ErrorLocatable.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1d9562a1920bd04a00f04d37841c7c08607672f4
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/ErrorLocatable.kt
@@ -0,0 +1,20 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import androidx.appcompat.content.res.AppCompatResources
+import xyz.apiote.bimba.czwek.R
+
+class ErrorLocatable(val stringResource: Int) : Locatable {
+	override fun icon(context: Context, scale: Float): Drawable {
+		return AppCompatResources.getDrawable(context, R.drawable.error_other)!!
+	}
+
+	override fun location(): Position {
+		return Position(0.0, 0.0)
+	}
+
+	override fun id(): String {
+		return "ERROR"
+	}
+}
\ No newline at end of file




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
new file mode 100644
index 0000000000000000000000000000000000000000..b25c1eaa7e0e9ec9ce3d58a8b8dcebe4896136d6
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
@@ -0,0 +1,48 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.net.ConnectivityManager
+
+interface Queryable
+interface Locatable {
+	fun icon(context: Context, scale: Float = 1f): Drawable
+	fun location(): Position
+	fun id(): String
+}
+
+interface Repository {
+	suspend fun getDepartures(
+		cm: ConnectivityManager,
+		feedID: String,
+		stop: String,
+		line: String?,
+		context: Context
+	): StopDepartures?
+
+	suspend fun getLocatablesIn(
+		cm: ConnectivityManager,
+		bl: Position,
+		tr: Position,
+		context: Context
+	): List<Locatable>?
+
+	suspend fun getLine(
+		cm: ConnectivityManager,
+		feedID: String,
+		line: String,
+		context: Context
+	): Line?
+
+	suspend fun queryQueryables(
+		cm: ConnectivityManager,
+		query: String,
+		context: Context
+	): List<Queryable>?
+
+	suspend fun locateQueryables(
+		cm: ConnectivityManager,
+		position: Position,
+		context: Context
+	): List<Queryable>?
+}
\ No newline at end of file




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
new file mode 100644
index 0000000000000000000000000000000000000000..62568fdce835e8abc3e82c9ce3280d848fb1d1a1
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt
@@ -0,0 +1,29 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import xyz.apiote.bimba.czwek.api.LineV1
+
+data class Line(
+	val name: String,
+	val colour: Colour,
+	val type: LineType,
+	val feedID: String,
+	val headsigns: List<List<String>>,
+	val graphs: List<LineGraph>,
+) : Queryable, LineAbstract {
+
+	constructor(line: LineV1) : this(
+		line.name,
+		Colour(line.colour),
+		LineType.of(line.type),
+		line.feedID,
+		line.headsigns,
+		line.graphs.map{LineGraph(it)}
+	)
+
+	fun icon(context: Context, scale: Float = 1f): Drawable {
+		return BitmapDrawable(context.resources, super.icon(context, type, colour, scale))
+	}
+}
\ No newline at end of file




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
new file mode 100644
index 0000000000000000000000000000000000000000..d3a0778b3595e3a215a478a0c7b77b0196582570
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineAbstract.kt
@@ -0,0 +1,91 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Matrix
+import android.graphics.Paint
+import android.graphics.Path
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.graphics.drawable.toBitmap
+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.pow
+
+interface LineAbstract {
+	fun textColour(c: Colour): Int {
+		val black = relativeLuminance(Colour(0u, 0u, 0u)) + .05
+		val white = relativeLuminance(Colour(255u, 255u, 255u)) + .05
+		val colour = relativeLuminance(c) + .05
+		return if ((white / colour) > (colour / black)) {
+			Color.WHITE
+		} else {
+			Color.BLACK
+		}
+	}
+
+	private fun relativeLuminance(colour: Colour): Double {
+		val r = fromSRGB(colour.R.toDouble() / 0xff)
+		val g = fromSRGB(colour.G.toDouble() / 0xff)
+		val b = fromSRGB(colour.B.toDouble() / 0xff)
+		return 0.2126 * r + 0.7152 * g + 0.0722 * b
+	}
+
+	private fun fromSRGB(part: Double): Double {
+		return if (part <= 0.03928) {
+			part / 12.92
+		} else {
+			((part + 0.055) / 1.055).pow(2.4)
+		}
+	}
+
+	fun icon(context: Context, type: LineType, colour: Colour, scale: Float): Bitmap {
+		val drawingBitmap = Bitmap.createBitmap(
+			dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888
+		)
+		val canvas = Canvas(drawingBitmap)
+
+		canvas.drawPath(getSquirclePath(
+			dpToPixel(.8f / scale), dpToPixel(.8f / scale), dpToPixelI(11.2f / scale)
+		), Paint().apply { color = textColour(colour) })
+		canvas.drawPath(getSquirclePath(
+			dpToPixel(1.6f / scale), dpToPixel(1.6f / scale), dpToPixelI(10.4f / scale)
+		), Paint().apply { color = colour.toInt() })
+
+		val iconID = when (type) {
+			LineType.BUS -> R.drawable.bus_black
+			LineType.TRAM -> R.drawable.tram_black
+			LineType.TROLLEYBUS -> R.drawable.trolleybus_black
+			LineType.UNKNOWN -> R.drawable.vehicle_black
+		}
+		val icon = AppCompatResources.getDrawable(context, iconID)?.mutate()?.apply {
+			setTint(textColour(colour))
+		}?.toBitmap(dpToPixelI(19.2f / scale), dpToPixelI(19.2f / scale), Bitmap.Config.ARGB_8888)
+		canvas.drawBitmap(
+			icon!!, dpToPixel(2.4f / scale), dpToPixel(2.4f / scale), Paint()
+		)
+		return drawingBitmap
+	}
+
+	private fun getSquirclePath(
+		left: Float, top: Float, radius: Int
+	): Path {
+		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()
+		)
+		for (x in radius downTo -radius) path.lineTo(
+			x.toFloat(), -Math.cbrt(radiusToPow - abs(x * x * x)).toFloat()
+		)
+		path.close()
+		val matrix = Matrix()
+		matrix.postTranslate((left + radius), (top + radius))
+		path.transform(matrix)
+		return path
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineGraph.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineGraph.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c8c34108be0c167e3bfa1bac52a6492275cf5fd6
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineGraph.kt
@@ -0,0 +1,16 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import xyz.apiote.bimba.czwek.api.LineGraph
+
+@Parcelize
+data class LineGraph(
+	val stops: List<StopStub>,
+	val nextNodes: Map<Long, List<Long>>,
+) : Parcelable {
+	constructor(lineGraph: LineGraph) : this(
+		lineGraph.stops.map { StopStub(it) },
+		lineGraph.nextNodes
+	)
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineStub.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineStub.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6f9f69264310cf126b42985bf1ab8557c4ddf95b
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineStub.kt
@@ -0,0 +1,16 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import xyz.apiote.bimba.czwek.api.LineStubV1
+import xyz.apiote.bimba.czwek.api.LineStubV2
+
+data class LineStub(val name: String, val kind: LineType, val colour: Colour) : LineAbstract {
+	constructor(l: LineStubV1) : this(l.name, LineType.of(l.kind), Colour(l.colour))
+	constructor(l: LineStubV2) : this(l.name, LineType.of(l.kind), Colour(l.colour))
+
+	fun icon(context: Context, scale: Float = 1f): Drawable {
+		return BitmapDrawable(context.resources, super.icon(context, kind, colour, scale))
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ce3c5ec6b35bdd93a154cd2f1d99b8cfaf526b19
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/LineType.kt
@@ -0,0 +1,26 @@
+package xyz.apiote.bimba.czwek.repo
+
+import xyz.apiote.bimba.czwek.api.LineTypeV1
+import xyz.apiote.bimba.czwek.api.LineTypeV2
+
+enum class LineType {
+	UNKNOWN, TRAM, BUS, TROLLEYBUS;
+
+	companion object {
+		fun of(t: LineTypeV1): LineType {
+			return when (t) {
+				LineTypeV1.UNKNOWN -> valueOf("UNKNOWN")
+				LineTypeV1.TRAM -> valueOf("TRAM")
+				LineTypeV1.BUS -> valueOf("BUS")
+			}
+		}
+		fun of(t: LineTypeV2): LineType {
+			return when (t) {
+				LineTypeV2.UNKNOWN -> valueOf("UNKNOWN")
+				LineTypeV2.TRAM -> valueOf("TRAM")
+				LineTypeV2.BUS -> valueOf("BUS")
+				LineTypeV2.TROLLEYBUS -> valueOf("TROLLEYBUS")
+			}
+		}
+	}
+}
\ No newline at end of file




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
new file mode 100644
index 0000000000000000000000000000000000000000..5c94740846682be05460fe8c9dce46bc21ae6228
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
@@ -0,0 +1,209 @@
+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.DeparturesResponse
+import xyz.apiote.bimba.czwek.api.DeparturesResponseDev
+import xyz.apiote.bimba.czwek.api.DeparturesResponseV1
+import xyz.apiote.bimba.czwek.api.DeparturesResponseV2
+import xyz.apiote.bimba.czwek.api.ErrorResponse
+import xyz.apiote.bimba.czwek.api.LineResponse
+import xyz.apiote.bimba.czwek.api.LineResponseDev
+import xyz.apiote.bimba.czwek.api.LineResponseV1
+import xyz.apiote.bimba.czwek.api.LineV1
+import xyz.apiote.bimba.czwek.api.LocatablesResponse
+import xyz.apiote.bimba.czwek.api.LocatablesResponseDev
+import xyz.apiote.bimba.czwek.api.LocatablesResponseV1
+import xyz.apiote.bimba.czwek.api.LocatablesResponseV2
+import xyz.apiote.bimba.czwek.api.PositionV1
+import xyz.apiote.bimba.czwek.api.QueryablesResponse
+import xyz.apiote.bimba.czwek.api.QueryablesResponseDev
+import xyz.apiote.bimba.czwek.api.QueryablesResponseV1
+import xyz.apiote.bimba.czwek.api.QueryablesResponseV2
+import xyz.apiote.bimba.czwek.api.Server
+import xyz.apiote.bimba.czwek.api.StopV1
+import xyz.apiote.bimba.czwek.api.StopV2
+import xyz.apiote.bimba.czwek.api.UnknownResourceException
+import xyz.apiote.bimba.czwek.api.VehicleV1
+import xyz.apiote.bimba.czwek.api.VehicleV2
+
+// todo [3.2] in Repository check if responses are BARE or HTML
+
+class OnlineRepository : Repository {
+	override suspend fun getDepartures(
+		cm: ConnectivityManager,
+		feedID: String,
+		stop: String,
+		line: String?,
+		context: Context
+	): StopDepartures? {
+		val result =
+			xyz.apiote.bimba.czwek.api.getDepartures(cm, Server.get(context), feedID, stop, line)
+		if (result.error != null) {
+			if (result.stream != null) {
+				val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
+				throw TrafficResponseException(result.error.statusCode, response.message, result.error)
+			} else {
+				throw TrafficResponseException(result.error.statusCode, "", result.error)
+			}
+		} else {
+			return 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 DeparturesResponseV1 -> StopDepartures(
+					response.departures.map { Departure(it) },
+					Stop(response.stop),
+					response.alerts.map { Alert(it) })
+
+				is DeparturesResponseV2 -> StopDepartures(
+					response.departures.map { Departure(it) },
+					Stop(response.stop),
+					response.alerts.map { Alert(it) })
+
+				else -> null
+			}
+		}
+	}
+
+	override suspend fun getLocatablesIn(
+		cm: ConnectivityManager,
+		bl: Position,
+		tr: Position,
+		context: Context
+	): List<Locatable>? {
+		val result = xyz.apiote.bimba.czwek.api.getLocatablesIn(
+			cm,
+			Server.get(context),
+			PositionV1(bl.latitude, bl.longitude),
+			PositionV1(tr.latitude, tr.longitude)
+		)
+		if (result.error != null) {
+			if (result.stream != null) {
+				val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
+				throw TrafficResponseException(result.error.statusCode, response.message, result.error)
+			} else {
+				throw TrafficResponseException(result.error.statusCode, "", result.error)
+			}
+		} else {
+			return when (val response =
+				withContext(Dispatchers.IO) { LocatablesResponse.unmarshal(result.stream!!) }) {
+				is LocatablesResponseDev -> response.locatables.map {
+					when (it) {
+						is StopV2 -> Stop(it)
+						is VehicleV2 -> Vehicle(it)
+						else -> throw UnknownResourceException("locatables", it::class)
+					}
+				}
+
+				is LocatablesResponseV1 -> response.locatables.map {
+					when (it) {
+						is StopV1 -> Stop(it)
+						is VehicleV1 -> Vehicle(it)
+						else -> throw UnknownResourceException("locatables", it::class)
+					}
+				}
+
+				is LocatablesResponseV2 -> response.locatables.map {
+					when (it) {
+						is StopV2 -> Stop(it)
+						is VehicleV2 -> Vehicle(it)
+						else -> throw UnknownResourceException("locatables", it::class)
+					}
+				}
+
+				else -> null
+			}
+		}
+	}
+
+	override suspend fun getLine(
+		cm: ConnectivityManager, feedID: String, line: String, context: Context
+	): Line? {
+		val result = xyz.apiote.bimba.czwek.api.getLine(cm, Server.get(context), feedID, line)
+		if (result.error != null) {
+			if (result.stream != null) {
+				val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
+				throw TrafficResponseException(result.error.statusCode, response.message, result.error)
+			} else {
+				throw TrafficResponseException(result.error.statusCode, "", result.error)
+			}
+		} else {
+			return when (val response =
+				withContext(Dispatchers.IO) { LineResponse.unmarshal(result.stream!!) }) {
+				is LineResponseDev -> Line(response.line)
+				is LineResponseV1 -> Line(response.line)
+				else -> null
+			}
+		}
+	}
+
+	override suspend fun queryQueryables(
+		cm: ConnectivityManager, query: String, context: Context
+	): List<Queryable>? {
+		return getQueryables(cm, query, null, context, "query")
+	}
+
+	override suspend fun locateQueryables(
+		cm: ConnectivityManager, position: Position, context: Context
+	): List<Queryable>? {
+		return getQueryables(cm, null, position, context, "locate")
+	}
+
+	private suspend fun getQueryables(
+		cm: ConnectivityManager, query: String?, position: Position?, context: Context, type: String
+	): List<Queryable>? {
+		val result = when (type) {
+			"query" -> {
+				xyz.apiote.bimba.czwek.api.queryQueryables(cm, Server.get(context), query!!, limit = 12)
+			}
+
+			"locate" -> xyz.apiote.bimba.czwek.api.locateQueryables(
+				cm, Server.get(context), PositionV1(position!!.latitude, position.longitude)
+			)
+
+			else -> throw RuntimeException("Unknown query type $type")
+		}
+		if (result.error != null) {
+			if (result.stream != null) {
+				val response = withContext(Dispatchers.IO) { ErrorResponse.unmarshal(result.stream) }
+				throw TrafficResponseException(result.error.statusCode, response.message, result.error)
+			} else {
+				throw TrafficResponseException(result.error.statusCode, "", result.error)
+			}
+		} else {
+			return when (val response =
+				withContext(Dispatchers.IO) { QueryablesResponse.unmarshal(result.stream!!) }) {
+				is QueryablesResponseDev -> response.queryables.map {
+					when (it) {
+						is StopV2 -> Stop(it)
+						is LineV1 -> Line(it)
+						else -> throw UnknownResourceException("queryablesV2", it::class)
+					}
+				}
+
+				is QueryablesResponseV1 -> response.queryables.map {
+					when (it) {
+						is StopV1 -> Stop(it)
+						else -> throw UnknownResourceException("queryablesV1", it::class)
+					}
+				}
+
+				is QueryablesResponseV2 -> response.queryables.map {
+					when (it) {
+						is StopV2 -> Stop(it)
+						is LineV1 -> Line(it)
+						else -> throw UnknownResourceException("queryablesV2", it::class)
+					}
+				}
+
+				else -> null
+			}
+		}
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt
new file mode 100644
index 0000000000000000000000000000000000000000..14e0be5439f1beb32b98f7dbe146a507f59ff9f4
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Position.kt
@@ -0,0 +1,10 @@
+package xyz.apiote.bimba.czwek.repo
+
+import xyz.apiote.bimba.czwek.api.PositionV1
+
+data class Position(val latitude: Double, val longitude: Double) {
+	constructor(p: PositionV1) : this(p.latitude, p.longitude)
+	fun isZero(): Boolean {
+		return latitude == 0.0 && longitude == 0.0
+	}
+}
\ No newline at end of file




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
new file mode 100644
index 0000000000000000000000000000000000000000..6a90fadf4b806c2e5e5806ef1d6e343910030cf2
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Stop.kt
@@ -0,0 +1,90 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.LayerDrawable
+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,
+	val name: String,
+	val nodeName: String,
+	val zone: String,
+	val feedID: String?,
+	val position: Position,
+	val changeOptions: List<ChangeOption>
+) : Queryable, Locatable {
+
+	override fun icon(context: Context, scale: Float): Drawable {
+		val saturationArray = arrayOf(0.5f, 0.65f, 0.8f)
+		val sal = saturationArray.size
+		val lightnessArray = arrayOf(.5f)
+		val lal = lightnessArray.size
+		val md = Adler32().let {
+			it.update(nodeName.toByteArray())
+			it.value
+		}
+		val h = md % 359f
+		val s = saturationArray[(md / 360 % sal).toInt()]
+		val l = lightnessArray[(md / 360 / sal % lal).toInt()]
+		val fg = AppCompatResources.getDrawable(context, R.drawable.stop)
+		val bg = AppCompatResources.getDrawable(context, R.drawable.stop_bg)!!.mutate().apply {
+			setTint(ColorUtils.HSLToColor(arrayOf(h, s, l).toFloatArray()))
+		}
+		return BitmapDrawable(
+			context.resources,
+			LayerDrawable(arrayOf(bg, fg)).mutate()
+				.toBitmap(dpToPixelI(24f / scale), dpToPixelI(24f / scale), Bitmap.Config.ARGB_8888)
+		)
+	}
+
+	override fun id(): String = code
+
+	override fun location(): Position = position
+
+	constructor(s: StopV1) : this(
+		s.code,
+		s.name,
+		s.name,
+		s.zone,
+		null,
+		Position(s.position),
+		s.changeOptions.map { ChangeOption(it) })
+
+	constructor(s: StopV2) : this(
+		s.code,
+		s.name,
+		s.nodeName,
+		s.zone,
+		s.feedID,
+		Position(s.position),
+		s.changeOptions.map { ChangeOption(it) })
+
+	fun changeOptions(context: Context): Pair<String, String> = Pair(changeOptions.groupBy { it.line }
+		.map { Pair(it.key, it.value.joinToString { co -> co.headsign }) }.joinToString {
+			context.getString(
+				R.string.vehicle_headsign, it.first, it.second
+			)
+		},
+		changeOptions.groupBy { it.line }
+			.map { Pair(it.key, it.value.joinToString { co -> co.headsign }) }.joinToString {
+				context.getString(
+					R.string.vehicle_headsign_content_description, it.first, it.second
+				)
+			})
+
+	override fun toString(): String {
+		var result = "$name ($code) [$zone] $position\n"
+		for (chOpt in changeOptions) result += "${chOpt.line} → ${chOpt.headsign}\n"
+		return result
+	}
+}
\ 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
new file mode 100644
index 0000000000000000000000000000000000000000..5366c50af64d254a6ba91a63c5c2bbf9cc51fcf4
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt
@@ -0,0 +1,50 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.LayerDrawable
+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 {
+	constructor(stopStub: StopStub) : this(
+		stopStub.name,
+		stopStub.nodeName,
+		stopStub.code,
+		stopStub.zone,
+		stopStub.onDemand
+	)
+	fun icon(context: Context, scale: Float = 1f): Drawable {
+		val saturationArray = arrayOf(0.5f, 0.65f, 0.8f)
+		val sal = saturationArray.size
+		val lightnessArray = arrayOf(.5f)
+		val lal = lightnessArray.size
+		val md = Adler32().let {
+			it.update(nodeName.toByteArray())
+			it.value
+		}
+		val h = md % 359f
+		val s = saturationArray[(md / 360 % sal).toInt()]
+		val l = lightnessArray[(md / 360 / sal % lal).toInt()]
+		val fg = AppCompatResources.getDrawable(context, R.drawable.stop)
+		val bg = AppCompatResources.getDrawable(context, R.drawable.stop_bg)!!.mutate().apply {
+			setTint(ColorUtils.HSLToColor(arrayOf(h, s, l).toFloatArray()))
+		}
+		return BitmapDrawable(
+			context.resources,
+			LayerDrawable(arrayOf(bg, fg)).mutate()
+				.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/TrafficResponseException.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/TrafficResponseException.kt
new file mode 100644
index 0000000000000000000000000000000000000000..cc5d6bf9b530b1c9499ea2751d2a876fd90304fc
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/TrafficResponseException.kt
@@ -0,0 +1,6 @@
+package xyz.apiote.bimba.czwek.repo
+
+import xyz.apiote.bimba.czwek.api.Error
+
+class TrafficResponseException(code: Int, msg: String, val error: Error) :
+	Exception("Error response with code $code: $msg")
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Vehicle.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Vehicle.kt
new file mode 100644
index 0000000000000000000000000000000000000000..509e4819d6b6826c07323c71425f0f1928549319
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Vehicle.kt
@@ -0,0 +1,119 @@
+package xyz.apiote.bimba.czwek.repo
+
+import android.content.Context
+import xyz.apiote.bimba.czwek.R
+import xyz.apiote.bimba.czwek.api.CongestionLevelV1
+import xyz.apiote.bimba.czwek.api.OccupancyStatusV1
+import xyz.apiote.bimba.czwek.api.VehicleV1
+import xyz.apiote.bimba.czwek.api.VehicleV2
+
+enum class CongestionLevel {
+	UNKNOWN, SMOOTH, STOP_AND_GO, SIGNIFICANT, SEVERE;
+
+	fun toString(context: Context): String {
+		return when (this) {
+			UNKNOWN -> context.getString(R.string.congestion_unknown)
+			SMOOTH -> context.getString(R.string.congestion_smooth)
+			STOP_AND_GO -> context.getString(R.string.congestion_stop_and_go)
+			SIGNIFICANT -> context.getString(R.string.congestion_congestion)
+			SEVERE -> context.getString(R.string.congestion_jams)
+		}
+	}
+
+	companion object {
+		fun of(type: CongestionLevelV1): CongestionLevel {
+			return when (type) {
+				CongestionLevelV1.UNKNOWN -> valueOf("UNKNOWN")
+				CongestionLevelV1.SMOOTH -> valueOf("SMOOTH")
+				CongestionLevelV1.STOP_AND_GO -> valueOf("STOP_AND_GO")
+				CongestionLevelV1.SIGNIFICANT -> valueOf("SIGNIFICANT")
+				CongestionLevelV1.SEVERE -> valueOf("SEVERE")
+			}
+		}
+	}
+}
+
+enum class OccupancyStatus {
+	UNKNOWN, EMPTY, MANY_AVAILABLE, FEW_AVAILABLE, STANDING_ONLY, CRUSHED, FULL, NOT_ACCEPTING;
+
+	fun toString(context: Context):String {
+		return when (this) {
+			UNKNOWN -> context.getString(R.string.occupancy_unknown)
+			EMPTY -> context.getString(R.string.occupancy_empty)
+			MANY_AVAILABLE -> context.getString(R.string.occupancy_many_seats)
+			FEW_AVAILABLE -> context.getString(R.string.occupancy_few_seats)
+			STANDING_ONLY -> context.getString(R.string.occupancy_standing_only)
+			CRUSHED -> context.getString(R.string.occupancy_crowded)
+			FULL -> context.getString(R.string.occupancy_full)
+			NOT_ACCEPTING -> context.getString(R.string.occupancy_wont_let)
+		}
+	}
+	companion object {
+		fun of(type: OccupancyStatusV1): OccupancyStatus {
+			return when (type) {
+				OccupancyStatusV1.UNKNOWN -> valueOf("UNKNOWN")
+				OccupancyStatusV1.EMPTY -> valueOf("EMPTY")
+				OccupancyStatusV1.MANY_AVAILABLE -> valueOf("MANY_AVAILABLE")
+				OccupancyStatusV1.FEW_AVAILABLE -> valueOf("FEW_AVAILABLE")
+				OccupancyStatusV1.STANDING_ONLY -> valueOf("STANDING_ONLY")
+				OccupancyStatusV1.CRUSHED -> valueOf("CRUSHED")
+				OccupancyStatusV1.FULL -> valueOf("FULL")
+				OccupancyStatusV1.NOT_ACCEPTING -> valueOf("NOT_ACCEPTING")
+			}
+		}
+	}
+}
+
+data class Vehicle(
+	val ID: String,
+	val Position: Position,
+	val Capabilities: UShort,
+	val Speed: Float,
+	val Line: LineStub,
+	val Headsign: String,
+	val congestionLevel: CongestionLevel,
+	val occupancyStatus: OccupancyStatus
+) : Locatable {
+	constructor(v: VehicleV1) : this(
+		v.ID,
+		Position(v.Position),
+		v.Capabilities,
+		v.Speed,
+		LineStub(v.Line),
+		v.Headsign,
+		CongestionLevel.of(v.CongestionLevel),
+		OccupancyStatus.of(v.OccupancyStatus)
+	)
+
+	constructor(v: VehicleV2) : this(
+		v.ID,
+		Position(v.Position),
+		v.Capabilities,
+		v.Speed,
+		LineStub(v.Line),
+		v.Headsign,
+		CongestionLevel.of(v.CongestionLevel),
+		OccupancyStatus.of(v.OccupancyStatus)
+	)
+
+	enum class Capability(val bit: UShort) {
+		RAMP(0b0001u), LOW_FLOOR(0b0010u), LOW_ENTRY(0b0001_0000_0000u), AC(0b0100u), BIKE(0b1000u), VOICE(
+			0b0001_0000u
+		),
+		TICKET_MACHINE(0b0010_0000u), TICKET_DRIVER(0b0100_0000u), USB_CHARGING(0b1000_0000u)
+	}
+
+	override fun icon(context: Context, scale: Float) = Line.icon(context, scale)
+
+	override fun location(): Position = Position
+
+	override fun id(): String = ID
+
+	fun congestion(context: Context) = congestionLevel.toString(context)
+
+	fun occupancy(context: Context)= occupancyStatus.toString(context)
+
+	fun getCapability(field: Capability): Boolean {
+		return Capabilities.and(field.bit) != (0).toUShort()
+	}
+}
\ 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
new file mode 100644
index 0000000000000000000000000000000000000000..30a1cb40e6158088c853e36cd7079cd0577a8200
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt
@@ -0,0 +1,70 @@
+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
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.viewpager.widget.ViewPager
+import com.google.android.material.tabs.TabLayout
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import xyz.apiote.bimba.czwek.databinding.ActivityLineGraphBinding
+import xyz.apiote.bimba.czwek.repo.OnlineRepository
+import xyz.apiote.bimba.czwek.repo.TrafficResponseException
+import xyz.apiote.bimba.czwek.search.ui.SectionsPagerAdapter
+
+class LineGraphActivity : AppCompatActivity() {
+
+	private lateinit var binding: ActivityLineGraphBinding
+	private lateinit var sectionsPagerAdapter: SectionsPagerAdapter
+
+	override fun onCreate(savedInstanceState: Bundle?) {
+		super.onCreate(savedInstanceState)
+
+		binding = ActivityLineGraphBinding.inflate(layoutInflater)
+		setContentView(binding.root)
+
+		val lineName = intent.getStringExtra("line")!!
+		val feedID = intent.getStringExtra("feedID")!!
+		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+		binding.title.text = lineName
+		getGraph(lineName, feedID, cm)
+	}
+
+	private fun getGraph(
+		lineName: String,
+		feedID: String,
+		cm: ConnectivityManager,
+	) {
+		MainScope().launch {
+			try {
+				val repository = OnlineRepository()
+				val line = repository.getLine(cm, feedID, lineName, this@LineGraphActivity)
+				line?.let {
+					sectionsPagerAdapter = SectionsPagerAdapter(supportFragmentManager, it)
+					val viewPager: ViewPager = binding.viewPager
+					viewPager.adapter = sectionsPagerAdapter
+					val tabs: TabLayout = binding.tabs
+					// todo [optimisation] hangs before changing progress to graph
+					tabs.setupWithViewPager(viewPager)
+					binding.lineOverlay.visibility = View.GONE
+					binding.viewPager.visibility = View.VISIBLE
+				}
+			} catch (e: TrafficResponseException) {
+				showError(e.error)
+				Log.w("Line", "$e")
+			}
+		}
+	}
+
+	private fun showError(e: xyz.apiote.bimba.czwek.api.Error) {
+		binding.lineProgress.visibility = View.GONE
+		binding.errorImage.visibility = View.VISIBLE
+		binding.errorText.visibility = View.VISIBLE
+		binding.errorText.text = getString(e.stringResource)
+		binding.errorImage.setImageDrawable(AppCompatResources.getDrawable(this, e.imageResource))
+	}
+}
\ No newline at end of file




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 81cc5bbe9e2f9f1759cfbb9c76c7c70a185049b4..b70409a51d62b6593e5a26a1747c823544730431 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
@@ -2,17 +2,19 @@ package xyz.apiote.bimba.czwek.search
 
 import android.annotation.SuppressLint
 import android.content.Context
+import android.content.Intent
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
 import android.widget.ImageView
 import android.widget.TextView
 import androidx.recyclerview.widget.RecyclerView
-import com.mancj.materialsearchbar.adapter.SuggestionsAdapter
 import xyz.apiote.bimba.czwek.R
-import xyz.apiote.bimba.czwek.api.Line
-import xyz.apiote.bimba.czwek.api.QueryableV1
-import xyz.apiote.bimba.czwek.api.StopV1
+import xyz.apiote.bimba.czwek.departures.DeparturesActivity
+import xyz.apiote.bimba.czwek.repo.Line
+import xyz.apiote.bimba.czwek.repo.Queryable
+import xyz.apiote.bimba.czwek.repo.Stop
+import xyz.apiote.bimba.czwek.repo.StopStub
 
 class BimbaViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
 	val root: View = itemView.findViewById(R.id.suggestion)
@@ -22,26 +24,56 @@ 	val description: TextView = itemView.findViewById(R.id.suggestion_description)
 
 	companion object {
 		fun bind(
-			queryable: QueryableV1,
+			queryable: Queryable,
 			holder: BimbaViewHolder?,
 			context: Context?,
-			onClickListener: (QueryableV1) -> Unit
+			onClickListener: (Queryable) -> Unit
 		) {
 			when (queryable) {
-				is StopV1 -> bindStop(queryable, holder, context)
-				//is Line -> bindLine(queryable, holder, context)
+				is Stop -> bindStop(queryable, holder, context)
+				is Line -> bindLine(queryable, holder, context)
 			}
 			holder?.root?.setOnClickListener {
 				onClickListener(queryable)
 			}
 		}
 
-		private fun bindStop(stop: StopV1, holder: BimbaViewHolder?, context: Context?) {
+		fun bind(
+			stopStub: StopStub,
+			holder: BimbaViewHolder?,
+			context: Context?,
+			onClickListener: (StopStub) -> Unit
+		) {
+			holder?.title?.text = stopStub.name
+			holder?.icon?.apply {
+				setImageDrawable(stopStub.icon(context!!))
+				contentDescription = context.getString(R.string.stop_content_description)
+			}
+			holder?.description?.text = when {
+				stopStub.zone != "" && stopStub.onDemand -> context?.getString(
+					R.string.stop_stub_on_demand_in_zone,
+					stopStub.zone
+				)
+
+				stopStub.zone == "" && stopStub.onDemand -> context?.getString(R.string.stop_stub_on_demand)
+				stopStub.zone != "" && !stopStub.onDemand -> context?.getString(
+					R.string.stop_stub_in_zone,
+					stopStub.zone
+				)
+
+				else -> ""
+			}
+			holder?.root?.setOnClickListener {
+				onClickListener(stopStub)
+			}
+		}
+
+		private fun bindStop(stop: Stop, holder: BimbaViewHolder?, context: Context?) {
 			holder?.icon?.apply {
 				setImageDrawable(stop.icon(context!!))
 				contentDescription = context.getString(R.string.stop_content_description)
 			}
-			holder?.title?.text = context?.getString(R.string.stop_title, stop.name, stop.code)
+			holder?.title?.text = stop.name
 			context?.let {
 				stop.changeOptions(it).let { changeOptions ->
 					holder?.description?.apply {
@@ -54,80 +86,81 @@ 		}
 
 		private fun bindLine(line: Line, holder: BimbaViewHolder?, context: Context?) {
 			holder?.icon?.apply {
-				setImageBitmap(line.icon(context!!))
+				setImageDrawable(line.icon(context!!))
 				contentDescription = line.type.name
 				colorFilter = null
 			}
 			holder?.title?.text = line.name
-			holder?.description?.text = context?.getString(
-				R.string.line_headsigns,
-				line.headsignsThere.joinToString { it },
-				line.headsignsBack.joinToString { it })
-			holder?.description?.contentDescription = context?.getString(
-				R.string.line_headsigns_content_description,
-				line.headsignsThere.joinToString { it },
-				line.headsignsBack.joinToString { it })
+			holder?.description?.text = if (line.headsigns.size == 1) {
+				context?.getString(
+					R.string.line_headsign,
+					line.headsigns[0].joinToString { it })
+			} else {
+				context?.getString(
+					R.string.line_headsigns,
+					line.headsigns[0].joinToString { it },
+					line.headsigns[1].joinToString { it })
+			}
+			holder?.description?.contentDescription =if (line.headsigns.size == 1) {
+				context?.getString(
+					R.string.line_headsign_content_description,
+					line.headsigns[0].joinToString { it })
+			} else {
+				context?.getString(
+					R.string.line_headsigns_content_description,
+					line.headsigns[0].joinToString { it },
+					line.headsigns[1].joinToString { it })
+			}
 		}
 	}
 }
 
-interface Adapter {
-	fun createViewHolder(
-		inflater: LayoutInflater,
-		layout: Int,
-		parent: ViewGroup
-	): BimbaViewHolder {
-		val rowView = inflater.inflate(layout, parent, false)
-		return BimbaViewHolder(rowView)
-	}
-
-	fun bindSuggestionHolder(
-		queryable: QueryableV1,
-		holder: BimbaViewHolder?,
-		context: Context?,
-		onClickListener: (QueryableV1) -> Unit
-	) {
-		BimbaViewHolder.bind(queryable, holder, context, onClickListener)
-	}
-}
 
 class BimbaResultsAdapter(
 	private val inflater: LayoutInflater,
 	private val context: Context?,
-	private var queryables: List<QueryableV1>,
-	private val onClickListener: ((QueryableV1) -> Unit)
+	private var queryables: List<Queryable>,
 ) :
-	RecyclerView.Adapter<BimbaViewHolder>(), Adapter {
+	RecyclerView.Adapter<BimbaViewHolder>() {
+	private val onClickListener: ((Queryable) -> Unit) = {
+		when (it) {
+			is Stop -> {
+				val intent = Intent(context, DeparturesActivity::class.java).apply {
+					putExtra("code", it.code)
+					putExtra("name", it.name)
+					putExtra("feedID", it.feedID)
+				}
+				context!!.startActivity(intent)
+			}
+
+			is Line -> {
+				val intent = Intent(context, LineGraphActivity::class.java).apply {
+					putExtra("line", it.name)
+					putExtra("feedID", it.feedID)
+				}
+				context!!.startActivity(intent)
+			}
+		}
+	}
+
 	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaViewHolder {
-		return createViewHolder(inflater, R.layout.result, parent)
+		val rowView = inflater.inflate(R.layout.result, parent, false)
+		return BimbaViewHolder(rowView)
 	}
 
 	override fun onBindViewHolder(holder: BimbaViewHolder, position: Int) {
-		bindSuggestionHolder(queryables[position], holder, context, onClickListener)
+		BimbaViewHolder.bind(queryables[position], holder, context, onClickListener)
 	}
 
 	override fun getItemCount(): Int = queryables.size
 
-	@SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil
-	fun update(queryables: List<QueryableV1>) {
-		this.queryables = queryables
+	@SuppressLint("NotifyDataSetChanged") // todo [3.2] DiffUtil
+	fun update(queryables: List<Queryable>?) {
+		this.queryables = queryables ?: emptyList()
 		notifyDataSetChanged()
 	}
-}
 
-class BimbaSuggestionsAdapter(
-	inflater: LayoutInflater,
-	private val context: Context?,
-	private val onClickListener: ((QueryableV1) -> Unit)
-) :
-	SuggestionsAdapter<QueryableV1, BimbaViewHolder>(inflater), Adapter {
-	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaViewHolder {
-		return createViewHolder(layoutInflater, R.layout.suggestion, parent)
-	}
-
-	override fun getSingleViewHeight(): Int = 72
-
-	override fun onBindSuggestionHolder(queryable: QueryableV1, holder: BimbaViewHolder?, pos: Int) {
-		bindSuggestionHolder(queryable, holder, context, onClickListener)
+	fun click(position: Int) {
+		onClickListener(queryables[position])
 	}
 }
\ No newline at end of file




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 0bb34efb22825f913e597157e6d40d025455559b..32ee74575ac55e9ae63bd0e6fd8f3cd53e78fc95 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
@@ -1,7 +1,6 @@
 package xyz.apiote.bimba.czwek.search
 
 import android.content.Context
-import android.content.Intent
 import android.location.Location
 import android.location.LocationListener
 import android.location.LocationManager
@@ -19,12 +18,14 @@ import kotlinx.coroutines.*
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.api.*
 import xyz.apiote.bimba.czwek.databinding.ActivityResultsBinding
-import xyz.apiote.bimba.czwek.departures.DeparturesActivity
-import java.io.InputStream
+import xyz.apiote.bimba.czwek.repo.TrafficResponseException
+import xyz.apiote.bimba.czwek.repo.OnlineRepository
+import xyz.apiote.bimba.czwek.repo.Position
+import xyz.apiote.bimba.czwek.repo.Queryable
 
 class ResultsActivity : AppCompatActivity(), LocationListener {
 	enum class Mode {
-		MODE_LOCATION, MODE_SEARCH
+		MODE_LOCATION, MODE_SEARCH, MODE_POSITION
 	}
 
 	private var _binding: ActivityResultsBinding? = null
@@ -41,20 +42,7 @@ 		_binding = ActivityResultsBinding.inflate(layoutInflater)
 		setContentView(binding.root)
 
 		binding.resultsRecycler.layoutManager = LinearLayoutManager(this)
-		adapter = BimbaResultsAdapter(layoutInflater, this, listOf()) {
-			when (it) {
-				is StopV1 -> {
-					val intent = Intent(this, DeparturesActivity::class.java).apply {
-						putExtra("code", it.code)
-						putExtra("name", it.name)
-					}
-					startActivity(intent)
-				}
-				is Line -> {
-					TODO("[3.1] start line graph activity")
-				}
-			}
-		}
+		adapter = BimbaResultsAdapter(layoutInflater, this, listOf())
 		binding.resultsRecycler.adapter = adapter
 		setSupportActionBar(binding.topAppBar)
 
@@ -66,10 +54,19 @@ 			Mode.MODE_LOCATION -> {
 				supportActionBar?.title = getString(R.string.stops_nearby)
 				locate()
 			}
+
+			Mode.MODE_POSITION -> {
+				val query = intent.extras?.getString("query")
+				val lat = intent.extras?.getDouble("lat")
+				val lon = intent.extras?.getDouble("lon")
+				supportActionBar?.title = getString(R.string.stops_near_code, query)
+				getQueryablesByLocation(Position(lat!!, lon!!), this)
+			}
+
 			Mode.MODE_SEARCH -> {
 				val query = intent.extras?.getString("query")!!
 				supportActionBar?.title = getString(R.string.results_for, query)
-				getQueryablesByQuery(Server.get(this), query)
+				getQueryablesByQuery(query, this)
 			}
 		}
 	}
@@ -94,7 +91,7 @@ 	}
 
 	override fun onLocationChanged(location: Location) {
 		handler.removeCallbacks(runnable)
-		getQueryablesByLocation(Server.get(this), PositionV1(location.latitude, location.longitude))
+		getQueryablesByLocation(Position(location.latitude, location.longitude), this)
 	}
 
 	override fun onResume() {
@@ -119,36 +116,30 @@ 		locationManager.removeUpdates(this)
 		handler.removeCallbacks(runnable)
 	}
 
-	private fun getQueryablesByQuery(server: Server, query: String) {
+	private fun getQueryablesByQuery(query: String, context: Context) {
 		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		MainScope().launch {
-			val result = queryQueryables(cm, server, query)
-			if (result.error != null) {
-				if (result.stream != null) {
-					val response = ErrorResponse.unmarshal(result.stream)
-					Log.w("Results", "${result.error.statusCode}, ${response.message}")
-				} else {
-					Log.w(
-						"Results",
-						"${result.error.statusCode}, ${getString(result.error.stringResource)}"
-					)
-				}
-				showError(result.error)
-			} else {
-				updateItems(unmarshallQueryablesResponse(result.stream!!)!!)
+			try {
+				val repository = OnlineRepository()
+				val result = repository.queryQueryables(cm, query, context)
+				updateItems(result)
+			} catch (e: TrafficResponseException) {
+				Log.w("Suggestion", "$e")
+				showError(e.error)
 			}
 		}
 	}
 
-	private fun getQueryablesByLocation(server: Server, position: PositionV1) {
+	private fun getQueryablesByLocation(position: Position, context: Context) {
 		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		MainScope().launch {
-			val result = locateQueryables(cm, server, position)
-			if (result.error != null) {
-				Log.e("Results.location", "$result")
-				showError(result.error)
-			} else {
-				updateItems(unmarshallQueryablesResponse(result.stream!!)!!)
+			try {
+				val repository = OnlineRepository()
+				val result = repository.locateQueryables(cm, position, context)
+				updateItems(result)
+			} catch (e: TrafficResponseException) {
+				Log.w("Suggestion", "$e")
+				showError(e.error)
 			}
 		}
 	}
@@ -163,10 +154,10 @@ 		binding.errorText.text = getString(error.stringResource)
 		binding.errorImage.setImageDrawable(AppCompatResources.getDrawable(this, error.imageResource))
 	}
 
-	private fun updateItems(queryables: List<QueryableV1>) {
+	private fun updateItems(queryables: List<Queryable>?) {
 		binding.resultsProgress.visibility = View.GONE
 		adapter.update(queryables)
-		if (queryables.isEmpty()) {
+		if (queryables.isNullOrEmpty()) {
 			binding.errorImage.visibility = View.VISIBLE
 			binding.errorText.visibility = View.VISIBLE
 			binding.resultsRecycler.visibility = View.GONE
@@ -179,20 +170,14 @@ 					R.drawable.error_search
 				)
 			)
 		} else {
+			@Suppress("DEPRECATION")  // fixme later getSerializable in API>=33
+			if (queryables.size == 1 && intent.extras?.get("mode") == Mode.MODE_SEARCH) {
+				adapter.click(0)
+			}
 			binding.resultsOverlay.visibility = View.GONE
 			binding.errorImage.visibility = View.GONE
 			binding.errorText.visibility = View.GONE
 			binding.resultsRecycler.visibility = View.VISIBLE
-		}
-	}
-
-	private suspend fun unmarshallQueryablesResponse(stream: InputStream): List<QueryableV1>? {
-		return withContext(Dispatchers.IO) {
-			when (val response = QueryablesResponse.unmarshal(stream)) {
-				is QueryablesResponseDev -> response.queryables
-				is QueryablesResponseV1 -> response.queryables
-				else -> null
-			}
 		}
 	}
 }
\ No newline at end of file




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
new file mode 100644
index 0000000000000000000000000000000000000000..a9a07a29f7dd8abae1c47b9b3386575169257ac1
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt
@@ -0,0 +1,119 @@
+package xyz.apiote.bimba.czwek.search.ui
+
+import android.content.Context
+import android.content.Intent
+import android.content.res.TypedArray
+import android.graphics.CornerPathEffect
+import android.graphics.Paint
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import dev.bandb.graphview.AbstractGraphAdapter
+import dev.bandb.graphview.layouts.layered.SugiyamaArrowEdgeDecoration
+import dev.bandb.graphview.layouts.layered.SugiyamaConfiguration
+import dev.bandb.graphview.layouts.layered.SugiyamaLayoutManager
+import xyz.apiote.bimba.czwek.R
+import xyz.apiote.bimba.czwek.databinding.FragmentLineGraphBinding
+import xyz.apiote.bimba.czwek.departures.DeparturesActivity
+import xyz.apiote.bimba.czwek.repo.LineGraph
+import xyz.apiote.bimba.czwek.repo.StopStub
+import xyz.apiote.bimba.czwek.search.BimbaViewHolder
+
+
+class LineGraphFragment : Fragment() {
+
+	private lateinit var pageViewModel: PageViewModel
+	private var _binding: FragmentLineGraphBinding? = null
+	private val binding get() = _binding!!
+	private lateinit var adapter: LineGraphAdapter
+
+	override fun onCreate(savedInstanceState: Bundle?) {
+		super.onCreate(savedInstanceState)
+		adapter = LineGraphAdapter(
+			arguments?.getString("lineName", "") ?: "",
+			arguments?.getString("feedID", "") ?: ""
+		)
+		pageViewModel = ViewModelProvider(this)[PageViewModel::class.java].apply {
+		}
+	}
+
+	override fun onCreateView(
+		inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+	): View {
+
+		_binding = FragmentLineGraphBinding.inflate(inflater, container, false)
+
+		val configuration = SugiyamaConfiguration.Builder()
+			.setLevelSeparation(100)
+			.build()
+
+		binding.recycler.layoutManager = SugiyamaLayoutManager(requireContext(), configuration)
+		binding.recycler.addItemDecoration(SugiyamaArrowEdgeDecoration(Paint(Paint.ANTI_ALIAS_FLAG).apply {
+			strokeWidth = 5f
+			val a: TypedArray? = context?.theme?.obtainStyledAttributes(
+				R.style.Theme_Bimba, intArrayOf(com.google.android.material.R.attr.colorOnBackground)
+			)
+			val intColor = a?.getColor(0, 0)
+			a?.recycle()
+			color = intColor ?: 0
+			style = Paint.Style.STROKE
+			strokeJoin = Paint.Join.ROUND
+			pathEffect = CornerPathEffect(10f)
+		}))
+		binding.recycler.adapter = adapter
+		pageViewModel.let {
+			val lineGraph = arguments?.getParcelable("graph") as LineGraph?
+			it.setupGraphView(lineGraph!!)
+			it.data.observe(viewLifecycleOwner) { graph ->
+				adapter.submitGraph(graph)
+				// adapter.notifyDataSetChanged()
+			}
+		}
+
+		return binding.root
+	}
+
+	companion object {
+		@JvmStatic
+		fun newInstance(lineGraph: LineGraph, lineName: String, feedID: String): LineGraphFragment {
+			return LineGraphFragment().apply {
+				arguments = Bundle().apply {
+					putParcelable("graph", lineGraph)
+					putString("lineName", lineName)
+					putString("feedID", feedID)
+				}
+			}
+		}
+	}
+
+	override fun onDestroyView() {
+		super.onDestroyView()
+		_binding = null
+	}
+}
+
+class LineGraphAdapter(private val lineName: String, private val feedID: String) :
+	AbstractGraphAdapter<BimbaViewHolder>() {
+	private lateinit var context: Context
+	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaViewHolder {
+		context = parent.context
+		val view = LayoutInflater.from(parent.context)
+			.inflate(R.layout.result, parent, false)
+		return BimbaViewHolder(view)
+	}
+
+	override fun onBindViewHolder(holder: BimbaViewHolder, position: Int) {
+		BimbaViewHolder.bind(getNodeData(position) as StopStub, holder, context) {
+			val intent = Intent(context, DeparturesActivity::class.java).apply {
+				putExtra("code", it.code)
+				putExtra("name", it.name)
+				putExtra("line", lineName)
+				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/PageViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/PageViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..37f372ef27afb25035564f77752ce922e30dc94b
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/PageViewModel.kt
@@ -0,0 +1,25 @@
+package xyz.apiote.bimba.czwek.search.ui
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import dev.bandb.graphview.graph.Graph
+import dev.bandb.graphview.graph.Node
+import xyz.apiote.bimba.czwek.repo.LineGraph
+
+class PageViewModel : ViewModel() {
+
+	private val _data = MutableLiveData<Graph>()
+	val data: LiveData<Graph> = _data
+
+	fun setupGraphView(lineGraph: LineGraph) {
+		val graph = Graph()
+		val nodes = lineGraph.stops.map { Node(it) }
+		lineGraph.nextNodes.filter { it.key != -1L }.forEach { (from, tos) ->
+			tos.filter { it != -1L }.forEach { to ->
+				graph.addEdge(nodes[from.toInt()], nodes[to.toInt()])
+			}
+		}
+		_data.value = graph
+	}
+}
\ 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
new file mode 100644
index 0000000000000000000000000000000000000000..30e75305b7b72d593326447062198536c4dd6169
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/SectionsPagerAdapter.kt
@@ -0,0 +1,22 @@
+package xyz.apiote.bimba.czwek.search.ui
+
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentManager
+import androidx.fragment.app.FragmentPagerAdapter
+import xyz.apiote.bimba.czwek.repo.Line
+
+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)
+	}
+
+	override fun getPageTitle(position: Int): CharSequence {
+		return line.headsigns[position].joinToString()
+	}
+
+	override fun getCount(): Int {
+		return 2
+	}
+}
\ 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 80c0975d04174d0958a97d6e5f3f03342c3ea17f..d1d96616c5998e2ed5f21c1da8719658f57fe9b6 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
@@ -3,6 +3,7 @@
 import android.content.Context
 import android.content.Intent
 import android.content.SharedPreferences
+import android.graphics.Color
 import android.net.ConnectivityManager
 import android.os.Bundle
 import android.util.Log
@@ -46,12 +47,16 @@ 		if (intent.getBooleanExtra("simple", false)) {
 			setServer("bimba.apiote.xyz", "")
 			checkServer(true)
 		} else {
-
 			_binding = ActivityServerChooserBinding.inflate(layoutInflater)
 			setContentView(binding.root)
 
 			preferences.edit(true) {
 				putBoolean("inFeedsTransaction", true)
+			}
+
+			if (preferences.getBoolean("shibboleet", false)) {
+				binding.button.setBackgroundColor(Color.rgb(35, 93, 121))
+				binding.button.setTextColor(Color.WHITE)
 			}
 
 			binding.button.isEnabled = false
@@ -67,11 +72,43 @@ 				}
 			}
 
 			binding.button.setOnClickListener {
-				setServer(
-					binding.serverField.editText!!.text.toString(),
-					binding.tokenField.editText!!.text.toString()
-				)
-				checkServer(false)
+				when (binding.serverField.editText!!.text.toString()) {
+					":shibboleet" -> {
+						binding.button.setBackgroundColor(Color.rgb(35, 93, 121))
+						binding.button.setTextColor(Color.WHITE)
+						preferences.edit(true) {
+							putBoolean("shibboleet", true)
+						}
+						if (!preferences.getBoolean("firstRun", true)) {
+							Server.get(this).let { server ->
+								binding.serverField.editText!!.setText(server.host)
+								binding.tokenField.editText!!.setText(server.token)
+							}
+						}
+					}
+
+					";shibboleet" -> {
+						_binding = ActivityServerChooserBinding.inflate(layoutInflater)
+						setContentView(binding.root)
+						preferences.edit(true) {
+							putBoolean("shibboleet", false)
+						}
+						if (!preferences.getBoolean("firstRun", true)) {
+							Server.get(this).let { server ->
+								binding.serverField.editText!!.setText(server.host)
+								binding.tokenField.editText!!.setText(server.token)
+							}
+						}
+					}
+
+					else -> {
+						setServer(
+							binding.serverField.editText!!.text.toString(),
+							binding.tokenField.editText!!.text.toString()
+						)
+						checkServer(false)
+					}
+				}
 			}
 		}
 	}
@@ -114,23 +151,45 @@ 				Log.w("ServerChooser", e.message)
 				showDialog(R.string.error, R.string.error_traffic_spec, R.drawable.error_server, null)
 				return@launch
 			}
-			val token = preferences.getString("token", "")
-			updateServer(bimba.servers[0]["url"]!!)
 
-			if (bimba.isPrivate() && token == "") {
-				showDialog(R.string.error, R.string.server_private_question, R.drawable.error_sec, null)
+			if (isSimple) {
+				updateServer(bimba.servers[0]["url"]!!)
+				moveOn(bimba, true)
 				return@launch
 			}
-			if (bimba.isRateLimited() && token == "" && !isSimple) {
-				showDialog(
-					R.string.rate_limit, R.string.server_rate_limited_question, R.drawable.error_limit
-				) {
-					runFeedsActivity()
+
+			if (preferences.getBoolean("shibboleet", false)) {
+				val validServers = bimba.servers.filter { !it.getOrDefault("url", null).isNullOrBlank() }
+				if (validServers.size > 1) {
+					val servers = validServers.toTypedArray()
+					MaterialAlertDialogBuilder(this@ServerChooserActivity)
+						.setTitle(R.string.choose_server)
+						.setItems(servers.map { it["description"] }.toTypedArray()) { _, i ->
+							updateServer(servers[i]["url"].toString())
+							moveOn(bimba, false)
+						}
+						.show()
 				}
-				return@launch
 			}
-			runFeedsActivity()
 		}
+	}
+
+	private fun moveOn(bimba: Bimba, isSimple: Boolean) {
+		val token = preferences.getString("token", "")
+
+		if (bimba.isPrivate() && token == "") {
+			showDialog(R.string.error, R.string.server_private_question, R.drawable.error_sec, null)
+			return
+		}
+		if (bimba.isRateLimited() && token == "" && !isSimple) {
+			showDialog(
+				R.string.rate_limit, R.string.server_rate_limited_question, R.drawable.error_limit
+			) {
+				runFeedsActivity()
+			}
+			return
+		}
+		runFeedsActivity()
 	}
 
 	private fun setServer(hostname: String, token: String) {




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 c7ee64b4a4e8c7615ec942ab0725ade256e0551f..853636cd3f3c7b1ca23a8259fc578a2c3d00bf60 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
@@ -10,9 +10,17 @@ import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.content.edit
 import androidx.recyclerview.widget.LinearLayoutManager
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.launch
-import xyz.apiote.bimba.czwek.api.*
+import kotlinx.coroutines.withContext
+import xyz.apiote.bimba.czwek.api.ErrorResponse
+import xyz.apiote.bimba.czwek.api.FeedInfoV1
+import xyz.apiote.bimba.czwek.api.FeedsResponse
+import xyz.apiote.bimba.czwek.api.FeedsResponseDev
+import xyz.apiote.bimba.czwek.api.FeedsResponseV1
+import xyz.apiote.bimba.czwek.api.Server
+import xyz.apiote.bimba.czwek.api.getFeeds
 import xyz.apiote.bimba.czwek.dashboard.MainActivity
 import xyz.apiote.bimba.czwek.databinding.ActivityFeedChooserBinding
 
@@ -54,7 +62,7 @@ 			val result = getFeeds(cm, Server.get(this@FeedChooserActivity))
 			if (result.error != null) {
 				showError(result.error.imageResource, result.error.stringResource)
 				if (result.stream != null) {
-					val response = ErrorResponse.unmarshal(result.stream)
+					val response = withContext(Dispatchers.IO) {ErrorResponse.unmarshal(result.stream)}
 					Log.w("FeedChooser", "${result.error.statusCode}, ${response.message}")
 				} else {
 					Log.w(
@@ -64,7 +72,7 @@ 					)
 				}
 				return@launch
 			}
-			val feeds = when (val response = FeedsResponse.unmarshal(result.stream!!)) {
+			val feeds = when (val response = withContext(Dispatchers.IO) {FeedsResponse.unmarshal(result.stream!!)}) {
 				is FeedsResponseDev -> response.feeds
 				is FeedsResponseV1 -> response.feeds
 				else -> null




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 ff9d63e7b907ebbdfca83abc145d1feb02bda4aa..8e8391c997e8fdd7d13c371139583e671d31d38f 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
@@ -14,6 +14,8 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
 import com.google.android.material.materialswitch.MaterialSwitch
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.api.FeedInfoV1
+import xyz.apiote.bimba.czwek.api.Server
+import java.net.URLEncoder
 
 
 class BimbaFeedInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
@@ -28,28 +30,16 @@ 			context: Context,
 			holder: BimbaFeedInfoViewHolder?,
 			onClickListener: (FeedInfoV1) -> Unit
 		) {
-			val shp = context.getSharedPreferences("shp", MODE_PRIVATE)
-			val host = shp.getString("host", "bimba.apiote.xyz")!!
-			val enabledFeeds =
-				shp.getString("${host}_feeds", "")!!.split(",").associateWith { }.toMutableMap()
-
+			val shp = context.getSharedPreferences(URLEncoder.encode(Server.get(context).apiPath, "utf-8"), MODE_PRIVATE)
 			holder?.root?.setOnClickListener {
 				onClickListener(feed)
 			}
 			holder?.name?.text = feed.name
 			holder?.switch?.apply {
-				isChecked = feed.id in enabledFeeds
+				isChecked = shp.getBoolean(feed.id, false)
 				setOnCheckedChangeListener { _, isChecked ->
-					if (isChecked) {
-						enabledFeeds[feed.id] = Unit
-					} else {
-						enabledFeeds.remove(feed.id)
-					}
 					shp.edit(true) {
-						putString(
-							"${host}_feeds",
-							enabledFeeds.map { it.key }.filter { it != "" }.joinToString(separator = ",")
-						)
+						putBoolean(feed.id, isChecked)
 					}
 				}
 			}
@@ -75,9 +65,9 @@ 	}
 
 	override fun getItemCount(): Int = feeds.size
 
-	@SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil
+	@SuppressLint("NotifyDataSetChanged") // todo [3.2] DiffUtil
 	fun update(items: List<FeedInfoV1>) {
-		feeds = items
+		feeds = items.sortedBy { it.name }
 		notifyDataSetChanged()
 	}
 }
@@ -96,7 +86,8 @@ 		val content = inflater.inflate(R.layout.feed_bottom_sheet, container, false)
 		content.findViewById<TextView>(R.id.title).text = feed.name
 		content.findViewById<TextView>(R.id.description).text = feed.description
 		content.findViewById<TextView>(R.id.attribution).text = feed.attribution
-		content.findViewById<TextView>(R.id.update_time).text = feed.formatDate()
+		content.findViewById<TextView>(R.id.update_time).text =
+			getString(R.string.last_update, feed.formatDate())
 		return content
 	}
 }
\ No newline at end of file




diff --git a/app/src/main/res/drawable/feeds_cities.xml b/app/src/main/res/drawable/feeds_cities.xml
index 7324708d81054028bfebfd981910557dff919acb..c6b30996be3ec096558260d491ae210f28fbb334 100644
--- a/app/src/main/res/drawable/feeds_cities.xml
+++ b/app/src/main/res/drawable/feeds_cities.xml
@@ -1,4 +1,4 @@
-<vector android:height="24dp" android:tint="#000000"
+<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="M15,11L15,5l-3,-3 -3,3v2L3,7v14h18L21,11h-6zM7,19L5,19v-2h2v2zM7,15L5,15v-2h2v2zM7,11L5,11L5,9h2v2zM13,19h-2v-2h2v2zM13,15h-2v-2h2v2zM13,11h-2L11,9h2v2zM13,7h-2L11,5h2v2zM19,19h-2v-2h2v2zM19,15h-2v-2h2v2z"/>




diff --git a/app/src/main/res/drawable/feeds_servers.xml b/app/src/main/res/drawable/feeds_servers.xml
index 4a19b1b7c5a7d45a14c3fe2b89c426917130299b..7332a6e0fc043fff01972e3245d9ce4be7e2feb4 100644
--- a/app/src/main/res/drawable/feeds_servers.xml
+++ b/app/src/main/res/drawable/feeds_servers.xml
@@ -1,4 +1,4 @@
-<vector android:height="24dp" android:tint="#000000"
+<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="M20,13H4c-0.55,0 -1,0.45 -1,1v6c0,0.55 0.45,1 1,1h16c0.55,0 1,-0.45 1,-1v-6c0,-0.55 -0.45,-1 -1,-1zM7,19c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM20,3H4c-0.55,0 -1,0.45 -1,1v6c0,0.55 0.45,1 1,1h16c0.55,0 1,-0.45 1,-1V4c0,-0.55 -0.45,-1 -1,-1zM7,9c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/>




diff --git a/app/src/main/res/drawable/ic_launcher_foreground_mono.xml b/app/src/main/res/drawable/ic_launcher_foreground_mono.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2b71c168983b2e58670ab9aac6ece8be2c5dc10b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground_mono.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="80dp"
+    android:height="80dp"
+    android:viewportWidth="512"
+    android:viewportHeight="512">
+  <path
+      android:fillColor="#FF000000"
+      android:pathData="m 231.56,138.87028 c -1.68,0 -3.04,1.36 -3.04,3.04 0,1.68 1.36,3.04 3.04,3.04 h 16.49 c 0.0287,0.18603 0.0787,0.37668 0.15,0.56 l 6.18,17.2 h -15.7 c -33.12,0 -59.92,26.88 -59.92,59.92 v 86.4 c 0,10.56 8.55999,19.2 19.2,19.2 h 8.09 l -20.25,39.44 c -1.12,2.16 0.48,4.48 3.44,5.2 3.04,0.8 6.32,-0.32 7.36,-2.4 l 21.69,-42.24 h 75.26 l 21.69,42.24 c 1.04,2.08 4.4,3.2 7.36,2.4 2.96,-0.72 4.56,-3.04 3.44,-5.2 l -20.25,-39.44 h 8.09 c 10.64,0 19.28,-8.64 19.36,-19.2 v -86.4 c 0,-33.12 -26.88,-59.92 -59.92,-59.92 h -12.43 l -6.38,-17.76 h 25.85 c 1.68,0 3.04,-1.36 3.04,-3.04 0,-1.68 -1.36,-3.04 -3.04,-3.04 z m 12.24,35.28 h 24.24 c 2.56,0 4.56,2.08 4.56,4.56 0,2.48 -2.08,4.56 -4.56,4.56 H 243.8 c -2.56,0 -4.56,-2.08 -4.56,-4.56 0,-2.56 2.08,-4.56 4.56,-4.56 z m -24.56,21.12 h 73.36 c 13.52,0 24.48,10.96 24.48,24.48 v 27.92 c -0.08,4.56 -3.68,8.16 -8.16,8.16 h -106 c -4.48,0 -8.16,-3.6 -8.16,-8.16 v -27.92 c 0,-13.52 10.96,-24.48 24.48,-24.48 z m 84.16,85.28 a 11.84,11.84 0 0 1 11.84,11.84 11.84,11.84 0 0 1 -23.68,0 11.84,11.84 0 0 1 11.84,-11.84 z m -93.51,0.08 a 11.84,11.84 0 0 1 10.47,11.76 11.84,11.84 0 0 1 -23.68,0 11.84,11.84 0 0 1 9.78,-11.66 11.84,11.84 0 0 1 3.43,-0.1 z"
+      android:strokeWidth="1"/>
+</vector>




diff --git a/app/src/main/res/drawable/menu.xml b/app/src/main/res/drawable/menu.xml
new file mode 100644
index 0000000000000000000000000000000000000000..280437da21f84c3375d9439288a62f3b6fe69729
--- /dev/null
+++ b/app/src/main/res/drawable/menu.xml
@@ -0,0 +1,5 @@
+<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="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z"/>
+</vector>




diff --git a/app/src/main/res/drawable/trolleybus_black.xml b/app/src/main/res/drawable/trolleybus_black.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8aad71f63054311cbd6c4b418cc42c5bde75f517
--- /dev/null
+++ b/app/src/main/res/drawable/trolleybus_black.xml
@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24"
+    android:viewportHeight="24">
+  <path
+      android:pathData="M7.456,2L7.456,3.363L11.773,3.363L11.084,4.743C7.501,4.82 4.729,5.432 4.729,8.367v9.088c0,0.8 0.354,1.518 0.909,2.018v1.617C5.638,21.591 6.047,22 6.547,22h0.909c0.5,0 0.909,-0.409 0.909,-0.909v-0.909h7.271v0.909C15.635,21.591 16.044,22 16.544,22h0.909c0.5,0 0.909,-0.409 0.909,-0.909v-1.617c0.554,-0.5 0.909,-1.218 0.909,-2.018L19.271,8.367c0,-2.795 -2.511,-3.486 -5.845,-3.612 -0.173,-0.009 -0.347,-0.015 -0.525,-0.02l0.691,-1.372h2.954L16.544,2ZM6.547,8.367L17.453,8.367L17.453,12.912L6.547,12.912ZM7.91,15.638c0.754,0 1.363,0.609 1.363,1.363 0,0.754 -0.609,1.363 -1.363,1.363 -0.754,0 -1.363,-0.609 -1.363,-1.363 0,-0.754 0.609,-1.363 1.363,-1.363zM16.09,15.638c0.754,0 1.363,0.609 1.363,1.363 0,0.754 -0.609,1.363 -1.363,1.363 -0.754,0 -1.363,-0.609 -1.363,-1.363 0,-0.754 0.609,-1.363 1.363,-1.363z"
+      android:strokeWidth="0.908849"
+      android:fillColor="#000000"/>
+</vector>




diff --git a/app/src/main/res/font/yellowcircle8.otf b/app/src/main/res/font/yellowcircle8.otf
index d7fe02a6e3eb6c6c6eab115c4b1e60c0f36bb63a..31e8706da286d9575e653938bfd72b7fdba1676d 100644
Binary files a/app/src/main/res/font/yellowcircle8.otf and b/app/src/main/res/font/yellowcircle8.otf differ




diff --git a/app/src/main/res/layout/activity_line_graph.xml b/app/src/main/res/layout/activity_line_graph.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fedde07d1e8a2d258d88beb2666126a730b3e49d
--- /dev/null
+++ b/app/src/main/res/layout/activity_line_graph.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout 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="match_parent"
+	tool:context=".search.LineGraphActivity">
+
+	<com.google.android.material.appbar.AppBarLayout
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content">
+
+		<TextView
+			android:id="@+id/title"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:gravity="center"
+			android:minHeight="?actionBarSize"
+			android:padding="16dp"
+			android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title" />
+
+		<com.google.android.material.tabs.TabLayout
+			android:id="@+id/tabs"
+			android:layout_width="match_parent"
+			android:layout_height="wrap_content" />
+	</com.google.android.material.appbar.AppBarLayout>
+
+	<androidx.constraintlayout.widget.ConstraintLayout
+		android:id="@+id/line_overlay"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent">
+
+		<com.google.android.material.progressindicator.CircularProgressIndicator
+			android:id="@+id/line_progress"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:indeterminate="true"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="parent" />
+
+		<ImageView
+			android:id="@+id/error_image"
+			android:layout_width="92dp"
+			android:layout_height="92dp"
+			android:visibility="gone"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="parent"
+			tool:ignore="ContentDescription"
+			tool:src="@drawable/error_net" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/error_text"
+			android:layout_width="0dp"
+			android:layout_height="wrap_content"
+			android:layout_marginStart="16dp"
+			android:layout_marginTop="8dp"
+			android:layout_marginEnd="16dp"
+			android:textAlignment="center"
+			android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
+			android:visibility="gone"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toBottomOf="@+id/error_image"
+			tool:text="No connection" />
+	</androidx.constraintlayout.widget.ConstraintLayout>
+
+	<androidx.viewpager.widget.ViewPager
+		android:id="@+id/view_pager"
+		android:visibility="gone"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		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/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
index 6ace86db683233a7d0443fe2e126f2cb19cbe25b..1897809f4f065b72823e7082a3c31f7fb796f2ed 100644
--- a/app/src/main/res/layout/fragment_home.xml
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.coordinatorlayout.widget.CoordinatorLayout 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"
@@ -7,46 +7,71 @@ 	android:layout_height="match_parent"
 	android:tag="@string/title_home"
 	tool:context="xyz.apiote.bimba.czwek.dashboard.ui.home.HomeFragment">
 
-	<com.mancj.materialsearchbar.MaterialSearchBar
-		android:id="@+id/search_bar"
-		style="@style/Theme.Bimba.SearchBar"
+	<com.google.android.material.appbar.AppBarLayout
 		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toTopOf="parent"
-		app:mt_hint=""
-		app:mt_navIconEnabled="true"
-		app:mt_placeholder="@string/search_placeholder"
-		app:mt_roundedSearchBarEnabled="true" />
+		android:layout_height="wrap_content">
+
+		<com.google.android.material.search.SearchBar
+			android:id="@+id/search_bar"
+			android:layout_width="match_parent"
+			android:layout_height="wrap_content"
+			android:layout_marginStart="8dp"
+			android:layout_marginTop="8dp"
+			android:layout_marginEnd="8dp"
+			android:contentDescription="@string/search_placeholder"
+			android:hint="@string/search_placeholder"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="parent"
+			app:navigationIcon="@drawable/menu"
+			app:useDrawerArrowDrawable="true"
+			tool:ignore="ContentDescription" />
+	</com.google.android.material.appbar.AppBarLayout>
+
+	<com.google.android.material.search.SearchView
+		android:id="@+id/search_view"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		android:hint="@string/search_placeholder"
+		app:layout_anchor="@id/search_bar">
+
+		<androidx.recyclerview.widget.RecyclerView
+			android:id="@+id/suggestions_recycler"
+			android:layout_width="match_parent"
+			android:layout_height="match_parent"
+			app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+	</com.google.android.material.search.SearchView>
+
+	<androidx.constraintlayout.widget.ConstraintLayout
+		android:layout_width="match_parent"
+		android:layout_height="match_parent">
+
+		<ImageView
+			android:id="@+id/inari"
+			android:layout_width="0dp"
+			android:layout_height="0dp"
+			android:layout_marginStart="16dp"
+			android:layout_marginTop="64dp"
+			android:layout_marginEnd="16dp"
+			android:layout_marginBottom="16dp"
+			android:alpha="0.25"
+			android:src="@drawable/inari"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="parent"
+			tool:ignore="ContentDescription" />
+
+	</androidx.constraintlayout.widget.ConstraintLayout>
 
 	<com.google.android.material.floatingactionbutton.FloatingActionButton
 		android:id="@+id/floating_action_button"
 		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
+		android:layout_gravity="bottom|end"
 		android:layout_margin="16dp"
 		android:contentDescription="@string/home_fab_description"
 		android:src="@drawable/gps_black"
 		app:layout_constraintBottom_toBottomOf="parent"
 		app:layout_constraintEnd_toEndOf="parent" />
-
-	<ImageView
-		android:id="@+id/inari"
-		android:layout_width="0dp"
-		android:layout_height="0dp"
-		android:layout_marginStart="16dp"
-		android:layout_marginTop="16dp"
-		android:layout_marginEnd="16dp"
-		android:layout_marginBottom="16dp"
-		android:alpha="0.25"
-		android:src="@drawable/inari"
-		app:layout_constraintBottom_toTopOf="@+id/floating_action_button"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/search_bar"
-		tool:ignore="ContentDescription" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/fragment_line_graph.xml b/app/src/main/res/layout/fragment_line_graph.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5b43b95eebb39f1326a01f634f1cbc53a66a0b2d
--- /dev/null
+++ b/app/src/main/res/layout/fragment_line_graph.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.otaliastudios.zoom.ZoomLayout 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="match_parent"
+	app:hasClickableChildren="true">
+
+	<androidx.recyclerview.widget.RecyclerView
+		android:id="@+id/recycler"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content" />
+
+</com.otaliastudios.zoom.ZoomLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/result.xml b/app/src/main/res/layout/result.xml
index 01fcf7e803bb9022ef16c39446fc50f1d5814e0c..b5296b119333850e23c7574bfd1ce952c5505ed6 100644
--- a/app/src/main/res/layout/result.xml
+++ b/app/src/main/res/layout/result.xml
@@ -16,31 +16,31 @@ 		android:layout_marginBottom="8dp"
 		app:layout_constraintBottom_toBottomOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
 		app:layout_constraintTop_toTopOf="parent"
+		tool:src="@drawable/vehicle_black"
 		tool:ignore="ContentDescription" />
 
+	<!-- todo maxWidth or separate layout for graphView -->
 	<com.google.android.material.textview.MaterialTextView
 		android:id="@+id/suggestion_title"
-		android:layout_width="0dp"
+		android:maxWidth="320dp"
+		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
 		android:layout_marginStart="8dp"
 		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		android:text=""
 		android:textAppearance="@style/Theme.Bimba.SearchResult.Title"
-		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toEndOf="@+id/suggestion_image"
-		app:layout_constraintTop_toTopOf="parent" />
+		app:layout_constraintTop_toTopOf="parent"
+		tool:text="Tower Hill" />
 
 	<com.google.android.material.textview.MaterialTextView
 		android:id="@+id/suggestion_description"
 		style="@style/Theme.Bimba.SearchResult.Description"
-		android:layout_width="0dp"
+		android:layout_width="wrap_content"
+		android:maxWidth="360dp"
 		android:layout_height="wrap_content"
-		android:layout_marginEnd="8dp"
-		android:text=""
 		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="@+id/suggestion_title"
-		app:layout_constraintTop_toBottomOf="@+id/suggestion_title" />
+		app:layout_constraintTop_toBottomOf="@+id/suggestion_title"
+		tool:text="Metropolitan » Baker Street, Tower Hill The Monument, Westminster, Piccadilly Circus, Oxford Street" />
 
 </androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/suggestion.xml b/app/src/main/res/layout/suggestion.xml
deleted file mode 100644
index 1d681d33fae944bf52f8243a589aab9b1aefdcce..0000000000000000000000000000000000000000
--- a/app/src/main/res/layout/suggestion.xml
+++ /dev/null
@@ -1,48 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<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:id="@+id/suggestion"
-	android:layout_width="match_parent"
-	android:layout_height="72dp">
-
-	<ImageView
-		android:id="@+id/suggestion_image"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="8dp"
-		android:layout_marginBottom="8dp"
-		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toTopOf="parent"
-		tool:ignore="ContentDescription" />
-
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/suggestion_title"
-		android:layout_width="0dp"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		android:text=""
-		android:textAppearance="@style/Theme.Bimba.SearchResult.Title"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toEndOf="@+id/suggestion_image"
-		app:layout_constraintTop_toTopOf="parent" />
-
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/suggestion_description"
-		style="@style/Theme.Bimba.SearchResult.Description"
-		android:layout_width="0dp"
-		android:layout_height="0dp"
-		android:layout_marginEnd="8dp"
-		android:ellipsize="end"
-		android:maxLines="4"
-		android:text=""
-		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="@+id/suggestion_title"
-		app:layout_constraintTop_toBottomOf="@+id/suggestion_title" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file




diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 7353dbd1fd82487df2d06121f85f7994728f1070..b3d0bc57c5d13dc7dd966884fb4ef9a513ee35f8 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -2,4 +2,5 @@ 
 <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
     <background android:drawable="@color/ic_launcher_background"/>
     <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+    <monochrome android:drawable="@drawable/ic_launcher_foreground_mono"/>
 </adaptive-icon>
\ No newline at end of file




diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 7353dbd1fd82487df2d06121f85f7994728f1070..b3d0bc57c5d13dc7dd966884fb4ef9a513ee35f8 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -2,4 +2,5 @@ 
 <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
     <background android:drawable="@color/ic_launcher_background"/>
     <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+    <monochrome android:drawable="@drawable/ic_launcher_foreground_mono"/>
 </adaptive-icon>
\ No newline at end of file




diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f32859adeab279291dc0da8294a9124ae7224571..15cd86d996b80e87b7c495217fc13d1637c9d092 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,7 +4,7 @@ 	Home
 	<string name="title_map">Map</string>
 	<string name="title_voyage">Voyage</string>
 	<string name="home_fab_description">GPS icon</string>
-	<string name="search_placeholder">Search stops and lines</string>
+	<string name="search_placeholder">stops, lines, or plus codes</string> <!-- and lines -->
 	<string name="title_activity_results">Results</string>
 	<string name="cont">Continue</string>
 	<string name="save">Save</string>
@@ -17,12 +17,11 @@ 	There was an error on the sever. Try again later
 	<string name="error_unknown">Unknown error happened</string>
 	<string name="error_connecting">Error connecting to the server. Try again later</string> <!-- send a bug report to bimba@git.apiote.xyz, details are: url=$URL, response=$response -->
 	<string name="error_offline">You are offline. Connect to the Internet</string>
-	<string name="error_gps">Cannot obtain location</string>
+	<string name="error_gps">Cannot obtain current location</string>
 	<string name="no_departures">No departures</string>
 	<string name="waiting_position">waiting for position</string>
-	<string name="vehicle_headsign">%s » %s</string>
-	<string name="vehicle_headsign_content_description">%s towards %s</string>
-	<string name="speed_in_km_per_h">%.3f km/h</string>
+	<string name="vehicle_headsign">%1$s » %2$s</string>
+	<string name="speed_in_km_per_h">%1$.3f km/h</string>
 	<string name="congestion_unknown">unknown</string>
 	<string name="congestion_smooth">smooth</string>
 	<string name="congestion_stop_and_go">stop and go</string>
@@ -36,25 +35,25 @@ 	standing only
 	<string name="occupancy_crowded">crowded</string>
 	<string name="occupancy_full">full</string>
 	<string name="occupancy_wont_let">won’t let in</string>
-	<string name="stop_title">%s [%s]</string>
 	<string name="no_map_app">No maps app installed</string>
-	<string name="departure_headsign">» %s</string>
-	<string name="departure_headsign_content_description">towards %s</string>
+	<string name="departure_headsign_content_description">towards %1$s</string>
 	<string name="departure_momentarily">momentarily</string>
 	<string name="departure_departed">departed</string>
 	<string name="departure_now">now</string>
-	<string name="at_time">at %02d:%02d</string>
-	<string name="at_time_realtime">at %02d:%02d:%02d</string>
+	<string name="at_time">at %1$02d:%2$02d</string>
+	<string name="at_time_realtime">at %1$02d:%2$02d:%3$02d</string>
 	<string name="on_demand">on demand</string>
 	<string name="no_boarding">no boarding</string>
 	<string name="on_boarding">on-boarding</string>
 	<string name="off_boarding">off-boarding</string>
 	<string name="boarding">can board</string>
-	<string name="line_headsigns">%s «» %s</string>
-	<string name="line_headsigns_content_description">between %s and %s</string>
+	<string name="line_headsign">» %1$s</string>
+	<string name="line_headsign_content_description">towards %1$s</string>
+	<string name="line_headsigns">%1$s «» %2$s</string>
+	<string name="line_headsigns_content_description">between %1$s and %2$s</string>
 	<string name="stops_nearby">Stops nearby</string>
-	<string name="results_for">Results for ‘%s’</string>
-	<string name="bimba_server_address_hint">Bimba server</string>
+	<string name="results_for">Results for ‘%1$s’</string>
+	<string name="bimba_server_address_hint">Server</string>
 	<string name="bimba_server_token_hint">Token</string>
 	<string name="bimba_server_continue_button">Continue</string>
 	<string name="realtime_content_description">departure is realtime</string>
@@ -67,10 +66,10 @@ 	USB charging
 	<string name="show_departures">Show departures</string>
 	<string name="open_in_maps_app">Open in maps app</string>
 	<string name="stop_content_description">stop</string>
-	<string name="seatbelts_everyone">Seatbelts, everyone!</string> <!-- taken from ‘Magic School Bus’. Should be translated like in the series -->
+	<string name="seatbelts_everyone">Seatbelts, everyone!</string> <!-- taken from ‘Magic School Bus’. Should be translated like in the series. It’s the first words of the intro song -->
 	<string name="onboarding_question">How would you like to start?</string>
 	<string name="onboarding_simple">Simple</string>
-	<string name="onboarding_simple_action">choose cities</string>
+	<string name="onboarding_simple_action">choose localities</string>
 	<string name="onboarding_advanced">Advanced</string>
 	<string name="onboarding_advanced_action">choose server</string>
 	<string name="cancel">Cancel</string>
@@ -78,10 +77,21 @@ 	Error
 	<string name="rate_limit">Rate limit</string>
 	<string name="server_rate_limited_question">This server is rate-limited and no token was given. Do you want to continue?</string>
 	<string name="server_private_question">This server is private and no token was given</string>
-	<string name="last_update">Last update: %s</string>
-	<string name="title_feeds">Feeds</string>
+	<string name="last_update">Last update: %1$s</string>
+	<string name="title_feeds">Timetables</string>
 	<string name="title_servers">Servers</string>
-	<string name="title_cities">Cities</string>
+	<string name="title_cities">Localities</string>
 	<string name="error_url">Malformed URL provided</string>
-	<string name="error_traffic_spec">Cannot verify traffic server</string>
+	<string name="error_traffic_spec">Cannot verify server</string>
+	<string name="stops_near_code">Stops near %1$s</string>
+	<string name="code_is_not_full">Code is not full</string>
+	<string name="choose_server">Choose server flavour</string>
+	<string name="ok">OK</string>
+	<string name="no_location_access">Location access not given</string>
+	<string name="no_location_message">Permission to use location is needed to find nearby stops and show current position on map. Other features will work without it. It can be enabled and disabled in system settings any time.</string>
+	<string name="stop_stub_on_demand_in_zone">Stop on demand in zone %1$s</string>
+	<string name="stop_stub_on_demand">Stop on demand</string>
+	<string name="stop_stub_in_zone">Stop in zone %1$s</string>
+	<string name="vehicle_headsign_content_description">%1$s towards %2$s</string>
+	<string name="departure_headsign">» %1$s</string>
 </resources>
\ No newline at end of file




diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index dc24f789c068396350617f6166ef162d6478a0bc..5b2c8f803785fa0b966b9cdb2438b75350b64640 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -39,26 +39,13 @@ 	
 		<attr name="lightStatusBar" format="boolean" />
 	</declare-styleable>
 
-	<style name="Theme.Bimba.SearchBar" parent="MaterialSearchBarLight">
-		<item name="mt_searchBarColor">@color/md_theme_light_surfaceVariant</item>
-		<item name="mt_textColor">@color/md_theme_light_onSurfaceVariant</item>
-		<item name="mt_placeholderColor">@color/md_theme_light_onSurfaceVariant
-		</item> <!-- todo(ui) grey out -->
-		<item name="mt_backIconTint">@color/md_theme_light_onSurfaceVariant</item>
-		<item name="mt_navIconTint">@color/md_theme_light_onSurfaceVariant</item>
-		<item name="mt_searchIconTint">@color/md_theme_light_onSurfaceVariant</item>
-		<item name="mt_menuIconTint">@color/md_theme_light_onSurfaceVariant</item>
-		<item name="mt_clearIconTint">@color/md_theme_light_onSurfaceVariant</item>
-	</style>
-
 	<style name="Theme.Bimba.SearchResult.Title" parent="Theme.Bimba">
 		<item name="android:textColor">@color/md_theme_light_onSurfaceVariant</item>
 		<item name="android:textSize">16sp</item>
 	</style>
 
 	<style name="Theme.Bimba.SearchResult.Description" parent="Theme.Bimba">
-		<item name="android:textColor">@color/md_theme_light_onSurfaceVariant
-		</item> <!-- todo(ui) grey out -->
+		<item name="android:textColor">@color/md_theme_light_onSurfaceVariant</item>
 		<item name="android:textSize">9sp</item>
 	</style>
 
@@ -69,4 +56,9 @@ 		@style/Theme.Bimba
 	</style>
 
 	<style name="Theme.Bimba.Style" />
+
+	<style name="Theme.Bimba.Style.NoActionBar">
+		<item name="windowActionBar">false</item>
+		<item name="windowNoTitle">true</item>
+	</style>
 </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
new file mode 100644
index 0000000000000000000000000000000000000000..3113ea79b8afd8472741695e91e2cd1408c8cb95
--- /dev/null
+++ b/app/src/main/res/values-it/strings.xml
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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>
+</resources>
\ No newline at end of file




diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 64131505cc055ae8432e5e6f350e280e352cd614..547f30d8a5dc214a04477654ce2df7019f454207 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -39,24 +39,13 @@ 	
 		<attr name="lightStatusBar" format="boolean" />
 	</declare-styleable>
 
-	<style name="Theme.Bimba.SearchBar" parent="MaterialSearchBarLight">
-		<item name="mt_searchBarColor">@color/md_theme_dark_surfaceVariant</item>
-		<item name="mt_textColor">@color/md_theme_dark_onSurfaceVariant</item>
-		<item name="mt_placeholderColor">@color/md_theme_dark_onSurfaceVariant</item> <!-- todo(ui) grey out -->
-		<item name="mt_backIconTint">@color/md_theme_dark_onSurfaceVariant</item>
-		<item name="mt_navIconTint">@color/md_theme_dark_onSurfaceVariant</item>
-		<item name="mt_searchIconTint">@color/md_theme_dark_onSurfaceVariant</item>
-		<item name="mt_menuIconTint">@color/md_theme_dark_onSurfaceVariant</item>
-		<item name="mt_clearIconTint">@color/md_theme_dark_onSurfaceVariant</item>
-	</style>
-
 	<style name="Theme.Bimba.SearchResult.Title" parent="Theme.Bimba">
 		<item name="android:textColor">@color/md_theme_dark_onSurfaceVariant</item>
 		<item name="android:textSize">16sp</item>
 	</style>
 
 	<style name="Theme.Bimba.SearchResult.Description" parent="Theme.Bimba">
-		<item name="android:textColor">@color/md_theme_dark_onSurfaceVariant</item> <!-- todo(ui) grey out -->
+		<item name="android:textColor">@color/md_theme_dark_onSurfaceVariant</item>
 		<item name="android:textSize">9sp</item>
 	</style>
 </resources>
\ No newline at end of file




diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7d1d73f687b3fb91a86981f4d12bf7c407e5a21e
--- /dev/null
+++ b/app/src/main/res/values-night-v31/themes.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+	<style name="Theme.Bimba" parent="Theme.Material3.Dark.NoActionBar">
+		<item name="android:fontFamily">@font/yellowcircle8</item>
+		<item name="colorPrimary">@android:color/system_accent1_300</item>
+		<item name="colorOnPrimary">@android:color/system_accent1_800</item>
+		<item name="colorPrimaryContainer">@android:color/system_accent1_600</item>
+		<item name="colorOnPrimaryContainer">@android:color/system_accent1_50</item>
+		<item name="colorSecondary">@android:color/system_accent3_100</item>
+		<item name="colorOnSecondary">@android:color/system_accent3_800</item>
+		<item name="colorSecondaryContainer">@android:color/system_accent3_700</item>
+		<item name="colorOutline">@android:color/system_neutral1_400</item>
+		<item name="colorOnSecondaryContainer">@android:color/system_accent3_50</item>
+		<item name="colorPrimaryInverse">@android:color/system_accent1_600</item>
+
+		<item name="android:colorBackground">@android:color/system_neutral1_900</item>
+		<item name="colorOnBackground">@android:color/system_neutral1_50</item>
+
+		<item name="colorSurface">@android:color/system_neutral1_900</item>
+		<item name="colorOnSurface">@android:color/system_neutral1_50</item>
+		<item name="colorSurfaceVariant">@android:color/system_neutral2_600</item>
+		<item name="colorOnSurfaceVariant">@android:color/system_neutral2_100</item>
+		<item name="colorSurfaceInverse">@android:color/system_neutral1_50</item>
+		<item name="colorOnSurfaceInverse">@android:color/system_neutral1_900</item>
+
+		<item name="colorTertiary">@color/md_theme_dark_tertiary</item>
+		<item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item>
+		<item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item>
+		<item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item>
+		<item name="colorError">@color/md_theme_dark_error</item>
+		<item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item>
+		<item name="colorOnError">@color/md_theme_dark_onError</item>
+		<item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item>
+
+		<item name="statusBarBackground">@android:color/transparent</item>
+		<item name="android:statusBarColor">@android:color/transparent</item>
+		<item name="android:enforceStatusBarContrast">false</item>
+		<item name="lightStatusBar">false</item>
+	</style>
+
+	<declare-styleable name="Theme.Bimba">
+		<attr name="lightStatusBar" format="boolean" />
+	</declare-styleable>
+
+	<style name="Theme.Bimba.SearchResult.Title" parent="Theme.Bimba">
+		<item name="android:textColor">@android:color/system_neutral2_100</item>
+		<item name="android:textSize">16sp</item>
+	</style>
+
+	<style name="Theme.Bimba.SearchResult.Description" parent="Theme.Bimba">
+		<item name="android:textColor">@android:color/system_neutral2_100</item>
+		<item name="android:textSize">9sp</item>
+	</style>
+</resources>
\ No newline at end of file




diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4e11a150fad036d9214d9317b51062ab9138ba59
--- /dev/null
+++ b/app/src/main/res/values-pl/strings.xml
@@ -0,0 +1,98 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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>
+</resources>
\ No newline at end of file




diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fc3b00ed0156f663b6f3164d675558159b4fa446
--- /dev/null
+++ b/app/src/main/res/values-v31/themes.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+	<style name="Theme.Bimba" parent="Theme.Material3.Light.NoActionBar">
+		<item name="android:fontFamily">@font/yellowcircle8</item>
+		<item name="colorPrimary">@android:color/system_accent1_600</item>
+		<item name="colorOnPrimary">@android:color/system_accent1_0</item>
+		<item name="colorPrimaryContainer">@android:color/system_accent1_200</item>
+		<item name="colorOnPrimaryContainer">@android:color/system_accent1_900</item>
+		<item name="colorPrimaryInverse">@android:color/system_accent1_300</item>
+		<item name="colorSecondary">@android:color/system_accent3_600</item>
+		<item name="colorOnSecondary">@android:color/system_accent3_0</item>
+		<item name="colorSecondaryContainer">@android:color/system_accent3_200</item>
+		<item name="colorOnSecondaryContainer">@android:color/system_accent3_800</item>
+
+		<item name="android:colorBackground">@android:color/system_neutral1_10</item>
+		<item name="colorOnBackground">@android:color/system_neutral1_900</item>
+
+		<item name="colorSurface">@android:color/system_neutral1_10</item>
+		<item name="colorOnSurface">@android:color/system_neutral1_900</item>
+		<item name="colorSurfaceVariant">@android:color/system_neutral2_100</item>
+		<item name="colorOnSurfaceVariant">@android:color/system_neutral2_700</item>
+		<item name="colorOutline">@android:color/system_neutral2_500</item>
+		<item name="colorSurfaceInverse">@android:color/system_neutral1_900</item>
+		<item name="colorOnSurfaceInverse">@android:color/system_neutral1_100</item>
+
+		<item name="colorTertiary">@color/md_theme_light_tertiary</item>
+		<item name="colorOnTertiary">@color/md_theme_light_onTertiary</item>
+		<item name="colorTertiaryContainer">@color/md_theme_light_tertiaryContainer</item>
+		<item name="colorOnTertiaryContainer">@color/md_theme_light_onTertiaryContainer</item>
+		<item name="colorError">@color/md_theme_light_error</item>
+		<item name="colorErrorContainer">@color/md_theme_light_errorContainer</item>
+		<item name="colorOnError">@color/md_theme_light_onError</item>
+		<item name="colorOnErrorContainer">@color/md_theme_light_onErrorContainer</item>
+
+		<item name="statusBarBackground">@android:color/transparent</item>
+		<item name="android:statusBarColor">@android:color/transparent</item>
+		<item name="android:enforceStatusBarContrast">false</item>
+		<item name="lightStatusBar">true</item>
+	</style>
+
+	<style name="Theme.Bimba.SearchResult.Title" parent="Theme.Bimba">
+		<item name="android:textColor">@android:color/system_neutral2_700</item>
+		<item name="android:textSize">16sp</item>
+	</style>
+
+	<style name="Theme.Bimba.SearchResult.Description" parent="Theme.Bimba">
+		<item name="android:textColor">@android:color/system_neutral2_700</item>
+		<item name="android:textSize">9sp</item>
+	</style>
+
+	<style name="Theme.Bimba.Splash" parent="Theme.SplashScreen">
+		<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
+		<item name="windowSplashScreenIconBackgroundColor">@color/ic_launcher_background</item>
+		<item name="postSplashScreenTheme">@style/Theme.Bimba</item>
+	</style>
+</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
new file mode 100644
index 0000000000000000000000000000000000000000..9053779f7de170c3876d04d1367660902df06b00
--- /dev/null
+++ b/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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/build.gradle b/build.gradle
index 73d1e088368f91b43d6e9b60b37550773df667cd..1ba30930324f15120b9a13f221aff3df1e5bbc94 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,7 +1,7 @@
 // Top-level build file where you can add configuration options common to all sub-projects/modules.
 plugins {
-    id 'com.android.application' version '7.4.2' apply false
-    id 'com.android.library' version '7.4.2' apply false
+    id 'com.android.application' version '8.0.2' apply false
+    id 'com.android.library' version '8.0.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




diff --git a/fruchtfleisch/build.gradle b/fruchtfleisch/build.gradle
index e7ceb4cce7d2dcbc650fa806ead2fcfa26ba232e..730e890c34aca32c1cf7c79d430c86a9f204929a 100644
--- a/fruchtfleisch/build.gradle
+++ b/fruchtfleisch/build.gradle
@@ -3,10 +3,10 @@     id 'java-library'
     id 'org.jetbrains.kotlin.jvm'
 }
 
-java {
-    sourceCompatibility = JavaVersion.VERSION_1_8
-    targetCompatibility = JavaVersion.VERSION_1_8
-}
 dependencies {
     //implementation 'org.jetbrains.kotlin:kotlin-reflect:1.8.10'
+}
+java {
+    sourceCompatibility = JavaVersion.VERSION_17
+    targetCompatibility = JavaVersion.VERSION_17
 }
\ 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 9d49df734169f7b2934d7897e89311653de7139c..1f22ccb5807e8f6cf8c60ad24e3dfa1a42aaef88 100644
--- a/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt
+++ b/fruchtfleisch/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt
@@ -30,6 +30,7 @@ 				}
 				result = result.or(b.toULong().shl(s))
 				break
 			}
+			result = result.or(b.toULong().and(127u).shl(s))
 			i++
 			s += 7
 		}
@@ -108,14 +109,17 @@ 		val data = ByteArray(n)
 		var left = n
 		while (left > 0) {
 			val r = stream.read(data, n - left, left)
+			if (r == -1) {
+				break
+			}
 			left -= r
 		}
 		return data
 	}
 
 	fun readString(): String {
-		val length = readU8()
-		return readData(length.toInt()).decodeToString()
+		val length = readUInt()
+		return readData(length.toULong().toInt()).decodeToString()
 	}
 
 	fun readBoolean(): Boolean {




diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 72a0f4c89357e1fdc5ad0b2d63031ab0a9475f5b..42fc8d468a93cc5dcfef291ae05846be0aec1eea 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
 #Tue Aug 09 15:48:25 CEST 2022
 distributionBase=GRADLE_USER_HOME
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
 distributionPath=wrapper/dists
 zipStorePath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME




diff --git a/gradle.properties b/gradle.properties
index cd0519bb2a9450033b80e5906f766b71d176014f..875684c48eaaa04dfd77ffe09bb93c76fc49345a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,7 @@ kotlin.code.style=official
 # Enables namespacing of each library's R class so that its R class includes only the
 # resources declared in the library itself and none from the library's dependencies,
 # thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.buildconfig=true
+android.nonFinalResIds=true
+org.gradle.unsafe.configuration-cache=true
\ No newline at end of file




diff --git a/inari.svg b/inari.svg
deleted file mode 100644
index 33ef0449f475b445414c384d7050c0c552173d37..0000000000000000000000000000000000000000
--- a/inari.svg
+++ /dev/null
@@ -1,122 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" standalone="no"?>
-<!-- Created with Inkscape (http://www.inkscape.org/) -->
-
-<svg
-   width="66.98246mm"
-   height="55.715553mm"
-   viewBox="0 0 66.98246 55.715553"
-   version="1.1"
-   id="svg5"
-   inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
-   sodipodi:docname="drawing.svg"
-   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
-   xmlns="http://www.w3.org/2000/svg">
-  <sodipodi:namedview
-     id="namedview7"
-     pagecolor="#505050"
-     bordercolor="#eeeeee"
-     borderopacity="1"
-     inkscape:pageshadow="0"
-     inkscape:pageopacity="0"
-     inkscape:pagecheckerboard="0"
-     inkscape:document-units="mm"
-     showgrid="false"
-     inkscape:zoom="2.9362514"
-     inkscape:cx="109.66363"
-     inkscape:cy="116.30476"
-     inkscape:window-width="1920"
-     inkscape:window-height="1007"
-     inkscape:window-x="0"
-     inkscape:window-y="0"
-     inkscape:window-maximized="1"
-     inkscape:current-layer="layer1"
-     fit-margin-top="0"
-     fit-margin-left="0"
-     fit-margin-right="0"
-     fit-margin-bottom="0" />
-  <defs
-     id="defs2" />
-  <g
-     inkscape:label="Layer 1"
-     inkscape:groupmode="layer"
-     id="layer1"
-     transform="translate(-68.809603,-134.76337)">
-    <g
-       id="g93742"
-       style="opacity:1">
-      <ellipse
-         style="fill:#567ca0;fill-opacity:1;stroke-width:0.352122"
-         id="path76"
-         cx="74.383362"
-         cy="147.65054"
-         rx="5.5737591"
-         ry="5.313138" />
-      <rect
-         style="fill:#b5c5c5;fill-opacity:1;stroke-width:0.264866"
-         id="rect180"
-         width="0.98591608"
-         height="33.420025"
-         x="73.769379"
-         y="152.92934"
-         ry="0" />
-      <rect
-         style="fill:#798595;fill-opacity:1;stroke-width:0.264583"
-         id="rect284"
-         width="4.1957474"
-         height="7.8133383"
-         x="72.240288"
-         y="160.08839" />
-      <path
-         id="rect286"
-         style="fill:#3f413c;fill-opacity:1;stroke-width:0.264583"
-         d="m 69.122551,186.42857 h 10.616875 v 3.85714 c -3.61761,0.26681 -7.1511,0.24826 -10.616875,0 z"
-         sodipodi:nodetypes="ccccc" />
-    </g>
-    <g
-       id="g93748"
-       style="opacity:1">
-      <path
-         style="fill:#53221b;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-         d="m 85.111539,167.81735 5.761685,-3.25446 5.651425,-0.18282 6.146911,0.29303 4.65229,2.21507 -0.004,1.03917 -3.42174,2.39617 -13.590806,0.26858 z"
-         id="path573" />
-      <path
-         id="rect995"
-         style="fill:#5d1c18;fill-opacity:1;stroke-width:0.999999"
-         d="m 364.35938,609.69922 v 47.28516 2.73046 h 1.83007 v -2.73046 -47.28516 z"
-         transform="scale(0.26458333)" />
-      <path
-         style="fill:#da4e59;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-         d="m 107.32381,166.88813 c 0.17887,-0.70857 -0.37741,-1.39599 -0.85581,-1.87043 -0.55448,-0.54375 -1.32405,-1.41023 -1.88439,-1.95734 -0.99251,-0.57802 -2.00332,-0.8984 -2.9239,-1.58632 -0.71176,-0.69646 -1.61272,-0.54635 -2.504459,-0.85685 -0.987749,-0.38335 -2.036605,-0.15004 -3.061415,-0.16444 -1.122642,0.12445 -2.25529,0.27454 -3.381729,0.40037 -0.863483,0.15571 -1.681674,0.48755 -2.422128,0.94552 -0.867217,0.26214 -1.547411,0.82582 -2.185826,1.44612 -0.789259,0.55813 -1.351825,1.34121 -2.036629,2.01205 -0.79524,0.5884 -0.879605,1.65158 -0.955985,2.56054 l 5.761685,-3.25446 5.651425,-0.18282 6.146911,0.29303 z"
-         id="path610"
-         sodipodi:nodetypes="ccccccccccc" />
-      <path
-         id="rect1099"
-         style="fill:#743027;fill-opacity:1;stroke-width:0.264583"
-         d="m 96.403603,174.54978 h 1.29989 v 0 c 0,0 0.182881,2.34872 -0.629739,2.3552 -0.816203,0.007 -0.670151,-2.3552 -0.670151,-2.3552 z"
-         sodipodi:nodetypes="cccac" />
-    </g>
-    <path
-       style="opacity:1;fill:#586c24;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
-       d="m 124.288,137.28695 c 1.55667,0.0247 3.11335,0.0494 4.67002,0.0741 0.0656,-0.39103 0.14672,-0.81251 0.24808,-1.21017 0.22665,-0.37332 0.0954,-0.84353 0.26759,-1.20237 0.14774,-0.13672 0.64826,-0.36418 0.70598,0.0579 0.11517,0.41977 0.12391,0.92662 -0.058,1.3294 -0.10087,0.35297 -0.22333,0.72336 -0.38857,1.03184 1.59878,0.008 3.19756,0.0169 4.79634,0.0254 0.19706,0.32618 0.59875,0.21796 0.91466,0.39235 0.45633,0.0959 0.35374,0.5962 0.2949,1.00893 -0.32729,0.22892 -0.43162,0.6555 -0.8307,0.80451 -0.22902,0.28261 -0.57205,0.53644 -0.98158,0.35787 -0.51169,0.15718 -1.00745,-10e-4 -1.51752,-0.007 -0.38644,-0.0184 -0.68374,0.24067 -1.04592,0.36592 -0.39795,0.17617 -0.77318,0.43648 -1.05834,0.75772 -0.4346,0.48119 -0.95467,0.0349 -1.47961,0.0659 -0.41668,-0.1197 -0.69595,-0.41392 -1.07726,-0.616 -0.42922,-0.21433 -0.83971,-0.47247 -1.20031,-0.78722 -0.40077,-0.25153 -0.87849,-0.37984 -1.18117,-0.7678 -0.30003,-0.35635 -0.87962,-0.39383 -1.0307,-0.86207 -0.0807,-0.24907 -0.15484,-0.57474 -0.0479,-0.81961 z"
-       id="path2287" />
-    <path
-       sodipodi:type="star"
-       style="opacity:1;fill:#ffe438;fill-opacity:1;stroke:none"
-       id="path96214"
-       inkscape:flatsided="false"
-       sodipodi:sides="5"
-       sodipodi:cx="268.70459"
-       sodipodi:cy="546.09521"
-       sodipodi:r1="27.938145"
-       sodipodi:r2="13.969073"
-       sodipodi:arg1="0.98751558"
-       sodipodi:arg2="1.6158341"
-       inkscape:rounded="0"
-       inkscape:randomized="0"
-       d="m 284.09195,569.41407 -16.01628,-9.36395 -16.79368,7.88527 3.95635,-18.12601 -12.68887,-13.53506 18.46144,-1.83853 8.95153,-16.25039 7.45345,16.98973 18.22121,3.49176 -13.85495,12.33877 z"
-       transform="matrix(0.13064095,0,0,0.13064095,39.153983,76.367755)"
-       inkscape:transform-center-x="-0.05077931"
-       inkscape:transform-center-y="-0.29988515" />
-  </g>
-</svg>




diff --git a/metadata/en-US/changelogs/22.txt b/metadata/en-US/changelogs/22.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9a3dfaf104f264406ce5bf6e8a286c22cebe53dd
--- /dev/null
+++ b/metadata/en-US/changelogs/22.txt
@@ -0,0 +1,11 @@
+Version 3.1 features:
+* monochrome launcher icon and predictive back gesture for Android 12,
+* Material You theme with system colours,
+* new search bar,
+* searching by Open Location Code,
+* gliding instead of jumping in minimap,
+* Polish and Italian translations,
+* multiple feeds handling,
+* QR codes in Metropolis GZM,
+* searching by line (and picking stops on graphs),
+* dependency updates, bug fixes, and some code refactor.
\ No newline at end of file




diff --git a/release.sh b/release.sh
new file mode 100755
index 0000000000000000000000000000000000000000..438f8a3ec2a920bfc05e18d8fbed02f6d035551f
--- /dev/null
+++ b/release.sh
@@ -0,0 +1,101 @@
+#!/bin/sh
+
+releaseType=""
+phase=0
+case "$1" in
+	major|minor|patch) releaseType=$1 ;;
+	-c) phase=1 ;;
+	*)
+		echo "no release type given or -c given"
+		exit 1
+		;;
+esac
+
+if [ $phase -eq 0 ]
+then
+	if [ "$(git status -s | wc -l)" -ne 0 ]
+	then
+		echo "uncommited changes"
+		git status -s
+		echo "continue? [y/N]"
+		read -r decision
+		if [ "$decision" != 'y' ]
+		then
+			echo 'aborting'
+			exit 0
+		fi
+	fi
+
+	if [ "$(git log '@{u}..' | wc -l)" -ne 0 ]
+	then
+		echo "unpushed changes"
+		git status -s
+		echo "continue? [y/N/p]"
+		read -r decision
+		if [ "$decision" != 'y' ]
+		then
+			echo 'aborting'
+			exit 0
+		elif [ "$decision" = 'p' ]
+		then
+			git push
+			sleep 5
+		fi
+	fi
+
+	retry="1"
+	latestCI=$(curl https://ci.apiote.xyz/toys/czwek-commitly/latest 2>/dev/null)
+	latestCIstatus=$(echo "$latestCI" | grep '<h2' | sed 's/<h2[^>]*>//' | sed 's|</h2>||' | grep -oE '[A-Z]+')
+	latestCIstarted=$(echo "$latestCI" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+\+[0-9]{2}:[0-9]{2}' | head -n1)
+	while [ "$latestCIstatus" != 'OK' ] && [ "$retry" = "1" ]
+	do
+		echo "latest CI started at $latestCIstarted result is $latestCIstatus, not OK"
+		echo "retry? [y/N]"
+		read -r decision
+		if [ "$decision" != 'y' ]
+		then
+			retry="0"
+			exit 1
+		fi
+	done
+
+	currentVersionName=$(grep 'versionName' app/build.gradle | tr -s ' ' | cut -d ' ' -f3 | tr -d '"')
+	major=$(echo "$currentVersionName" | cut -d '.' -f1)
+	minor=$(echo "$currentVersionName" | cut -d '.' -f2)
+	patch=$(echo "$currentVersionName" | cut -d '.' -f3)
+	currentVersionCode=$(grep 'versionCode' app/build.gradle | tr -s ' ' | cut -d ' ' -f3)
+
+	case $releaseType in
+		major) newVersionName="$((major + 1)).0.0" ;;
+		minor) newVersionName="${major}.$((minor + 1)).0" ;;
+		patch) newVersionName="${major}.${minor}.$((patch + 1))" ;;
+		*) echo "wrong release type given"; exit 1 ;;
+	esac
+	newVersionCode=$((currentVersionCode + 1))
+
+	sed -i "s/versionName \"$currentVersionName\"/versionName \"$newVersionName\"/" app/build.gradle
+	sed -i "s/versionCode $currentVersionCode/versionCode $newVersionCode/" app/build.gradle
+
+	git shortlog "v${currentVersionName}..HEAD" >> "metadata/en-US/changelogs/$newVersionCode.txt"
+
+	echo "time to update changelogs"
+elif [ $phase -eq 1 ]
+then
+	newVersionName=$(grep 'versionName' app/build.gradle | tr -s ' ' | cut -d ' ' -f3 | tr -d '"')
+	newVersionCode=$(grep 'versionCode' app/build.gradle | tr -s ' ' | cut -d ' ' -f3)
+	if ! find metadata -type d -name changelogs -print0 | xargs -0 -I{} [ -f "{}/$newVersionCode.txt" ]
+	then
+		echo "not all languages have changelog"
+		exit 1
+	fi
+	git add app/build.gradle
+	git add metadata/
+	git commit -S -m "release version $newVersionName ($newVersionCode)"
+	git push
+	git switch master
+	git merge -S --no-ff -m "merge develop into master for version $newVersionName" develop
+	git tag -s -m "v${newVersionName}" "v${newVersionName}"
+	git push origin --tags
+	git switch develop
+	git merge master
+fi