Bimba.git

commit 607af5cd301867109d29b49e1171fdfa0254d860

Author: Adam <git@apiote.xyz>

show departures

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


diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 88fa50c2e6286f4d53688dce0b3860a058798f69..8d4aaff42578cccac40b793bd26d41d6b93a4b93 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -18,6 +18,9 @@ 		android:supportsRtl="true"
 		android:theme="@style/Theme.Bimba.Style"
 		tools:targetApi="31">
 		<activity
+			android:name=".departures.DeparturesActivity"
+			android:exported="false" />
+		<activity
 			android:name=".search.ResultsActivity"
 			android:exported="false"
 			android:label="@string/title_activity_results"




diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
index 67be4d5e21984f6a8dbbf89f1f15195d84b20aad..c1ae81a02ba8a03d5b51abaadeb161af3041b940 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
@@ -15,6 +15,10 @@ suspend fun locateItems(plusCode: String): InputStream {
 	return request("https://bimba.apiote.xyz", "poznan_ztm", "items", mapOf("near" to plusCode))
 }
 
+suspend fun getDepartures(stop: String, line: String?): InputStream {
+	return request("https://bimba.apiote.xyz", "poznan_ztm", "departures", mapOf("code" to stop))
+}
+
 @Suppress("BlockingMethodInNonBlockingContext")
 suspend fun request(
 	host: String,
@@ -38,6 +42,7 @@ 		)
 		val c = (url.openConnection() as HttpURLConnection).apply {
 			setRequestProperty("X-Bimba-Token", "ef0179272e7270e1a2da1710a8ba24e1")
 		}
+		// todo handle errors
 		c.inputStream
 	}
 }
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt
index 6cb63b72d138c72809b6d7fa963409d6a349d0ed..bd01ca6f68bc9c0da07283037872c1f0d7be1f18 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt
@@ -3,6 +3,61 @@
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
 
+interface DeparturesResponse {
+	companion object {
+		fun printable(it: String) = it.map {
+			when (Character.getType(it).toByte()) {
+				Character.CONTROL, Character.FORMAT, Character.PRIVATE_USE,
+				Character.SURROGATE, Character.UNASSIGNED, Character.OTHER_SYMBOL
+				-> "\\u%04x".format(it.code)
+				else -> it.toString()
+			}
+		}.joinToString("")
+
+		fun unmarshall(stream: InputStream): DeparturesResponse {
+			val reader = Reader(stream)
+			when (reader.readUInt()) {
+				0UL -> {
+					TODO("error response")
+				}
+				1UL -> {
+					return DeparturesSuccess.unmarshall(stream)
+				}
+				else -> {
+					TODO("throw unknown tag")
+				}
+			}
+		}
+	}
+}
+
+data class DeparturesSuccess(
+	val alerts: List<Alert>,
+	val departures: List<Departure>,
+	val stop: Stop
+) : DeparturesResponse {
+	companion object {
+		fun unmarshall(stream: InputStream): DeparturesSuccess {
+			val alerts = mutableListOf<Alert>()
+			val departures = mutableListOf<Departure>()
+
+			val reader = Reader(stream)
+			val alertsNum = reader.readUInt()
+			for (i in 0UL until alertsNum) {
+				val alert = Alert.unmarshall(stream)
+				alerts.add(alert)
+			}
+			val departuresNum = reader.readUInt()
+			for (i in 0UL until departuresNum) {
+				val departure = Departure.unmarshall(stream)
+				departures.add(departure)
+			}
+
+			return DeparturesSuccess(alerts, departures, Stop.unmarshall(stream))
+		}
+	}
+}
+
 interface ItemsResponse {
 	companion object {
 		fun unmarshall(stream: InputStream): ItemsResponse {




diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt
index f99673af949bfcf97a51726de147d4a816d77846..25bb90e968345856ed89aef2b161fab580d2c40d 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt
@@ -1,8 +1,104 @@
 package ml.adamsprogs.bimba.api
 
+import android.content.Context
+import android.graphics.*
+import android.graphics.drawable.Drawable
 import android.util.Log
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.graphics.drawable.toBitmap
+import ml.adamsprogs.bimba.R
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
+import kotlin.math.abs
+import kotlin.math.pow
+
+data class Alert(
+	val header: String,
+	val Description: String,
+	val Url: String,
+	val Cause: UInt,
+	val Effect: UInt
+) {
+	companion object {
+		fun unmarshall(stream: InputStream): Alert {
+			val reader = Reader(stream)
+			val header = reader.readString()
+			val description = reader.readString()
+			val url = reader.readString()
+			val cause = reader.readU32()
+			val effect = reader.readU32()
+			return Alert(header, description, url, cause, effect)
+		}
+	}
+}
+
+data class Time(
+	val Hour: UInt,
+	val Minute: UInt,
+	val Second: UInt,
+	val DayOffset: Byte,
+	val Zone: String
+) {
+	companion object {
+		fun unmarshall(stream: InputStream): Time {
+			val reader = Reader(stream)
+			return Time(
+				reader.readUInt().toUInt(),
+				reader.readUInt().toUInt(),
+				reader.readUInt().toUInt(),
+				reader.readI8(),
+				reader.readString()
+			)
+		}
+	}
+}
+
+data class Vehicle(
+	val Position: String,
+	val Capabilities: UByte,
+	val Speed: Float,
+	val CongestionLevel: UByte,
+	val OccupancyStatus: UByte
+) {
+	companion object {
+		fun unmarshall(stream: InputStream): Vehicle {
+			val reader = Reader(stream)
+			return Vehicle(
+				reader.readString(),
+				reader.readU8(),
+				reader.readFloat32(),
+				reader.readU8(),
+				reader.readU8()
+			)
+		}
+	}
+}
+
+data class Departure(
+	val line: String,
+	val headsign: String,
+	val time: Time,
+	val status: UByte,
+	val isRealtime: Boolean,
+	val stopOrder: String,
+	val vehicle: Vehicle,
+	val boarding: UByte
+) {
+	companion object {
+		fun unmarshall(stream: InputStream): Departure {
+			val reader = Reader(stream)
+			val line = reader.readString()
+			val headsign = reader.readString()
+			val time = Time.unmarshall(stream)
+			val status = reader.readU8()
+			val isRealtime = reader.readBoolean()
+			val stopOrder = reader.readString()
+			val vehicle = Vehicle.unmarshall(stream)
+			val boarding = reader.readU8()
+			return Departure(line, headsign, time, status, isRealtime, stopOrder, vehicle, boarding)
+		}
+	}
+}
 
 interface Item
 
@@ -44,7 +140,6 @@ 	}
 }
 
 data class Line(
-	val textColour: Int,
 	val colour: Int,
 	val type: LineType,
 	val headsignsThere: List<String>,
@@ -54,7 +149,75 @@ 	val graphBack: LineGraph,
 	val name: String
 ) : Item {
 	override fun toString(): String {
-		return "$name ($type) [$textColour/$colour]\n→ [${headsignsThere.joinToString()}]\n→ [${headsignsBack.joinToString()}]\n"
+		return "$name ($type) [${textColour()}/$colour]\n→ [${headsignsThere.joinToString()}]\n→ [${headsignsBack.joinToString()}]\n"
+	}
+
+	fun textColour(): Int {
+		val black = relativeLuminance(0x000000) + .05
+		val white = relativeLuminance(0xffffff) + .05
+		val colour = relativeLuminance(this.colour) + .05
+		return if (white / colour > colour / black) {
+			0xffffff
+		} else {
+			0x000000
+		}
+	}
+
+	private fun relativeLuminance(colour: Int): Double {
+		val r = fromSRGB((colour / 0xffff).toDouble() / 0xff)
+		val g = fromSRGB((colour / 0xff).and(0xff).toDouble() / 0xff)
+		val b = fromSRGB(colour.and(0xff).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): Bitmap {
+		val drawingBitmap = Bitmap.createBitmap(
+			2400,
+			2400,
+			Bitmap.Config.ARGB_8888
+		)
+		val canvas = Canvas(drawingBitmap)
+		val squirclePaint = Paint().apply {
+
+		}
+		canvas.drawPath(getSquirclePath(80, 80, 2240), Paint().apply { color = textColour() })
+		canvas.drawPath(getSquirclePath(160, 160, 2080), Paint().apply { color = colour })
+		val iconID = when (type) {
+			LineType.BUS -> R.drawable.ic_bus_black_24dp
+			LineType.TRAM -> R.drawable.ic_tram_black_24dp
+			LineType.UNKNOWN -> R.drawable.ic_square_white_24dp
+		}
+		val icon = AppCompatResources.getDrawable(context, iconID)
+			?.toBitmap(1920, 1920, Bitmap.Config.ARGB_8888)
+		canvas.drawBitmap(icon!!, 240f, 240f, Paint().apply { color = textColour() })
+		return drawingBitmap
+	}
+
+	private fun getSquirclePath(left: Int, top: Int, 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).toFloat(), (top + radius).toFloat())
+		path.transform(matrix)
+		return path
 	}
 
 	companion object {
@@ -77,7 +240,7 @@ 			val graphThere = LineGraph.unmarshall(stream)
 			val graphBack = LineGraph.unmarshall(stream)
 			val name = reader.readString()
 			return Line(
-				name = name, textColour = textColour,
+				name = name,
 				colour = colour, type = LineType(type.toUInt()), headsignsThere = headsignsThere,
 				headsignsBack = headsignsBack, graphThere = graphThere, graphBack = graphBack
 			)




diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt
index 5963e6ff2bfd57957699339564f7d6dec9890bb1..1e1db600a542b551fc02a4e8479c6bf0a060dac8 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt
@@ -18,11 +18,15 @@ import androidx.navigation.fragment.NavHostFragment
 import androidx.navigation.ui.setupWithNavController
 import com.google.android.material.bottomnavigation.BottomNavigationView
 import ml.adamsprogs.bimba.R
+import ml.adamsprogs.bimba.api.Item
+import ml.adamsprogs.bimba.api.Line
+import ml.adamsprogs.bimba.api.Stop
 import ml.adamsprogs.bimba.databinding.ActivityMainBinding
 import ml.adamsprogs.bimba.search.ResultsActivity
 import ml.adamsprogs.bimba.dashboard.ui.home.HomeFragment
 import ml.adamsprogs.bimba.dashboard.ui.map.MapFragment
 import ml.adamsprogs.bimba.dashboard.ui.voyage.VoyageFragment
+import ml.adamsprogs.bimba.departures.DeparturesActivity
 
 
 class MainActivity : AppCompatActivity() {
@@ -85,7 +89,6 @@ 		}
 	}
 
 	fun onGpsClicked(fab: View) {
-
 		when (PackageManager.PERMISSION_GRANTED) {
 			ContextCompat.checkSelfPermission(
 				this,
@@ -104,6 +107,22 @@ 						Manifest.permission.ACCESS_FINE_LOCATION,
 						Manifest.permission.ACCESS_COARSE_LOCATION
 					)
 				)
+			}
+		}
+	}
+
+	fun onSuggestionClicked(item: Item) {
+		when (item) {
+			is Stop -> {
+				val intent = Intent(this, DeparturesActivity::class.java).apply {
+					putExtra("code", item.code)
+					putExtra("name", item.name)
+					// todo line, date, etc
+				}
+				startActivity(intent)
+			}
+			is Line -> {
+				TODO("start line graph actvity")
 			}
 		}
 	}




diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt
index 744d3b43f1476c3916ee2fd2a3e48920efe4cf9f..a1b274047a5161010c9982befea97dc495bfa2fd 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt
@@ -58,7 +58,8 @@ 			}
 		})
 		binding.searchBar.setCardViewElevation(0)
 		binding.searchBar.setCustomSuggestionAdapter(BimbaSuggestionsAdapter(layoutInflater, context){
-			TODO("On click suggestion")
+			binding.searchBar.clearSuggestions()
+			(context as MainActivity).onSuggestionClicked(it)
 		})
 
 		binding.floatingActionButton.setOnClickListener {




diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt
index 48254fc128da6829a73a41991af55f70db50b0be..b4127b6668c4abbb97446356ec8637d671f1db04 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeViewModel.kt
@@ -34,13 +34,4 @@ 				}
 			}
 		}
 	}
-
-	private fun String.printable() = map {
-		when (Character.getType(it).toByte()) {
-			Character.CONTROL, Character.FORMAT, Character.PRIVATE_USE,
-			Character.SURROGATE, Character.UNASSIGNED, Character.OTHER_SYMBOL
-			-> "\\u%04x".format(it.code)
-			else -> it.toString()
-		}
-	}.joinToString("")
 }
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/departures/Departures.kt b/app/src/main/java/ml/adamsprogs/bimba/departures/Departures.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6f332e97db71bd37fe532d492121f2c6ba5d221a
--- /dev/null
+++ b/app/src/main/java/ml/adamsprogs/bimba/departures/Departures.kt
@@ -0,0 +1,95 @@
+package ml.adamsprogs.bimba.departures
+
+import android.content.Context
+import android.util.Log
+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 ml.adamsprogs.bimba.R
+import ml.adamsprogs.bimba.api.Departure
+import java.util.*
+
+
+class BimbaDepartureViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+	val root: View = itemView.findViewById(R.id.departure)
+	val lineIcon: ImageView = itemView.findViewById(R.id.line_icon)
+	val lineName: TextView = itemView.findViewById(R.id.line_name)
+	val departureTime: TextView = itemView.findViewById(R.id.departure_time)
+	val headsign: TextView = itemView.findViewById(R.id.departure_headsign)
+
+
+	companion object {
+		fun bind(
+			departure: Departure,
+			holder: BimbaDepartureViewHolder?,
+			context: Context?,
+			onClickListener: (Departure) -> Unit
+		) {
+			holder?.root?.setOnClickListener {
+				onClickListener(departure)
+			}
+			// todo line icon
+			holder?.lineName?.text = departure.line
+			holder?.headsign?.text = "» ${departure.headsign}"
+			val now = Calendar.getInstance()
+			val departureTime = Calendar.getInstance().apply {
+				set(Calendar.HOUR_OF_DAY, departure.time.Hour.toInt())
+				set(Calendar.MINUTE, departure.time.Minute.toInt())
+				set(Calendar.SECOND, departure.time.Second.toInt())
+				// todo zone
+				roll(Calendar.DAY_OF_MONTH, departure.time.DayOffset.toInt())
+			}
+			var duration = departureTime.timeInMillis - now.timeInMillis
+			val days = duration / (24 * 60 * 60 * 1000)
+			duration %= (24 * 60 * 60 * 1000)
+			val hours = duration / (60 * 60 * 1000)
+			duration %= (60 * 60 * 1000)
+			val minutes = duration / (60 * 1000)
+			duration %= (60 * 1000)
+			val seconds = duration / 1000
+			holder?.departureTime?.text = when (departure.status.toInt()) {
+				0 -> {
+					"in " +
+									if (hours > 0) {
+										"$hours h "
+									} else {
+										""
+									} + "$minutes min"
+				}
+				1 -> "momentarily"
+				2 -> "now"
+				3 -> "departed"
+				else -> ""
+			}
+		}
+	}
+}
+
+class BimbaDeparturesAdapter(
+	private val inflater: LayoutInflater,
+	private val context: Context?,
+	private var departures: List<Departure>,
+	private val onClickListener: ((Departure) -> Unit)
+) :
+	RecyclerView.Adapter<BimbaDepartureViewHolder>() {
+	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaDepartureViewHolder {
+		val rowView = inflater.inflate(R.layout.departure, parent, false)
+		return BimbaDepartureViewHolder(rowView)
+	}
+
+	override fun onBindViewHolder(holder: BimbaDepartureViewHolder, position: Int) {
+		BimbaDepartureViewHolder.bind(departures[position], holder, context, onClickListener)
+	}
+
+	override fun getItemCount(): Int = departures.size
+
+	fun update(items: List<Departure>) {
+		departures = items
+		Log.v("Departures", departures.toString())
+		notifyDataSetChanged()
+	}
+
+}
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt
index 299c9d06176c355fa85f0baacc6a124817d232b2..9a152b387a0476eada01ddaf1c9051f225f5ca5e 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt
@@ -3,12 +3,25 @@
 import androidx.appcompat.app.AppCompatActivity
 import android.os.Bundle
 import android.util.Log
+import android.view.View
+import androidx.core.view.WindowCompat
+import androidx.recyclerview.widget.LinearLayoutManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import ml.adamsprogs.bimba.api.*
 import ml.adamsprogs.bimba.databinding.ActivityDeparturesBinding
+import ml.adamsprogs.bimba.search.BimbaResultsAdapter
+import java.io.InputStream
 
 class DeparturesActivity : AppCompatActivity() {
 	private var _binding: ActivityDeparturesBinding? = null
 
 	private val binding get() = _binding!!
+
+	private lateinit var adapter: BimbaDeparturesAdapter
+
 	override fun onCreate(savedInstanceState: Bundle?) {
 		super.onCreate(savedInstanceState)
 		_binding = ActivityDeparturesBinding.inflate(layoutInflater)
@@ -16,6 +29,43 @@ 		setContentView(binding.root)
 
 //		setSupportActionBar(binding.departuresAppBar)
 //		supportActionBar?.title = "Półwiejska"
-		binding.collapsingLayout.title = "Very long stop name that should span over multpile lines" // intent?.extras?.getString("name")
+		binding.collapsingLayout.title = intent?.extras?.getString("name")
+		binding.departuresRecycler.layoutManager = LinearLayoutManager(this)
+		adapter = BimbaDeparturesAdapter(layoutInflater, this, listOf()) {
+			Log.v("Departure", "clicked: $it")
+			// todo show bottom sheet
+		}
+		binding.departuresRecycler.adapter = adapter
+		WindowCompat.setDecorFitsSystemWindows(window, false)
+
+		// todo check every 30s
+
+		MainScope().launch {
+			intent?.extras?.getString("code")?.let {
+				val departuresStream = getDepartures(it, null)
+				updateItems(unmarshallDepartureResponse(departuresStream))
+			}
+		}
+	}
+
+	private suspend fun unmarshallDepartureResponse(stream: InputStream): DeparturesSuccess {
+		return withContext(Dispatchers.IO) {
+			when (val response = DeparturesResponse.unmarshall(stream)) {
+				is DeparturesSuccess -> {
+					return@withContext response
+				}
+				else -> {
+					TODO("Error response")
+				}
+			}
+		}
+	}
+
+	private fun updateItems(response: DeparturesSuccess) {
+		binding.departuresProgress.visibility = View.GONE
+		binding.departuresRecycler.visibility = View.VISIBLE
+		adapter.update(response.departures)
+		// todo alerts
+		// todo stop info
 	}
 }
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt b/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt
index 2218389bd2574588d9b6f66944be39a319137f43..94594584aef29ccad8bc69e64a00d0750885d2b5 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt
@@ -38,25 +38,12 @@ 					holder?.description?.text =
 						item.changeOptions.joinToString { "${it.line} » ${it.headsign}" }
 				}
 				is Line -> {
-					val icon = when (item.type) {
-						LineType.TRAM -> {
-							AppCompatResources.getDrawable(context!!, R.drawable.ic_circle_white_24dp)?.mutate()
-						}
-						LineType.BUS -> {
-							AppCompatResources.getDrawable(
-								context!!,
-								R.drawable.ic_square_white_24dp
-							)?.mutate()  // todo bigger and squircle
-						}
-						else -> {
-							null
-						}
-					}
+					val icon = item.icon(context!!)
 					holder?.icon?.apply {
-						setImageDrawable(icon)
+						setImageBitmap(icon)
 						colorFilter = null
 						Log.v("Colour", "${item.name}: ${item.colour}, ${item.colour.toString(16)}")
-						setColorFilter(item.colour, PorterDuff.Mode.SRC_IN)  // fixme check colours in TRAFFIC
+						Log.v("Colour", "${item.name}: ${item.textColour()}, ${item.textColour().toString(16)}")
 					}
 					holder?.title?.text = item.name
 					holder?.description?.text =
@@ -134,7 +121,7 @@ 		val rowView = layoutInflater.inflate(R.layout.suggestion, parent, false)
 		return BimbaViewHolder(rowView)
 	}
 
-	override fun getSingleViewHeight(): Int = 64
+	override fun getSingleViewHeight(): Int = 64  // todo calculate actual height
 
 	override fun onBindSuggestionHolder(item: Item, holder: BimbaViewHolder?, pos: Int) {
 		BimbaViewHolder.bind(item, holder, context, onClickListener)




diff --git a/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt
index 57b5d1f384f54b620c1238774cf8a5846ab228f1..bbf6efd30df91934f5cf3b6949a531fe257d290f 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/search/ResultsActivity.kt
@@ -74,9 +74,12 @@ 		val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
 		locationManager.requestLocationUpdates(
 			LocationManager.GPS_PROVIDER, 5000, 10f, this
 		)
+		locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
+			?.let { onLocationChanged(it) }
 	}
 
 	override fun onLocationChanged(location: Location) {
+		Log.v("Location", "found $location")
 		val code = OpenLocationCode.encode(location.latitude, location.longitude)
 		val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
 		locationManager.removeUpdates(this)
@@ -98,6 +101,7 @@ 		}
 	}
 
 	private fun getItemsByLocation(plusCode: String) {
+		Log.v("RESPONSE", "getting ItemsByLocation")
 		MainScope().launch {
 			val itemsStream = locateItems(plusCode)
 			updateItems(unmarshallItemResponse(itemsStream))




diff --git a/app/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt b/app/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt
index cf700941c0f505541b2e818365e5535880ba7fa1..d6246c44ab04f79f66dccf7913a02bdc2ad3a665 100644
--- a/app/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt
+++ b/app/src/main/java/xyz/apiote/fruchtfleisch/Reader.kt
@@ -68,6 +68,10 @@ 		}
 		return result.toUShort()
 	}
 
+	fun readI8(): Byte {
+		return readU8().toByte()
+	}
+
 	fun readFloat32(): Float {
 		return DataInputStream(stream).readFloat()
 	}




diff --git a/app/src/main/res/drawable/ic_bus_black_24dp.xml b/app/src/main/res/drawable/ic_bus_black_24dp.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e2b18a05948d28b39cd2b4bfb102cac858c738bf
--- /dev/null
+++ b/app/src/main/res/drawable/ic_bus_black_24dp.xml
@@ -0,0 +1,5 @@
+<vector android:height="24dp" android:tint="#000000"
+    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="M4,16c0,0.88 0.39,1.67 1,2.22L5,20c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h8v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1.78c0.61,-0.55 1,-1.34 1,-2.22L20,6c0,-3.5 -3.58,-4 -8,-4s-8,0.5 -8,4v10zM7.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5S6.67,14 7.5,14s1.5,0.67 1.5,1.5S8.33,17 7.5,17zM16.5,17c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM18,11L6,11L6,6h12v5z"/>
+</vector>




diff --git a/app/src/main/res/drawable/ic_tram_black_24dp.xml b/app/src/main/res/drawable/ic_tram_black_24dp.xml
new file mode 100644
index 0000000000000000000000000000000000000000..989349cbc9a456e6458fff607d6ad99c1e9d1c79
--- /dev/null
+++ b/app/src/main/res/drawable/ic_tram_black_24dp.xml
@@ -0,0 +1,5 @@
+<vector android:height="24dp" android:tint="#000000"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M19,16.94L19,8.5c0,-2.79 -2.61,-3.4 -6.01,-3.49l0.76,-1.51L17,3.5L17,2L7,2v1.5h4.75l-0.76,1.52C7.86,5.11 5,5.73 5,8.5v8.44c0,1.45 1.19,2.66 2.59,2.97L6,21.5v0.5h2.23l2,-2L14,20l2,2h2v-0.5L16.5,20h-0.08c1.69,0 2.58,-1.37 2.58,-3.06zM12,18.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM17,14L7,14L7,9h10v5z"/>
+</vector>




diff --git a/app/src/main/res/layout/activity_departures.xml b/app/src/main/res/layout/activity_departures.xml
new file mode 100644
index 0000000000000000000000000000000000000000..91b47cfbe8c7eb229560a7d9f74e23df2ec32d97
--- /dev/null
+++ b/app/src/main/res/layout/activity_departures.xml
@@ -0,0 +1,53 @@
+<?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"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent">
+	<androidx.constraintlayout.widget.ConstraintLayout
+		android:id="@+id/departures_progress"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent">
+
+		<ProgressBar
+			style="?android:attr/progressBarStyle"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="parent" />
+	</androidx.constraintlayout.widget.ConstraintLayout>
+
+	<com.google.android.material.appbar.AppBarLayout
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:fitsSystemWindows="true">
+
+		<!-- todo toolbar font family -->
+		<com.google.android.material.appbar.CollapsingToolbarLayout
+			android:id="@+id/collapsing_layout"
+			app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
+			style="?attr/collapsingToolbarLayoutMediumStyle"
+			android:layout_width="match_parent"
+			app:maxLines="2"
+			android:layout_height="?attr/collapsingToolbarLayoutMediumSize">
+
+			<com.google.android.material.appbar.MaterialToolbar
+				android:id="@+id/departures_app_bar"
+				android:layout_width="match_parent"
+				app:layout_collapseMode="pin"
+				android:layout_height="?attr/actionBarSize"
+				android:elevation="0dp" />
+
+		</com.google.android.material.appbar.CollapsingToolbarLayout>
+
+	</com.google.android.material.appbar.AppBarLayout>
+	<androidx.recyclerview.widget.RecyclerView
+		android:id="@+id/departures_recycler"
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+
+		android:visibility="gone"
+		app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/activity_results.xml b/app/src/main/res/layout/activity_results.xml
index 52fbb1021ffa1df3b2e05915a803979f8160c9fa..5da29b4134691ec1c55cd50e76cead7aecbfceae 100644
--- a/app/src/main/res/layout/activity_results.xml
+++ b/app/src/main/res/layout/activity_results.xml
@@ -19,6 +19,7 @@ 			app:layout_constraintStart_toStartOf="parent"
 			app:layout_constraintTop_toTopOf="parent" />
 	</androidx.constraintlayout.widget.ConstraintLayout>
 
+	<!-- todo toolbar font family -->
 	<com.google.android.material.appbar.AppBarLayout
 		android:layout_width="match_parent"
 		android:layout_height="wrap_content"




diff --git a/app/src/main/res/layout/departure.xml b/app/src/main/res/layout/departure.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4392552af5dfd71ac7cf8fb90038d46af9ca0a44
--- /dev/null
+++ b/app/src/main/res/layout/departure.xml
@@ -0,0 +1,58 @@
+<?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"
+	android:layout_width="match_parent"
+	android:id="@+id/departure"
+	android:layout_height="wrap_content">
+
+	<androidx.constraintlayout.widget.ConstraintLayout
+		android:id="@+id/line"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="16dp"
+		app:layout_constraintBottom_toBottomOf="@+id/departure_headsign"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="@+id/departure_time">
+
+		<ImageView
+			android:id="@+id/line_icon"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			app:layout_constraintEnd_toEndOf="@+id/line_name"
+			app:layout_constraintStart_toStartOf="@+id/line_name"
+			app:layout_constraintTop_toTopOf="parent"/>
+
+		<TextView
+			android:id="@+id/line_name"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginTop="8dp"
+			android:text=""
+			android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toBottomOf="@+id/line_icon" />
+	</androidx.constraintlayout.widget.ConstraintLayout>
+
+	<TextView
+		android:id="@+id/departure_time"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="16dp"
+		android:layout_marginTop="16dp"
+		android:text=""
+		android:textAppearance="@style/TextAppearance.Material3.DisplaySmall"
+		app:layout_constraintStart_toEndOf="@+id/line"
+		app:layout_constraintTop_toTopOf="parent" />
+
+	<TextView
+		android:id="@+id/departure_headsign"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginTop="8dp"
+		android:text=""
+		android:textAppearance="@style/TextAppearance.Material3.BodySmall"
+		app:layout_constraintStart_toStartOf="@+id/departure_time"
+		app:layout_constraintTop_toBottomOf="@+id/departure_time" />
+
+
+</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
index d1eb7758fad295a2a8d7fe4f1c54638ea9a67cef..9eff05aff67bae1f471373a73beb09ea30c57b0a 100644
--- a/app/src/main/res/layout/suggestion.xml
+++ b/app/src/main/res/layout/suggestion.xml
@@ -3,7 +3,7 @@  	xmlns:app="http://schemas.android.com/apk/res-auto"
 	android:id="@+id/suggestion"
 	android:layout_width="match_parent"
-	android:layout_height="64dp">
+	android:layout_height="64dp"> <!-- todo wrap_content -->
 
 	<ImageView
 		android:id="@+id/suggestion_image"