Bimba.git

commit 6f3fdb0eb3ea6a58165f1485f624fbf82faadf17

Author: Adam <git@apiote.xyz>

add line graph

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


diff --git a/app/build.gradle b/app/build.gradle
index 38c18d116578de85dcf8952bc44b3db958c0012f..f9dfb822e4edf7551e2d829f6cbb227e7094b5cc 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -57,6 +57,8 @@     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')
 




diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f59bf39163d2835d898792459c0746d7fb5424b2..7052fb8d22eb570199fb066418e6551e7bb45410 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -21,6 +21,10 @@ 		android:supportsRtl="true"
 		android:theme="@style/Theme.Bimba.Style"
 		tool:targetApi="33">
 		<activity
+			android:name=".search.LineGraphActivity"
+			android:exported="false"
+			android:theme="@style/Theme.Bimba.Style.NoActionBar" />
+		<activity
 			android:name=".settings.ServerChooserActivity"
 			android:exported="false">
 			<meta-data




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 35ba1348dcc65e4da149fabe8ca54732cbf3f998..4cba8902ca3652ee80da5ecd0c4dd4f10ed28c46 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
@@ -65,11 +65,11 @@ 	val params = mutableMapOf("q" to query)
 	if (limit != null) {
 		params["limit"] = limit.toString()
 	}
-	return request(server, "queryables", params, cm, arrayOf(1u), null)
+	return request(server, "queryables", null, params, cm, arrayOf(1u, 0u), null)
 }
 
 suspend fun locateQueryables(cm: ConnectivityManager, server: Server, near: PositionV1): Result {
-	return request(server, "queryables", mapOf("near" to near.toString()), cm, arrayOf(1u), null)
+	return request(server, "queryables", null, mapOf("near" to near.toString()), cm, arrayOf(1u, 0u), null)
 }
 
 suspend fun getLocatablesIn(
@@ -78,13 +78,18 @@ ): Result {
 	return request(
 		server,
 		"locatables",
+		null,
 		mapOf("lb" to bl.toString(), "rt" to tr.toString()),
 		cm,
-		arrayOf(1u),
+		arrayOf(1u, 0u),
 		null
 	)
 }
 
+suspend fun getLine(cm: ConnectivityManager, server: Server, feedID: String, line: String): Result {
+	return request(server, "lines", line, mapOf(), cm, arrayOf(1u, 0u), feedID)
+}
+
 suspend fun getDepartures(
 	cm: ConnectivityManager, server: Server, feedID: String, stop: String, line: String? = null
 ): Result {
@@ -92,7 +97,7 @@ 	val params = mutableMapOf("code" to stop)
 	if (line != null) {
 		params["line"] = line
 	}
-	return request(server, "departures", params, cm, arrayOf(1u), feedID)
+	return request(server, "departures", null, params, cm, arrayOf(1u, 0u), feedID)
 }
 
 suspend fun rawRequest(
@@ -134,6 +139,7 @@
 suspend fun request(
 	server: Server,
 	resource: String,
+	item: String?,
 	params: Map<String, String>,
 	cm: ConnectivityManager,
 	responseVersion: Array<UInt>,
@@ -142,6 +148,12 @@ ): Result {
 	return withContext(Dispatchers.IO) {
 		val url = URL( // todo [3.1] 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/Responses.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Responses.kt
index 2a0606d9c377a2fc44dc5b4ef879de6e96545c44..411ff36c468c9920ecbf9bd182cf5f676b5f8e67 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
@@ -5,6 +5,39 @@ import java.io.InputStream
 
 class UnknownResponseVersion(val resource: String, val version: ULong) : Exception()
 
+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 {




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 c1b231a82b20e4d37216e0e3e9ace145a7dd9908..0b620b1604ee37b25998b635562bfb57c263794b 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
@@ -383,37 +383,11 @@ 	val name: String,
 	val colour: ColourV1,
 	val type: LineTypeV2,
 	val feedID: String,
-	val headsigns: Array<List<String>>,
-	val graphs: Array<LineGraph>,
+	val headsigns: List<List<String>>,
+	val graphs: List<LineGraph>,
 ) : QueryableV2 {
 	override fun toString(): String {
-		return "$name ($type) [$colour]\n→ [${headsigns[0].joinToString()}]\n→ [${headsigns[0].joinToString()}]\n"
-	}
-
-	override fun equals(other: Any?): Boolean {
-		if (this === other) return true
-		if (javaClass != other?.javaClass) return false
-
-		other as LineV1
-
-		if (name != other.name) return false
-		if (colour != other.colour) return false
-		if (type != other.type) return false
-		if (feedID != other.feedID) return false
-		if (!headsigns.contentEquals(other.headsigns)) return false
-		if (!graphs.contentEquals(other.graphs)) return false
-
-		return true
-	}
-
-	override fun hashCode(): Int {
-		var result = name.hashCode()
-		result = 31 * result + colour.hashCode()
-		result = 31 * result + type.hashCode()
-		result = 31 * result + feedID.hashCode()
-		result = 31 * result + headsigns.contentHashCode()
-		result = 31 * result + graphs.contentHashCode()
-		return result
+		return "$name ($type) [$colour]\n${headsigns.map { "-> ${it.joinToString()}" }}"
 	}
 
 	companion object {
@@ -423,22 +397,27 @@ 			val name = reader.readString()
 			val colour = ColourV1.unmarshal(stream)
 			val type = reader.readUInt()
 			val feedID = reader.readString()
-			val headsigns = arrayOf<List<String>>(mutableListOf(), mutableListOf())
-			for (i in 0..1) {
+			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) {
-					(headsigns[i] as MutableList).add(reader.readString())
+					headsignsDir.add(reader.readString())
 				}
+				headsignsDir
 			}
-			val graphThere = LineGraph.unmarshal(stream)
-			val graphBack = LineGraph.unmarshal(stream)
+			directionsNum = reader.readUInt().toULong()
+			val graphs = mutableListOf<LineGraph>()
+			for (i in 0UL until directionsNum) {
+				graphs.add(LineGraph.unmarshal(stream))
+			}
 			return LineV1(
 				name = name,
 				colour = colour,
 				type = LineTypeV2.of(type.toULong().toUInt()),
 				feedID = feedID,
 				headsigns = headsigns,
-				graphs = arrayOf(graphThere, graphBack)
+				graphs = graphs
 			)
 		}
 	}




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 a991106dd7b82f9ddb37b15f450b127c75cddbdc..4c8d7a0a8146a5c88ecdcffa186e992714cf6f1a 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
@@ -109,13 +109,20 @@ 			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 {
 			try {
 				val repository = OnlineRepository()
 				val stopDepartures =
-					repository.getDepartures(cm, getFeedID(), getCode(), null, this@DeparturesActivity)
+					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: ErrorResponseError) {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
index 4cdcd2894b818f790edd4146aadbb36e1592fe28..b25c1eaa7e0e9ec9ce3d58a8b8dcebe4896136d6 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
@@ -27,6 +27,13 @@ 		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,




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt
index 55a472675d6a413ebaa288771f2e19a5d24906f9..62568fdce835e8abc3e82c9ce3280d848fb1d1a1 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Line.kt
@@ -10,8 +10,8 @@ 	val name: String,
 	val colour: Colour,
 	val type: LineType,
 	val feedID: String,
-	val headsigns: Array<List<String>>,
-	val graphs: Array<LineGraph>,
+	val headsigns: List<List<String>>,
+	val graphs: List<LineGraph>,
 ) : Queryable, LineAbstract {
 
 	constructor(line: LineV1) : this(
@@ -20,36 +20,10 @@ 		Colour(line.colour),
 		LineType.of(line.type),
 		line.feedID,
 		line.headsigns,
-		line.graphs.map{LineGraph(it)}.toTypedArray()
+		line.graphs.map{LineGraph(it)}
 	)
 
 	fun icon(context: Context, scale: Float = 1f): Drawable {
 		return BitmapDrawable(context.resources, super.icon(context, type, colour, scale))
-	}
-
-	override fun equals(other: Any?): Boolean {
-		if (this === other) return true
-		if (javaClass != other?.javaClass) return false
-
-		other as Line
-
-		if (name != other.name) return false
-		if (colour != other.colour) return false
-		if (type != other.type) return false
-		if (feedID != other.feedID) return false
-		if (!headsigns.contentEquals(other.headsigns)) return false
-		if (!graphs.contentEquals(other.graphs)) return false
-
-		return true
-	}
-
-	override fun hashCode(): Int {
-		var result = name.hashCode()
-		result = 31 * result + colour.hashCode()
-		result = 31 * result + type.hashCode()
-		result = 31 * result + feedID.hashCode()
-		result = 31 * result + headsigns.contentHashCode()
-		result = 31 * result + graphs.contentHashCode()
-		return result
 	}
 }
\ 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
index e6ca30739439925c4d8a2612a2528192b204251d..5fc157428a5c0cf50e52f7b2e111840813078bdc 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt
@@ -8,6 +8,9 @@ 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.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
@@ -88,6 +91,27 @@ 						else -> TODO("nothing else")
 					}
 				}
 
+				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 ErrorResponseError(result.error.statusCode, response.message, result.error)
+			} else {
+				throw ErrorResponseError(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
 			}
 		}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt
index 5fac1fd0c30f08ada1f894d1199a96dc3a703911..5366c50af64d254a6ba91a63c5c2bbf9cc51fcf4 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/StopStub.kt
@@ -1,6 +1,19 @@
 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) :
@@ -12,4 +25,26 @@ 		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/search/LineGraphActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..eca856d9a9935795debc4854e5a37cf6e0fb283b
--- /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.ErrorResponseError
+import xyz.apiote.bimba.czwek.repo.OnlineRepository
+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: ErrorResponseError) {
+				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 988821e7833eb0b19d611d1410d35925099a73c6..700383540800c6646d4e8f7b4d46d6aca2851116 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
@@ -14,6 +14,7 @@ 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)
@@ -37,6 +38,36 @@ 				onClickListener(queryable)
 			}
 		}
 
+		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!!))
@@ -60,14 +91,26 @@ 				contentDescription = line.type.name
 				colorFilter = null
 			}
 			holder?.title?.text = line.name
-			holder?.description?.text = context?.getString(
-				R.string.line_headsigns,
-				line.headsigns[0].joinToString { it },
-				line.headsigns[1].joinToString { it })
-			holder?.description?.contentDescription = context?.getString(
-				R.string.line_headsigns_content_description,
-				line.headsigns[0].joinToString { it },
-				line.headsigns[1].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 })
+			}
 		}
 	}
 }
@@ -91,7 +134,11 @@ 				context!!.startActivity(intent)
 			}
 
 			is Line -> {
-				TODO("[3.1] start line graph activity")
+				val intent = Intent(context, LineGraphActivity::class.java).apply {
+					putExtra("line", it.name)
+					putExtra("feedID", it.feedID)
+				}
+				context!!.startActivity(intent)
 			}
 		}
 	}




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/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_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/values/strings.xml b/app/src/main/res/values/strings.xml
index d2030511c15b7a4599667e790c0e5b051aadc04c..c69ae1ce3512d3f13a4bf285c961cc509e300b0a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -49,6 +49,8 @@ 	no boarding
 	<string name="on_boarding">on-boarding</string>
 	<string name="off_boarding">off-boarding</string>
 	<string name="boarding">can board</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>
@@ -89,4 +91,7 @@ 	Choose server flavour
 	<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.\nIt 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>
 </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 fc66e51f3cb25eb4725f6d953e4dd603eaead510..5b2c8f803785fa0b966b9cdb2438b75350b64640 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -56,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