Bimba.git

commit a686fc312057666f790440407615485fb6ffa20c

Author: Adam <git@apiote.xyz>

show map with stops and vehicles

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


diff --git a/app/build.gradle b/app/build.gradle
index a96bfaca838c91cd5bc02dd44734a70e07556140..f83613c9bb0a0fadee2f4a6202fe11327e3cae4d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -49,6 +49,7 @@     implementation 'com.github.mancj:MaterialSearchBar:0.8.5'
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
     implementation 'androidx.core:core-splashscreen:1.0.0'
     implementation 'com.google.openlocationcode:openlocationcode:1.0.4'
+    implementation 'org.osmdroid:osmdroid-android:6.1.14'
     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/java/ml/adamsprogs/bimba/api/Api.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
index 06c49b9b35338ae17af9b98256b50a63ef00b4b6..4036f5b7617d2f894b516e53bb1026a8b256f92d 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt
@@ -55,6 +55,10 @@ suspend fun locateItems(cm: ConnectivityManager, server: Server, plusCode: String): Result {
 	return request(server, "items", mapOf("near" to plusCode), cm)
 }
 
+suspend fun getLocatablesIn(cm: ConnectivityManager, server: Server, bl: String, tr: String): Result {
+	return request(server, "locatables", mapOf("lb" to bl, "rt" to tr), cm)
+}
+
 suspend fun getDepartures(
 	cm: ConnectivityManager,
 	server: Server,




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 6ebdece24a398fb5511291697275703729ccc6bd..ab40e38e878d814f8f80c812ff731b812883f8d1 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt
@@ -133,7 +133,51 @@ 		}
 	}
 }
 
-data class ErrorResponse(val field: String, val message: String) : ItemsResponse, DeparturesResponse, FeedsResponse {
+interface LocatablesResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): LocatablesResponse {
+			val reader = Reader(stream)
+			return when (reader.readUInt()) {
+				0UL -> {
+					ErrorResponse.unmarshal(stream)
+				}
+				1UL -> {
+					LocatablesSuccess.unmarshal(stream)
+				}
+				else -> {
+					TODO("throw unknown tag")
+				}
+			}
+		}
+	}
+}
+
+data class LocatablesSuccess(val locatables: List<Locatable>) : LocatablesResponse {
+	companion object {
+		fun unmarshal(stream: InputStream): LocatablesSuccess {
+			val locatables = mutableListOf<Locatable>()
+			val reader = Reader(stream)
+			val itemsNum = reader.readUInt()
+			for (i in 0UL until itemsNum) {
+				when (reader.readUInt()) {
+					0UL -> {
+						locatables.add(Stop.unmarshal(stream))
+					}
+					1UL -> {
+						locatables.add(Vehicle.unmarshal(stream))
+					}
+					else -> {
+						TODO("throw unknown tag")
+					}
+				}
+			}
+			return LocatablesSuccess(locatables)
+		}
+	}
+}
+
+data class ErrorResponse(val field: String, val message: String) : ItemsResponse,
+	DeparturesResponse, FeedsResponse, LocatablesResponse {
 	companion object {
 		fun unmarshal(stream: InputStream): ErrorResponse {
 			val reader = Reader(stream)




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 8c13ba605ef96f1a907c5b9523a1971897a6d237..2f6d8ce34d7a26be330aaaae1a8ac95c965b65b0 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt
@@ -2,13 +2,18 @@ package ml.adamsprogs.bimba.api
 
 import android.content.Context
 import android.graphics.*
+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.HSLToColor
 import androidx.core.graphics.drawable.toBitmap
 import ml.adamsprogs.bimba.R
 import ml.adamsprogs.bimba.dpToPixel
 import ml.adamsprogs.bimba.dpToPixelI
 import xyz.apiote.fruchtfleisch.Reader
 import java.io.InputStream
+import java.util.zip.Adler32
 import kotlin.math.abs
 import kotlin.math.pow
 
@@ -137,9 +142,11 @@ 	val ID: String,
 	val Position: String,
 	val Capabilities: UShort,
 	val Speed: Float,
+	val Line: LineStub,
+	val Headsign: String,
 	val CongestionLevel: UByte,
 	val OccupancyStatus: UByte
-) {
+) : Locatable {
 	enum class Capability(val bit: UShort) {
 		RAMP(0b0001u),
 		LOW_FLOOR(0b0010u),
@@ -152,6 +159,14 @@ 		TICKET_DRIVER(0b0100_0000u),
 		USB_CHARGING(0b1000_0000u)
 	}
 
+	override fun id(): String = ID
+
+	override fun icon(context: Context): Drawable {
+		return BitmapDrawable(context.resources, Line.icon(context, 1f))
+	}
+
+	override fun location(): String = Position
+
 	companion object {
 		fun unmarshal(stream: InputStream): Vehicle {
 			val reader = Reader(stream)
@@ -160,6 +175,8 @@ 				reader.readString(),
 				reader.readString(),
 				reader.readU16(),
 				reader.readFloat32(),
+				LineStub.unmarshal(stream),
+				reader.readString(),
 				reader.readU8(),
 				reader.readU8()
 			)
@@ -186,8 +203,8 @@ 			return LineStub(name = name, colour = colour, type = LineType(type.toUInt()))
 		}
 	}
 
-	fun icon(context: Context): Bitmap {
-		return super.icon(context, type, colour)
+	fun icon(context: Context, scale: Float = 1f): Bitmap {
+		return super.icon(context, type, colour, scale)
 	}
 }
 
@@ -218,6 +235,11 @@ 	}
 }
 
 interface Item
+interface Locatable {
+	fun icon(context: Context): Drawable
+	fun location(): String
+	fun id(): String
+}
 
 data class Stop(
 	val code: String,
@@ -225,12 +247,43 @@ 	val zone: String,
 	val position: String,
 	val changeOptions: List<ChangeOption>,
 	val name: String
-) : Item {
+) : Item, Locatable {
+	override fun icon(context: Context): 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
+		}
+		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 LayerDrawable(arrayOf(bg, fg))
+	}
+
+	override fun id(): String = code
+
+	override fun location(): String = 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(): String {
+		return changeOptions.groupBy { it.line }
+			.map { Pair(it.key, it.value.joinToString { co -> co.headsign }) }
+			.joinToString { "${it.first} » ${it.second}" }
 	}
 
 	companion object {
@@ -283,25 +336,25 @@ 			((part + 0.055) / 1.055).pow(2.4)
 		}
 	}
 
-	fun icon(context: Context, type: LineType, colour: Colour): Bitmap {
+	fun icon(context: Context, type: LineType, colour: Colour, scale: Float): Bitmap {
 		val drawingBitmap = Bitmap.createBitmap(
-			dpToPixelI(24f),
-			dpToPixelI(24f),
+			dpToPixelI(24f/scale),
+			dpToPixelI(24f/scale),
 			Bitmap.Config.ARGB_8888
 		)
 		val canvas = Canvas(drawingBitmap)
 
 		canvas.drawPath(
 			getSquirclePath(
-				dpToPixel(.8f),
-				dpToPixel(.8f),
-				dpToPixelI(11.2f)
+				dpToPixel(.8f/scale),
+				dpToPixel(.8f/scale),
+				dpToPixelI(11.2f/scale)
 			), Paint().apply { color = textColour(colour) })
 		canvas.drawPath(
 			getSquirclePath(
-				dpToPixel(1.6f),
-				dpToPixel(1.6f),
-				dpToPixelI(10.4f)
+				dpToPixel(1.6f/scale),
+				dpToPixel(1.6f/scale),
+				dpToPixelI(10.4f/scale)
 			), Paint().apply { color = colour.toInt() })
 
 		val iconID = when (type) {
@@ -313,11 +366,11 @@ 		val icon =
 			AppCompatResources.getDrawable(context, iconID)?.mutate()  // todo(code) move context out
 				?.apply {
 					setTint(textColour(colour))
-				}?.toBitmap(dpToPixelI(19.2f), dpToPixelI(19.2f), Bitmap.Config.ARGB_8888)
+				}?.toBitmap(dpToPixelI(19.2f/scale), dpToPixelI(19.2f/scale), Bitmap.Config.ARGB_8888)
 		canvas.drawBitmap(
 			icon!!,
-			dpToPixel(2.4f),
-			dpToPixel(2.4f),
+			dpToPixel(2.4f/scale),
+			dpToPixel(2.4f/scale),
 			Paint()
 		)
 		return drawingBitmap
@@ -360,8 +413,8 @@ 	override fun toString(): String {
 		return "$name ($type) [${textColour()}/$colour]\n→ [${headsignsThere.joinToString()}]\n→ [${headsignsBack.joinToString()}]\n"
 	}
 
-	fun icon(context: Context): Bitmap {
-		return super.icon(context, type, colour)
+	fun icon(context: Context, scale: Float = 1f): Bitmap {
+		return super.icon(context, type, colour, scale)
 	}
 
 	fun textColour(): Int {




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 96e5d3957337b05e928fb35b02ee4be41e56373e..fd757e49612e6dd6e73f627dc48f909d4535e94c 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt
@@ -34,6 +34,8 @@ class MainActivity : AppCompatActivity() {
 	private lateinit var binding: ActivityMainBinding
 	private lateinit var locationPermissionRequest: ActivityResultLauncher<Array<String>>
 
+	private lateinit var permissionAsker: Fragment
+
 	override fun onCreate(savedInstanceState: Bundle?) {
 		super.onCreate(savedInstanceState)
 		binding = ActivityMainBinding.inflate(layoutInflater)
@@ -63,7 +65,14 @@ 		) { permissions ->
 			when {
 				permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false ||
 								permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false -> {
-					showResults(ResultsActivity.Mode.MODE_LOCATION)
+					when (permissionAsker) {
+						is HomeFragment -> {
+							showResults(ResultsActivity.Mode.MODE_LOCATION)
+						}
+						is MapFragment -> {
+							(permissionAsker as MapFragment).showLocation()
+						}
+					}
 				}
 				else -> {
 					// todo(ux,ui) dialog
@@ -93,19 +102,23 @@ 			binding.container.openDrawer(binding.navigationDrawer)
 		}
 	}
 
-	fun onGpsClicked(fab: View) {
+	fun onGpsClicked(fab: View, fragment: Fragment) {
 		when (PackageManager.PERMISSION_GRANTED) {
 			ContextCompat.checkSelfPermission(
 				this,
 				Manifest.permission.ACCESS_COARSE_LOCATION
 			) -> {
-				/* todo(ux,low) animation
-					https://developer.android.com/guide/fragments/animate
-					https://github.com/raheemadamboev/fab-explosion-animation-app
-				*/
-				showResults(ResultsActivity.Mode.MODE_LOCATION)
+				when (fragment) {
+					is HomeFragment -> {
+						showResults(ResultsActivity.Mode.MODE_LOCATION)
+					}
+					is MapFragment -> {
+						fragment.showLocation()
+					}
+				}
 			}
 			else -> {
+				permissionAsker = fragment
 				locationPermissionRequest.launch(
 					arrayOf(
 						Manifest.permission.ACCESS_FINE_LOCATION,




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 6d2bf76f6bfb9ceca255fa4a85fd0de301060031..2470cf45ad7dc2c49c7bb1a634f08da1178f5487 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
@@ -78,7 +78,7 @@ 		})
 
 		binding.floatingActionButton.setOnClickListener {
 			binding.searchBar.clearSuggestions()
-			(context as MainActivity).onGpsClicked(it)
+			(context as MainActivity).onGpsClicked(it, this)
 		}
 		/* todo(ux,low) on searchbar focus && if != '' -> populate suggestions
 		binding.searchBar.searchEditText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->




diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt
index 5088bd52954aa7946746eca869c2ed21dc03647e..da970dc91871e1157c601ebcfb4d832cafe01c26 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt
@@ -1,42 +1,252 @@
 package ml.adamsprogs.bimba.dashboard.ui.map
 
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.content.SharedPreferences
+import android.content.res.Configuration.*
+import android.graphics.Bitmap
+import android.net.ConnectivityManager
 import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
-import android.widget.TextView
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.content.edit
+import androidx.core.graphics.drawable.toBitmap
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
+import com.google.android.material.snackbar.Snackbar
+import com.google.openlocationcode.OpenLocationCode
+import ml.adamsprogs.bimba.R
+import ml.adamsprogs.bimba.api.Server
+import ml.adamsprogs.bimba.dashboard.MainActivity
 import ml.adamsprogs.bimba.databinding.FragmentMapBinding
+import ml.adamsprogs.bimba.dpToPixelI
+import org.osmdroid.config.Configuration
+import org.osmdroid.events.MapListener
+import org.osmdroid.events.ScrollEvent
+import org.osmdroid.events.ZoomEvent
+import org.osmdroid.tileprovider.tilesource.TileSourceFactory
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.CustomZoomButtonsController
+import org.osmdroid.views.overlay.Marker
+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
+import java.io.File
+
+
+// todo empty state on no network and long time on entry
 
 class MapFragment : Fragment() {
 
-	private var _binding: FragmentMapBinding? = null
+	private var maybeBinding: FragmentMapBinding? = null
+	private val binding get() = maybeBinding!!
+
+	private lateinit var locationOverlay: MyLocationNewOverlay
+	private lateinit var mapViewModel: MapViewModel
+
+	private val handler = Handler(Looper.getMainLooper())
+	private var workRunnable = Runnable {}
 
-	// This property is only valid between onCreateView and
-	// onDestroyView.
-	private val binding get() = _binding!!
+	private var snackbar: Snackbar? = null
 
+	@SuppressLint("ClickableViewAccessibility")
 	override fun onCreateView(
 		inflater: LayoutInflater,
 		container: ViewGroup?,
 		savedInstanceState: Bundle?
 	): View {
-		val mapViewModel =
-			ViewModelProvider(this).get(MapViewModel::class.java)
+		mapViewModel =
+			ViewModelProvider(this)[MapViewModel::class.java]
+
+		observeLocatables()
+		configureMap()
 
-		_binding = FragmentMapBinding.inflate(inflater, container, false)
+		maybeBinding = FragmentMapBinding.inflate(inflater, container, false)
 		val root: View = binding.root
 
-		val textView: TextView = binding.textNotifications
-		mapViewModel.text.observe(viewLifecycleOwner) {
-			textView.text = it
+		binding.map.setTileSource(TileSourceFactory.MAPNIK)
+		if (((context?.resources?.configuration?.uiMode ?: UI_MODE_NIGHT_UNDEFINED)
+							and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES
+		) {
+			binding.map.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS)
+		}
+		binding.map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
+		binding.map.setMultiTouchControls(true)
+		binding.map.overlays.add(RotationGestureOverlay(binding.map).apply { isEnabled = true })
+
+		locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map)
+		context?.let {
+			centreMap(it.getSharedPreferences("shp", MODE_PRIVATE))
+
+			locationOverlay.setDirectionIcon(
+				AppCompatResources.getDrawable(it, R.drawable.navigation_arrow)?.mutate()
+					?.toBitmap(dpToPixelI(36f), dpToPixelI(36f), Bitmap.Config.ARGB_8888)
+			)
+			locationOverlay.setPersonIcon(
+				AppCompatResources.getDrawable(it, R.drawable.navigation_circle)?.mutate()
+					?.toBitmap(dpToPixelI(36f), dpToPixelI(36f), Bitmap.Config.ARGB_8888)
+			)
 		}
+
+		binding.floatingActionButton.setOnClickListener {
+			(context as MainActivity).onGpsClicked(it, this)
+		}
+
+		binding.map.addMapListener(object : MapListener {
+			override fun onScroll(event: ScrollEvent?): Boolean {
+				return onMapMove()
+			}
+
+			override fun onZoom(event: ZoomEvent?): Boolean {
+				return onMapMove()
+			}
+		})
+
+		binding.map.setOnTouchListener { _, _ ->
+			binding.floatingActionButton.show()
+			false
+		}
+
 		return root
 	}
 
+	private fun configureMap() {
+		Configuration.getInstance().let { config ->
+			context?.let { ctx ->
+				ctx.getSharedPreferences("shp", MODE_PRIVATE).let {
+					config.load(context, it)
+				}
+				config.osmdroidBasePath = File(ctx.cacheDir.absolutePath, "osmdroid")
+			}
+			config.osmdroidTileCache = File(config.osmdroidBasePath.absolutePath, "tile")
+		}
+	}
+
+	private fun onMapMove(): Boolean {
+		snackbar?.dismiss()
+		return delayGetLocatables()
+	}
+
+	private fun delayGetLocatables(delay: Long = 1000): Boolean {
+		handler.removeCallbacks(workRunnable)
+		workRunnable = Runnable {
+			getLocatables()
+		}
+		handler.postDelayed(workRunnable, delay)
+		return true
+	}
+
+	private fun observeLocatables() {
+		mapViewModel.items.observe(viewLifecycleOwner) {
+			binding.map.overlays.removeAll { marker ->
+				marker is Marker
+			}
+
+			it.forEach { locatable ->
+				val marker = Marker(binding.map)
+				marker.position = OpenLocationCode.decode(locatable.location()).let { area ->
+					GeoPoint(area.centerLatitude, area.centerLongitude)
+				}
+				marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
+				marker.icon = context?.let { ctx -> locatable.icon(ctx) }
+
+				context?.let { ctx ->
+					marker.setOnMarkerClickListener { _, _ ->
+						MapBottomSheet(locatable).apply {
+							(ctx as MainActivity?)?.supportFragmentManager?.let { fm ->
+								show(fm, MapBottomSheet.TAG)
+							}
+						}
+						true
+					}
+				}
+				binding.map.overlays.add(marker)
+			}
+
+			binding.map.invalidate()
+		}
+	}
+
+	fun showLocation() {
+		snackbar = Snackbar.make(binding.root, "waiting for position", Snackbar.LENGTH_INDEFINITE)
+		snackbar!!.show()
+		binding.floatingActionButton.hide()
+		binding.map.overlays.removeAll {
+			it is MyLocationNewOverlay
+		}
+		locationOverlay.enableFollowLocation()
+		binding.map.overlays.add(locationOverlay)
+		locationOverlay.runOnFirstFix {
+			snackbar?.dismiss()
+		}
+	}
+
+	private fun getLocatables() {
+		maybeBinding?.let { binding ->
+			val (bl, tr) = binding.map.boundingBox.let {
+				Pair(
+					OpenLocationCode.encode(it.latSouth, it.lonWest),
+					OpenLocationCode.encode(it.latNorth, it.lonEast)
+				)
+			}
+			context?.let {
+				val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+				mapViewModel.getLocatablesIn(
+					cm,
+					Server.get(it), bl, tr
+				)
+			}
+			delayGetLocatables(30000)
+		}
+	}
+
+	private fun centreMap(preferences: SharedPreferences) {
+		maybeBinding?.map?.controller?.apply {
+			setZoom(preferences.getFloat("mapZoom", 17.0f).toDouble())
+			val startPoint = GeoPoint(
+				preferences.getFloat("mapCentreLat", 52.39511f).toDouble(),
+				preferences.getFloat("mapCentreLon", 16.89506f).toDouble()
+			)
+			setCenter(startPoint)
+		}
+	}
+
+	override fun onResume() {
+		super.onResume()
+		binding.map.onResume()
+		locationOverlay.enableMyLocation()
+		context?.let { ctx ->
+			ctx.getSharedPreferences("shp", MODE_PRIVATE).let {
+				Configuration.getInstance()
+					.load(ctx, it)
+				centreMap(it)
+			}
+		}
+	}
+
+	override fun onPause() {
+		super.onPause()
+		binding.map.onPause()
+		locationOverlay.disableMyLocation()
+		val centre = binding.map.mapCenter
+		context?.let { ctx ->
+			ctx.getSharedPreferences("shp", MODE_PRIVATE).edit {
+				this.putFloat("mapCentreLat", centre.latitude.toFloat())
+				this.putFloat("mapCentreLon", centre.longitude.toFloat())
+				this.putFloat("mapZoom", binding.map.zoomLevelDouble.toFloat())
+			}
+		}
+		handler.removeCallbacks(workRunnable)
+	}
+
 	override fun onDestroyView() {
 		super.onDestroyView()
-		_binding = null
+		maybeBinding = null
 	}
 }
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt
index f811e9b19b6ffd84189117f0c2e1e04114a79f61..2f520a17e16ebfa05701fd737638632dcb4a0285 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt
@@ -1,13 +1,187 @@
 package ml.adamsprogs.bimba.dashboard.ui.map
 
-import androidx.lifecycle.LiveData
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.net.ConnectivityManager
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.openlocationcode.OpenLocationCode
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import ml.adamsprogs.bimba.R
+import ml.adamsprogs.bimba.api.*
+import ml.adamsprogs.bimba.departures.DeparturesActivity
+import java.io.InputStream
 
 class MapViewModel : ViewModel() {
 
-	private val _text = MutableLiveData<String>().apply {
-		value = "This is notifications Fragment"
+	private val _items = MutableLiveData<List<Locatable>>()
+	val items: MutableLiveData<List<Locatable>> = _items
+
+	fun getLocatablesIn(cm: ConnectivityManager, server: Server, bl: String, tr: String) {
+		viewModelScope.launch {
+			val locatablesResult = ml.adamsprogs.bimba.api.getLocatablesIn(cm, server, bl, tr)
+			val response = if (locatablesResult.stream != null) {
+				unmarshallLocatablesResponse(locatablesResult.stream)
+			} else {
+				null
+			}
+			if (locatablesResult.error != null) {
+				Log.e("Results.location", "$locatablesResult")
+				Log.e("Results.location", "$response")
+				//todo showError(itemsResult.error)
+			} else {
+				_items.value = (response as LocatablesSuccess).locatables
+			}
+		}
+	}
+
+	private suspend fun unmarshallLocatablesResponse(stream: InputStream): LocatablesResponse {
+		return withContext(Dispatchers.IO) {
+			LocatablesResponse.unmarshal(stream)
+		}
 	}
-	val text: LiveData<String> = _text
+}
+
+class MapBottomSheet(private val locatable: Locatable) : BottomSheetDialogFragment() {
+	companion object {
+		const val TAG = "MapBottomSheet"
+	}
+
+	override fun onCreateView(
+		inflater: LayoutInflater,
+		container: ViewGroup?,
+		savedInstanceState: Bundle?
+	): View {
+		val content = inflater.inflate(R.layout.map_bottom_sheet, container, false)
+		content.apply {
+			when (locatable) {
+				is Vehicle -> {
+					findViewById<TextView>(R.id.title).apply {
+						text = "${locatable.Line.name} » ${locatable.Headsign}"
+						contentDescription = "${locatable.Line.name} towards ${locatable.Headsign}"
+					}
+					findViewById<TextView>(R.id.change_options).visibility = View.GONE
+					findViewById<Button>(R.id.departures_button).visibility = View.GONE
+					findViewById<Button>(R.id.navigation_button).visibility = View.GONE
+					findViewById<TextView>(R.id.speed_text).apply {
+						// todo units
+						val speed = locatable.Speed * 1.703
+						text = "%.3f Vl".format(speed)
+					}
+					findViewById<TextView>(R.id.congestion_text).apply {
+						text = when (locatable.CongestionLevel.toUInt()) {
+							0u -> "unknown"
+							1u -> "smooth traffic"
+							2u -> "stop and go"
+							3u -> "congestion"
+							4u -> "severe jams"
+							else -> TODO("throw invalid congestion")
+						}
+					}
+					findViewById<TextView>(R.id.occupancy_text).apply {
+						text = when (locatable.OccupancyStatus.toUInt()) {
+							0u -> "unknown"
+							1u -> "empty"
+							2u -> "many seats"
+							3u -> "few seats"
+							4u -> "standing only"
+							5u -> "crowded"
+							6u -> "full"
+							7u -> "won’t accept passengers"  // todo shorten
+							else -> TODO("throw invalid congestion")
+						}
+					}
+
+					findViewById<ImageView>(R.id.ac).visibility =
+						if (locatable.getCapability(Vehicle.Capability.AC)) {
+							View.VISIBLE
+						} else {
+							View.GONE
+						}
+					findViewById<ImageView>(R.id.bike).visibility =
+						if (locatable.getCapability(Vehicle.Capability.BIKE)) {
+							View.VISIBLE
+						} else {
+							View.GONE
+						}
+					findViewById<ImageView>(R.id.voice).visibility =
+						if (locatable.getCapability(Vehicle.Capability.VOICE)) {
+							View.VISIBLE
+						} else {
+							View.GONE
+						}
+					findViewById<ImageView>(R.id.ticket).visibility =
+						if (locatable.let {
+								it.getCapability(Vehicle.Capability.TICKET_DRIVER) || it.getCapability(Vehicle.Capability.TICKET_MACHINE)
+							}) {
+							View.VISIBLE
+						} else {
+							View.GONE
+						}
+					findViewById<ImageView>(R.id.usb).visibility =
+						if (locatable.getCapability(Vehicle.Capability.USB_CHARGING)) {
+							View.VISIBLE
+						} else {
+							View.GONE
+						}
+				}
+				is Stop -> {
+					findViewById<TextView>(R.id.title).text = "${locatable.name} [${locatable.code}]"
+					findViewById<Button>(R.id.departures_button).setOnClickListener {
+						val intent = Intent(context, DeparturesActivity::class.java).apply {
+							putExtra("code", locatable.code)
+							putExtra("name", locatable.name)
+						}
+						startActivity(intent)
+					}
+					findViewById<Button>(R.id.navigation_button).setOnClickListener {
+						OpenLocationCode.decode(locatable.position).let { position ->
+							try {
+								startActivity(
+									Intent(
+										Intent.ACTION_VIEW,
+										Uri.parse("geo:${position.centerLatitude},${position.centerLongitude}")
+									)
+								)
+							} catch (_: ActivityNotFoundException) {
+								Toast.makeText(context, "No maps app installed", Toast.LENGTH_SHORT).show()
+							}
+						}
+					}
+
+					findViewById<TextView>(R.id.change_options).text = locatable.changeOptions()
+
+					findViewById<ImageView>(R.id.speed_icon).visibility = View.GONE
+					findViewById<TextView>(R.id.speed_text).visibility = View.GONE
+					findViewById<ImageView>(R.id.congestion_icon).visibility = View.GONE
+					findViewById<TextView>(R.id.congestion_text).visibility = View.GONE
+					findViewById<ImageView>(R.id.occupancy_icon).visibility = View.GONE
+					findViewById<TextView>(R.id.occupancy_text).visibility = View.GONE
+
+					findViewById<ImageView>(R.id.ac).visibility = View.GONE
+					findViewById<ImageView>(R.id.bike).visibility = View.GONE
+					findViewById<ImageView>(R.id.voice).visibility = View.GONE
+					findViewById<ImageView>(R.id.ticket).visibility = View.GONE
+					findViewById<ImageView>(R.id.usb).visibility = View.GONE
+				}
+			}
+		}
+		//(dialog as BottomSheetDialog).behavior.peekHeight = dpToPixelI(90f)
+
+		return content
+	}
 }
\ 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 51b733ef56b74df0d568d2fd0ac41d097c95f1bb..aa1d82958e0319167f150132141f746f20f529a5 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt
@@ -23,6 +23,8 @@ import ml.adamsprogs.bimba.api.*
 import ml.adamsprogs.bimba.databinding.ActivityDeparturesBinding
 import java.io.InputStream
 
+// todo show stop on map
+
 class DeparturesActivity : AppCompatActivity() {
 	private var _binding: ActivityDeparturesBinding? = null
 	private val binding get() = _binding!!
@@ -52,8 +54,16 @@ 			}
 		}
 		binding.departuresRecycler.adapter = adapter
 		WindowCompat.setDecorFitsSystemWindows(window, false)
+	}
 
+	override fun onResume() {
+		super.onResume()
 		getDepartures()
+	}
+
+	override fun onPause() {
+		super.onPause()
+		handler.removeCallbacks(runnable)
 	}
 
 	private fun getName(): String {




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 0b7b80f155331f96e61085904576486cd1762cbd..9ce76e9d21d575e48739745db6497a38996de56c 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt
@@ -42,10 +42,7 @@ 		@SuppressLint("SetTextI18n")
 		fun bindStop(stop: Stop, holder: BimbaViewHolder?) {
 			holder?.icon?.setImageResource(R.drawable.stop)
 			holder?.title?.text = "${stop.name} [${stop.code}]"
-			holder?.description?.text =
-				stop.changeOptions.groupBy { it.line }
-					.map { Pair(it.key, it.value.joinToString { co -> co.headsign }) }
-					.joinToString { "${it.first} » ${it.second}" }
+			holder?.description?.text = stop.changeOptions()
 		}
 
 		@SuppressLint("SetTextI18n")




diff --git a/app/src/main/res/drawable/departure.xml b/app/src/main/res/drawable/departure.xml
new file mode 100644
index 0000000000000000000000000000000000000000..9bf1a5d515b626ebbfc9e9b6dcfc59240d614229
--- /dev/null
+++ b/app/src/main/res/drawable/departure.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="M16,1c-2.4,0 -4.52,1.21 -5.78,3.05 0.01,-0.01 0.01,-0.02 0.02,-0.03C9.84,4 9.42,4 9,4c-4.42,0 -8,0.5 -8,4v10c0,0.88 0.39,1.67 1,2.22L2,22c0,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.22v-3.08c3.39,-0.49 6,-3.39 6,-6.92 0,-3.87 -3.13,-7 -7,-7zM4.5,19c-0.83,0 -1.5,-0.67 -1.5,-1.5S3.67,16 4.5,16s1.5,0.67 1.5,1.5S5.33,19 4.5,19zM3,13L3,8h6c0,1.96 0.81,3.73 2.11,5L3,13zM13.5,19c-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.5zM16,13c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM16.5,4L15,4v5l3.62,2.16 0.75,-1.23 -2.87,-1.68z"/>
+</vector>




diff --git a/app/src/main/res/drawable/navigation_arrow.xml b/app/src/main/res/drawable/navigation_arrow.xml
new file mode 100644
index 0000000000000000000000000000000000000000..c29f851f60d8cbd8b38d63085504c6cde84c2ffc
--- /dev/null
+++ b/app/src/main/res/drawable/navigation_arrow.xml
@@ -0,0 +1,8 @@
+<vector android:height="24dp" android:tint="?colorPrimary"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillAlpha="0.3"
+        android:fillColor="@android:color/white"
+        android:pathData="M7.72,17.7l3.47,-1.53 0.81,-0.36 0.81,0.36 3.47,1.53L12,7.27z" android:strokeAlpha="0.3"/>
+    <path android:fillColor="@android:color/white" android:pathData="M4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71L12,2 4.5,20.29zM12.81,16.17l-0.81,-0.36 -0.81,0.36 -3.47,1.53L12,7.27l4.28,10.43 -3.47,-1.53z"/>
+</vector>




diff --git a/app/src/main/res/drawable/navigation_circle.xml b/app/src/main/res/drawable/navigation_circle.xml
new file mode 100644
index 0000000000000000000000000000000000000000..535e8520e6bde8d1784a1fb26a1ba701a26dfd74
--- /dev/null
+++ b/app/src/main/res/drawable/navigation_circle.xml
@@ -0,0 +1,8 @@
+<vector android:height="24dp" android:tint="?colorPrimary"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillAlpha="0.3"
+        android:fillColor="@android:color/white"
+        android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0" android:strokeAlpha="0.3"/>
+    <path android:fillColor="@android:color/white" android:pathData="M12,2C6.47,2 2,6.47 2,12c0,5.53 4.47,10 10,10s10,-4.47 10,-10C22,6.47 17.53,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,16.42 16.42,20 12,20z"/>
+</vector>




diff --git a/app/src/main/res/drawable/open_outside.xml b/app/src/main/res/drawable/open_outside.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2a44988cc484c69541ae1cf9e3db3b1745c12626
--- /dev/null
+++ b/app/src/main/res/drawable/open_outside.xml
@@ -0,0 +1,5 @@
+<vector android:autoMirrored="true" android:height="24dp"
+    android:tint="?attr/colorOnSurface" android:viewportHeight="24"
+    android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z"/>
+</vector>




diff --git a/app/src/main/res/drawable/stop_bg.xml b/app/src/main/res/drawable/stop_bg.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2bbe61ebf84e1af6e37ab94e17428476dca002a0
--- /dev/null
+++ b/app/src/main/res/drawable/stop_bg.xml
@@ -0,0 +1,9 @@
+<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="M2.807,12a9.193,9.193 0,1 0,18.386 0a9.193,9.193 0,1 0,-18.386 0z"
+      android:fillColor="@android:color/black"/>
+</vector>




diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml
index 619f8762a446d4a9644c88b56e08a217d00f7021..539858aa42b4041aea5db8e79b2f7c9f35b6aa96 100644
--- a/app/src/main/res/layout/fragment_map.xml
+++ b/app/src/main/res/layout/fragment_map.xml
@@ -1,22 +1,27 @@
 <?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:tools="http://schemas.android.com/tools"
 	android:layout_width="match_parent"
 	android:layout_height="match_parent"
 	tools:context=".dashboard.ui.map.MapFragment">
 
-	<TextView
-		android:id="@+id/text_notifications"
+	<org.osmdroid.views.MapView
+		android:id="@+id/map"
 		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="8dp"
-		android:layout_marginEnd="8dp"
-		android:textAlignment="center"
-		android:textSize="20sp"
+		android:layout_height="match_parent"
 		app:layout_constraintBottom_toBottomOf="parent"
 		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
 		app:layout_constraintTop_toTopOf="parent" />
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
+
+	<com.google.android.material.floatingactionbutton.FloatingActionButton
+		android:id="@+id/floating_action_button"
+		style="?attr/floatingActionButtonSmallStyle"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_gravity="bottom|right"
+		android:layout_margin="16dp"
+		android:contentDescription="@string/home_fab_description"
+		android:src="@drawable/gps_black" />
+</androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/map_bottom_sheet.xml b/app/src/main/res/layout/map_bottom_sheet.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d7ab738d5571271bc41a5417c7eab8c6b41e6282
--- /dev/null
+++ b/app/src/main/res/layout/map_bottom_sheet.xml
@@ -0,0 +1,201 @@
+<?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:layout_width="match_parent"
+	android:layout_height="wrap_content"
+	android:paddingBottom="16dp">
+
+	<com.google.android.material.bottomsheet.BottomSheetDragHandleView
+		android:id="@+id/drag_handle"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		app:layout_constraintTop_toTopOf="parent" />
+
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/title"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="48dp"
+		android:layout_marginEnd="8dp"
+		android:textAlignment="center"
+		android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"
+		tool:text="Aleje Marcinkowskiego [AMAR01]" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/change_options"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_margin="16dp"
+		android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/title"
+		tool:text="610 » Górczyn, 215 » Rondo Kaponiera, 392 » Słowiańska" />
+
+	<Button
+		android:id="@+id/departures_button"
+		style="@style/Widget.Material3.Button.TextButton.Icon"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_margin="4dp"
+		android:text="Show departures"
+		app:icon="@drawable/departure"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/change_options" />
+
+	<Button
+		android:id="@+id/navigation_button"
+		style="@style/Widget.Material3.Button.TextButton.Icon"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_margin="4dp"
+		android:text="Open in maps app"
+		app:icon="@drawable/open_outside"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/departures_button" />
+
+	<!--suppress AndroidUnknownAttribute -->
+	<ImageView
+		android:id="@+id/speed_icon"
+		android:layout_width="16dp"
+		android:layout_height="16dp"
+		android:layout_marginEnd="8dp"
+		android:importantForAccessibility="no"
+		app:layout_constraintBottom_toBottomOf="@+id/speed_text"
+		app:layout_constraintEnd_toStartOf="@+id/speed_text"
+		app:layout_constraintTop_toTopOf="@+id/speed_text"
+		app:srcCompat="@drawable/speed" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/speed_text"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginTop="8dp"
+		android:layout_marginEnd="8dp"
+		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+		app:layout_constraintEnd_toStartOf="@+id/middle"
+		app:layout_constraintTop_toBottomOf="@id/title"
+		tool:text="10 Vl" />
+
+	<!--suppress AndroidUnknownAttribute -->
+	<ImageView
+		android:id="@+id/congestion_icon"
+		android:layout_width="16dp"
+		android:layout_height="16dp"
+		android:layout_marginStart="8dp"
+		android:layout_marginEnd="8dp"
+		android:importantForAccessibility="no"
+		app:layout_constraintBottom_toBottomOf="@+id/congestion_text"
+		app:layout_constraintStart_toStartOf="@+id/middle"
+		app:layout_constraintTop_toTopOf="@+id/congestion_text"
+		app:srcCompat="@drawable/traffic" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/congestion_text"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="8dp"
+		android:layout_marginEnd="8dp"
+		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+		app:layout_constraintStart_toEndOf="@id/congestion_icon"
+		app:layout_constraintTop_toBottomOf="@id/title"
+		tool:text="smooth traffic" />
+
+	<!--suppress AndroidUnknownAttribute -->
+	<ImageView
+		android:id="@+id/occupancy_icon"
+		android:layout_width="16dp"
+		android:layout_height="16dp"
+		android:layout_marginStart="8dp"
+		android:layout_marginEnd="8dp"
+		android:importantForAccessibility="no"
+		app:layout_constraintBottom_toBottomOf="@+id/occupancy_text"
+		app:layout_constraintStart_toStartOf="@+id/middle"
+		app:layout_constraintTop_toTopOf="@+id/occupancy_text"
+		app:srcCompat="@drawable/crowd" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/occupancy_text"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="8dp"
+		android:layout_marginEnd="8dp"
+		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+		app:layout_constraintStart_toEndOf="@id/occupancy_icon"
+		app:layout_constraintTop_toBottomOf="@id/congestion_text"
+		tool:text="empty vehicle" />
+
+	<androidx.constraintlayout.widget.Guideline
+		android:id="@+id/middle"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:orientation="vertical"
+		app:layout_constraintGuide_percent=".5" />
+
+	<androidx.constraintlayout.helper.widget.Flow
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="16dp"
+		android:layout_marginEnd="8dp"
+		app:constraint_referenced_ids="ac,bike,voice,ticket,usb"
+		app:flow_horizontalGap="4dp"
+		app:flow_horizontalStyle="packed"
+		app:flow_verticalGap="4dp"
+		app:flow_wrapMode="chain"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/occupancy_text" />
+
+	<ImageView
+		android:id="@+id/ac"
+		android:layout_width="24dp"
+		android:layout_height="24dp"
+		android:contentDescription="air condition"
+		app:srcCompat="@drawable/ac"
+		tool:ignore="MissingConstraints" />
+
+	<ImageView
+		android:id="@+id/bike"
+		android:layout_width="24dp"
+		android:layout_height="24dp"
+		android:contentDescription="bicycles allowed"
+		app:srcCompat="@drawable/bike"
+		tool:ignore="MissingConstraints" />
+
+	<ImageView
+		android:id="@+id/voice"
+		android:layout_width="24dp"
+		android:layout_height="24dp"
+		android:contentDescription="voice announcements"
+		app:srcCompat="@drawable/voice"
+		tool:ignore="MissingConstraints" />
+
+	<ImageView
+		android:id="@+id/ticket"
+		android:layout_width="24dp"
+		android:layout_height="24dp"
+		android:contentDescription="tickets sold"
+		app:srcCompat="@drawable/ticket"
+		tool:ignore="MissingConstraints" />
+
+	<ImageView
+		android:id="@+id/usb"
+		android:layout_width="24dp"
+		android:layout_height="24dp"
+		android:contentDescription="USB charging"
+		app:srcCompat="@drawable/usb"
+		tool:ignore="MissingConstraints" />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>