Bimba.git

commit 0ae2a8256ae07d540e8e24148d64e56481e0d7de

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

merge develop into master for version 3.4.0

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


diff --git a/README.adoc b/README.adoc
index ca64b9acbe6363bc49bc599f03346fe86e70d549..f89c19fe5a0c108193f3166443f30c00901f5f57 100644
--- a/README.adoc
+++ b/README.adoc
@@ -4,7 +4,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 = Bimba
 Adam Evyčędo <me@apiote.xyz>
-v3.3.2 2024-05-22
+v3.4.0 2024-07-03
 :toc:
 
 Bimba is a FLOSS public transport passenger companion; a timetable in your pocket.




diff --git a/app/build.gradle b/app/build.gradle
index 95ad937cd17c833225b63ce882c5893c84872f58..54d50b5a32b00c98bde44d662ce7e94bfbe0df97 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -12,6 +12,7 @@     id 'org.jetbrains.kotlin.android'
     id "org.jetbrains.kotlin.plugin.parcelize"
     id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22'
     id 'com.mermake.locale-resource-generator' version '0.1'
+    id "com.google.protobuf" version "0.9.4"
 }
 
 android {
@@ -20,12 +21,12 @@
     defaultConfig {
         applicationId "xyz.apiote.bimba.czwek"
         minSdk 21
-        targetSdk 34
-        versionCode 27
-        versionName "3.3.2"
+        targetSdk 35
+        versionCode 28
+        versionName "3.4.0"
 
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-        resourceConfigurations += ["en", "pl", "it", "de"]
+        resourceConfigurations += ["en", "pl", "it", "de", "fr"]
     }
 
     applicationVariants.configureEach { variant ->
@@ -49,15 +50,19 @@         viewBinding true
     }
     namespace 'xyz.apiote.bimba.czwek'
     buildToolsVersion = '34.0.0'
+
+    sourceSets {
+        main.java.srcDirs += 'src/main/proto'
+    }
 }
 
 dependencies {
     implementation 'androidx.core:core-ktx:1.13.1'
-    implementation 'androidx.appcompat:appcompat:1.6.1'
+    implementation 'androidx.appcompat:appcompat:1.7.0'
     implementation 'com.google.android.material:material:1.12.0'
     implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
-    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.0'
-    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0'
+    implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.3'
+    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3'
     implementation 'androidx.navigation:navigation-fragment-ktx:2.7.7'
     implementation 'androidx.navigation:navigation-ui-ktx:2.7.7'
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
@@ -77,8 +82,8 @@
     coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
 
     testImplementation 'junit:junit:4.13.2'
-    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
-    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
+    androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+    androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
 }
 
 // NOTE fixes reproducible builds




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/AboutActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/AboutActivity.kt
index 836b721962f4c3a6f6056cffb95c1897a5d31ca6..cee5e4e154d8391de4925ee64a6afa39e1afce80 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/AboutActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/AboutActivity.kt
@@ -7,29 +7,49 @@
 import android.content.Intent
 import android.net.Uri
 import android.os.Bundle
+import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
 import xyz.apiote.bimba.czwek.databinding.ActivityAboutBinding
+import kotlin.math.max
 
 
 class AboutActivity : AppCompatActivity() {
-    private var _binding: ActivityAboutBinding? = null
-    private val binding get() = _binding!!
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        _binding = ActivityAboutBinding.inflate(layoutInflater)
-        setContentView(binding.root)
+	private var _binding: ActivityAboutBinding? = null
+	private val binding get() = _binding!!
+	override fun onCreate(savedInstanceState: Bundle?) {
+		enableEdgeToEdge()
+		super.onCreate(savedInstanceState)
+		_binding = ActivityAboutBinding.inflate(layoutInflater)
+		setContentView(binding.root)
 
-        binding.mastodon.setOnClickListener {
-            startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://floss.social/@bimba")))
-        }
-        binding.website.setOnClickListener {
-            startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://bimba.apiote.xyz")))
-        }
-        binding.code.setOnClickListener {
-            startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://git.apiote.xyz/Bimba.git")))
-        }
-        binding.translate.setOnClickListener {
-            startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://hosted.weblate.org/projects/bimba/")))
-        }
-    }
+		ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			val l = max(windowInsets.displayCutout?.safeInsetLeft ?: 0, insets.left)
+			val r = max(windowInsets.displayCutout?.safeInsetRight ?: 0, insets.right)
+			val t = max(windowInsets.displayCutout?.safeInsetTop ?: 0, insets.top)
+			v.updatePadding(left = l, right = r, top = t)
+			windowInsets
+		}
+
+		binding.mastodon.setOnClickListener {
+			startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://floss.social/@bimba")))
+		}
+		binding.website.setOnClickListener {
+			startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://bimba.apiote.xyz")))
+		}
+		binding.code.setOnClickListener {
+			startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://git.apiote.xyz/Bimba.git")))
+		}
+		binding.translate.setOnClickListener {
+			startActivity(
+				Intent(
+					Intent.ACTION_VIEW,
+					Uri.parse("https://hosted.weblate.org/projects/bimba/")
+				)
+			)
+		}
+	}
 }




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 3ebf1676b286d06d1505a26a162af9843274a88a..76953700a4a76e06a41716bcdcc43d07e50e1008 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
@@ -159,7 +159,8 @@ }
 
 fun mapHttpError(code: Int): Pair<Int, Int> {
 	return when (code) {
-		41 -> Pair(R.string.error_41, R.drawable.error_other) // TODO error_locality
+		41 -> Pair(R.string.error_41, R.drawable.error_locality)
+		44 -> Pair(R.string.error_44, R.drawable.error_departures)
 		400 -> Pair(R.string.error_400, R.drawable.error_app)
 		401 -> Pair(R.string.error_401, R.drawable.error_sec)
 		403 -> Pair(R.string.error_403, R.drawable.error_sec)
@@ -205,15 +206,15 @@
 private fun isNetworkAvailable(context: Context): Boolean {
 	val connectivityManager =
 		context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
-	
+
 	return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
-		connectivityManager.activeNetwork?.let {network ->
-			connectivityManager.getNetworkCapabilities(network)?.let {capabilities ->
+		connectivityManager.activeNetwork?.let { network ->
+			connectivityManager.getNetworkCapabilities(network)?.let { capabilities ->
 				capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || capabilities.hasTransport(
 					NetworkCapabilities.TRANSPORT_CELLULAR
 				) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)
 			}
-		}?: false
+		} ?: false
 	} else {
 		@Suppress("DEPRECATION")
 		connectivityManager.activeNetworkInfo?.isConnected ?: false




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
index 090e870cac6a76658ce555f4a8e7a0eac3f98355..b2a20c6ca37251d8b153b0789dbfa920fa015eb3 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt
@@ -11,14 +11,17 @@ import android.os.Bundle
 import android.view.View
 import android.widget.Toast
 import androidx.activity.OnBackPressedCallback
+import androidx.activity.enableEdgeToEdge
 import androidx.activity.result.ActivityResultLauncher
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.content.ContextCompat
 import androidx.core.content.edit
-import androidx.core.view.WindowCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.get
+import androidx.core.view.updatePadding
 import androidx.drawerlayout.widget.DrawerLayout.DrawerListener
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.FragmentManager
@@ -47,12 +50,20 @@ 	private lateinit var permissionAsker: Fragment
 	private var locationPermissionDialogShown = false
 
 	override fun onCreate(savedInstanceState: Bundle?) {
+		enableEdgeToEdge()
 		super.onCreate(savedInstanceState)
 		binding = ActivityMainBinding.inflate(layoutInflater)
 		setContentView(binding.root)
 
 		getSharedPreferences("shp", MODE_PRIVATE).edit(true) {
 			putBoolean("firstRun", false)
+		}
+
+		ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			val l = windowInsets.displayCutout?.safeInsetLeft?.takeIf { it > 0 } ?: insets.left
+			binding.navigationDrawer.updatePadding(left = l)
+			windowInsets
 		}
 
 		supportFragmentManager.registerFragmentLifecycleCallbacks(
@@ -105,7 +116,6 @@ 			}
 			false
 		}
 
-		WindowCompat.setDecorFitsSystemWindows(window, false)
 		val navView: BottomNavigationView = binding.bottomNavigation
 		val navHostFragment =
 			supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_main) as NavHostFragment
@@ -117,7 +127,7 @@ 			ActivityResultContracts.RequestMultiplePermissions()
 		) { permissions ->
 			when {
 				permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false ||
-								permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false -> {
+					permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false -> {
 					when (permissionAsker) {
 						is HomeFragment -> {
 							showResults(ResultsActivity.Mode.MODE_LOCATION)
@@ -133,10 +143,15 @@ 				else -> {
 					if (locationPermissionDialogShown) {
 						return@registerForActivityResult
 					}
-					MaterialAlertDialogBuilder(this).setIcon(AppCompatResources.getDrawable(this, R.drawable.error_gps))
+					MaterialAlertDialogBuilder(this).setIcon(
+						AppCompatResources.getDrawable(
+							this,
+							R.drawable.error_gps
+						)
+					)
 						.setTitle(getString(R.string.no_location_access))
 						.setMessage(getString(R.string.no_location_message))
-						.setPositiveButton(resources.getString(R.string.ok)) { _, _ ->}
+						.setPositiveButton(R.string.ok) { _, _ ->}
 						.show()
 					locationPermissionDialogShown = true
 				}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b3cb8722547c815be4076bc24615a101aba0a8f6
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt
@@ -0,0 +1,213 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.bimba.czwek.dashboard.ui.home
+
+import android.content.Context
+import android.content.Intent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import xyz.apiote.bimba.czwek.R
+import xyz.apiote.bimba.czwek.departures.DeparturesActivity
+import xyz.apiote.bimba.czwek.repo.Departure
+import xyz.apiote.bimba.czwek.repo.Favourite
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.util.Collections
+import java.util.Optional
+
+class BimbaFavouritesAdapter(
+	private var favourites: List<Favourite>,
+	private var departures: Map<String, Optional<Departure>>,
+	private val inflater: LayoutInflater,
+	private val context: Context
+) :
+	RecyclerView.Adapter<FavouriteViewHolder>() {
+	var lastUpdate: ZonedDateTime =
+		ZonedDateTime.of(0, 1, 1, 0, 0, 0, 0, ZoneId.systemDefault())
+		private set
+
+	inner class DiffUtilCallback(
+		private val oldFavourites: List<Favourite>,
+		private val oldDepartures: Map<String, Optional<Departure>?>,
+		private val newFavourites: List<Favourite>,
+		private val newDepartures: Map<String, Optional<Departure>?>
+	) : DiffUtil.Callback() {
+		override fun getOldListSize() = oldFavourites.size
+
+		override fun getNewListSize() = newFavourites.size
+
+		override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
+			oldFavourites[oldItemPosition].feedID + oldFavourites[oldItemPosition].stopCode == newFavourites[newItemPosition].feedID + newFavourites[newItemPosition].stopCode
+
+		@Suppress("KotlinConstantConditions", "UNNECESSARY_NOT_NULL_ASSERTION")
+		override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+			val oldFav = oldFavourites[oldItemPosition]
+			val newFav = newFavourites[newItemPosition]
+			val oldDeparture = oldDepartures[oldFav.feedID + oldFav.stopCode]
+			val newDeparture = newDepartures[oldFav.feedID + oldFav.stopCode]
+
+			if ((oldDeparture == null && newDeparture != null) || (oldDeparture != null && newDeparture == null)) {
+				return false
+			}
+
+			val favouritesSame = oldFav.feedName == newFav.feedName &&
+				oldFav.stopName == newFav.stopName &&
+				oldFav.sequence == newFav.sequence &&
+				oldFav.lines == newFav.lines
+
+			if (!favouritesSame) {
+				return false
+			}
+
+			if ((oldDeparture == null && newDeparture == null) || (oldDeparture!!.isEmpty && newDeparture!!.isEmpty)) {
+				return true
+			}
+
+			if ((oldDeparture!!.isEmpty && !newDeparture!!.isEmpty) || (!oldDeparture!!.isEmpty && newDeparture!!.isEmpty)) {
+				return false
+			}
+
+			return oldDeparture!!.get().ID == newDeparture!!.get().ID &&
+				oldDeparture!!.get().vehicle.Line == newDeparture!!.get().vehicle.Line &&
+				oldDeparture!!.get().vehicle.Headsign == newDeparture!!.get().vehicle.Headsign &&
+				oldDeparture!!.get().statusText(
+					context,
+					false,
+					lastUpdate
+				) == newDeparture!!.get().statusText(context, false)
+		}
+	}
+
+	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavouriteViewHolder {
+		val rowView = inflater.inflate(R.layout.favourite, parent, false)
+		return FavouriteViewHolder(rowView)
+	}
+
+	override fun getItemCount() = favourites.size
+
+	override fun onBindViewHolder(holder: FavouriteViewHolder, position: Int) {
+		FavouriteViewHolder.bind(
+			favourites[position], holder, context,
+			departures[favourites[position].feedID + favourites[position].stopCode]
+		)
+	}
+
+	fun updateFavourites(favourites: List<Favourite>) {
+		val diff = DiffUtil.calculateDiff(
+			DiffUtilCallback(
+				this.favourites,
+				this.departures,
+				favourites,
+				this.departures
+			)
+		)
+		this.favourites = favourites
+		diff.dispatchUpdatesTo(this)
+	}
+
+	fun updateDepartures(departures: Map<String, Optional<Departure>>) {
+		this.departures = departures
+		notifyDataSetChanged()
+		lastUpdate = ZonedDateTime.now()
+	}
+
+	fun swap(from: Int, to: Int): List<Favourite> {
+		Collections.swap(favourites, from, to)
+		favourites = favourites.mapIndexed { i, it ->
+			it.copy(sequence = i)
+		}
+		notifyItemMoved(from, to)
+		return favourites
+	}
+
+	fun delete(position: Int): Pair<List<Favourite>, Favourite> {
+		val removedFavourite = favourites[position]
+		favourites = favourites.filterIndexed { i, _ ->
+			i != position
+		}.mapIndexed { i, it ->
+			it.copy(sequence = i)
+		}
+		notifyItemRemoved(position)
+		return Pair(favourites, removedFavourite)
+	}
+
+	fun insert(removedFavourite: Favourite): List<Favourite> {
+		favourites = favourites.toMutableList().apply {
+			add(removedFavourite.sequence!!, removedFavourite)
+		}.mapIndexed { i, it ->
+			it.copy(sequence = i)
+		}
+		return favourites
+	}
+}
+
+class FavouriteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+	val root: View = itemView.findViewById(R.id.favourite)
+	val feedName: TextView = itemView.findViewById(R.id.feed_name)
+	val lineIcon: ImageView = itemView.findViewById(R.id.line_icon)
+	val departureTime: TextView = itemView.findViewById(R.id.departure_time)
+	val departureTimeFull: TextView = itemView.findViewById(R.id.departure_full_time)
+	val lineName: TextView = itemView.findViewById(R.id.departure_line)
+	val headsign: TextView = itemView.findViewById(R.id.departure_headsign)
+	val stopHeadline: TextView = itemView.findViewById(R.id.stop_name)
+
+	companion object {
+		fun bind(
+			favourite: Favourite,
+			holder: FavouriteViewHolder,
+			context: Context,
+			departure: Optional<Departure>?
+		) {
+			if (departure == null) {
+				holder.feedName.text = favourite.feedName
+				holder.stopHeadline.text = favourite.stopName
+				holder.lineIcon.setImageDrawable(null)
+				holder.lineName.text = context.getString(R.string.loading)
+				holder.departureTime.text = ""
+				holder.departureTimeFull.text = ""
+				holder.headsign.text = ""
+			} else if (departure.isEmpty) {
+				holder.feedName.text = favourite.feedName
+				holder.stopHeadline.text = favourite.stopName
+				holder.lineIcon.setImageDrawable(null)
+				holder.lineName.text = context.getString(R.string.no_next_departures)
+				holder.departureTime.text = ""
+				holder.departureTimeFull.text = ""
+				holder.headsign.text = ""
+			} else {
+				val vehicle = departure.get().vehicle
+				holder.feedName.text = favourite.feedName
+				holder.stopHeadline.text = favourite.stopName
+				holder.lineIcon.setImageDrawable(vehicle.Line.icon(context))
+				holder.lineIcon.contentDescription = vehicle.Line.kind.name
+				holder.lineName.text = vehicle.Line.name
+				holder.departureTime.text = departure.get().statusText(context, false)
+				holder.departureTimeFull.text = departure.get().timeString(context)
+				holder.headsign.text =
+					context.getString(R.string.departure_headsign, vehicle.Headsign)
+				holder.headsign.contentDescription =
+					context.getString(
+						R.string.departure_headsign_content_description,
+						vehicle.Headsign
+					)
+			}
+
+			holder.root.setOnClickListener {
+				val intent = Intent(context, DeparturesActivity::class.java).apply {
+					putExtra("code", favourite.stopCode)
+					putExtra("name", favourite.stopName)
+					putExtra("feedID", favourite.feedID)
+					putExtra("linesFilter", favourite.lines.toTypedArray())
+				}
+				context.startActivity(intent)
+			}
+		}
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt
index 937651e34f7d64ab76dc0caeecf9ae19f23374ce..d3d3c1afc89244763f309441eb5c24275862d30d 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt
@@ -5,28 +5,50 @@
 package xyz.apiote.bimba.czwek.dashboard.ui.home
 
 import android.os.Bundle
+import android.os.CountDownTimer
 import android.view.KeyEvent
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
-import android.widget.FrameLayout
 import android.widget.TextView
 import androidx.activity.OnBackPressedCallback
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.ItemTouchHelper
 import androidx.recyclerview.widget.LinearLayoutManager
-import com.google.android.material.search.SearchView
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.search.SearchView.TransitionState
+import com.google.android.material.snackbar.Snackbar
+import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.dashboard.MainActivity
 import xyz.apiote.bimba.czwek.databinding.FragmentHomeBinding
+import xyz.apiote.bimba.czwek.dpToPixelI
+import xyz.apiote.bimba.czwek.repo.Favourite
 import xyz.apiote.bimba.czwek.search.BimbaResultsAdapter
+import xyz.apiote.bimba.czwek.units.Millisecond
+import xyz.apiote.bimba.czwek.units.Second
 
 class HomeFragment : Fragment() {
 	private var _binding: FragmentHomeBinding? = null
 	private val binding get() = _binding!!
 
 	private lateinit var adapter: BimbaResultsAdapter
+	private lateinit var favouritesAdapter: BimbaFavouritesAdapter
+	private lateinit var viewModel: HomeViewModel
+
+	private val countdown =
+		object : CountDownTimer(Millisecond(Second(30)).millis, Millisecond(Second(10)).millis) {
+			override fun onTick(millisUntilFinished: Long) {
+			}
+
+			override fun onFinish() {
+				refreshDepartures()
+			}
+		}
 
 	override fun onCreateView(
 		inflater: LayoutInflater,
@@ -35,35 +57,40 @@ 		savedInstanceState: Bundle?
 	): View {
 		_binding = FragmentHomeBinding.inflate(inflater, container, false)
 
-		val homeViewModel =
+		viewModel =
 			ViewModelProvider(this)[HomeViewModel::class.java]
-		homeViewModel.queryables.observe(viewLifecycleOwner) {
-			adapter.feedsSettings = homeViewModel.feedsSettings
-			adapter.feeds = homeViewModel.feeds
+		viewModel.queryables.observe(viewLifecycleOwner) {
+			adapter.feedsSettings = viewModel.feedsSettings
+			adapter.feeds = viewModel.feeds
 			adapter.update(it)
 		}
-
-		val root = binding.root
-		ViewCompat.setOnApplyWindowInsetsListener(root) { view, windowInsets ->
-			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
-			view.layoutParams = (view.layoutParams as FrameLayout.LayoutParams).apply {
-				topMargin = insets.top
-			}
-			WindowInsetsCompat.CONSUMED
+		viewModel.favourites.observe(viewLifecycleOwner) {
+			favouritesAdapter.updateFavourites(it)
+			refreshDepartures()
+		}
+		viewModel.departures.observe(viewLifecycleOwner) {
+			favouritesAdapter.updateDepartures(it)
 		}
 
+		countdown.start()
+
+		binding.searchView.setupWithSearchBar(binding.searchBar)
+
 		val onBackPressedCallback = object :
-			OnBackPressedCallback(binding.searchView.currentTransitionState == SearchView.TransitionState.SHOWN) {
+			OnBackPressedCallback(binding.searchView.currentTransitionState == TransitionState.SHOWN) {
 			override fun handleOnBackPressed() {
-				binding.searchView.hide()
+				binding.searchBar.collapse(binding.searchView)
 			}
 		}
 		activity?.onBackPressedDispatcher?.addCallback(onBackPressedCallback)
 		binding.searchView.addTransitionListener { _, _, newState ->
 			onBackPressedCallback.isEnabled = when (newState) {
-				SearchView.TransitionState.SHOWN -> true
-				SearchView.TransitionState.HIDDEN -> false
+				TransitionState.SHOWN -> true
+				TransitionState.HIDDEN -> false
 				else -> false
+			}
+			if (newState === TransitionState.HIDING) {
+				adapter.update(listOf())
 			}
 		}
 
@@ -75,7 +102,7 @@ 		adapter = BimbaResultsAdapter(layoutInflater, activity, listOf())
 		binding.suggestionsRecycler.adapter = adapter
 
 		binding.searchView.editText.addTextChangedListener(
-			homeViewModel.SearchBarWatcher(requireContext())
+			viewModel.SearchBarWatcher(requireContext())
 		)
 		binding.searchView.editText.setOnKeyListener { v, keyCode, event ->
 			when (keyCode) {
@@ -96,11 +123,101 @@ 		binding.floatingActionButton.setOnClickListener {
 			(context as MainActivity).onGpsClicked(this)
 		}
 
+		ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			binding.floatingActionButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+				rightMargin = insets.right + dpToPixelI(16f)
+			}
+			binding.searchBar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+				rightMargin = insets.right + dpToPixelI(8f)
+				leftMargin = insets.left + dpToPixelI(8f)
+				topMargin = insets.top + dpToPixelI(8f)
+			}
+			binding.suggestionsRecycler.updatePadding(left = insets.left, right = insets.right)
+			windowInsets.displayCutout?.safeInsetLeft?.let {
+				binding.suggestionsRecycler.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+					leftMargin = it
+				}
+			}
+			binding.favourites.updatePadding(left = insets.left, right = insets.right)
+			binding.favourites.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+				windowInsets.displayCutout?.safeInsetLeft?.let {
+					leftMargin = it + dpToPixelI(16f)
+				}
+				windowInsets.displayCutout?.safeInsetRight?.let {
+					rightMargin = it + dpToPixelI(16f)
+				}
+			}
+			windowInsets
+		}
+
+		binding.favourites.layoutManager = LinearLayoutManager(context)
+		favouritesAdapter = BimbaFavouritesAdapter(listOf(), mapOf(), layoutInflater, requireContext())
+		binding.favourites.adapter = favouritesAdapter
+
+		viewModel.getFavourites(requireContext())
+
+		val ith = ItemTouchHelper(object : ItemTouchHelper.Callback() {
+			var newFavourites = emptyList<Favourite>()
+			override fun onMove(
+				recyclerView: RecyclerView,
+				viewHolder: RecyclerView.ViewHolder,
+				target: RecyclerView.ViewHolder
+			): Boolean {
+				newFavourites =
+					favouritesAdapter.swap(viewHolder.absoluteAdapterPosition, target.absoluteAdapterPosition)
+				return true
+			}
+
+			override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+				val (newFavourites, removedFavourite) = favouritesAdapter.delete(viewHolder.absoluteAdapterPosition)
+				this.newFavourites = newFavourites
+				Snackbar.make(binding.fragmentRoot, R.string.favourite_deleted, Snackbar.LENGTH_LONG)
+					.setAction(R.string.undo) {
+						this.newFavourites = favouritesAdapter.insert(removedFavourite)
+						viewModel.saveFavourites(this.newFavourites, requireContext())
+					}
+					.show()
+			}
+
+			override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
+				super.clearView(recyclerView, viewHolder)
+
+				viewModel.saveFavourites(newFavourites, requireContext())
+			}
+
+			override fun getMovementFlags(
+				recyclerView: RecyclerView,
+				viewHolder: RecyclerView.ViewHolder
+			): Int {
+				return makeFlag(
+					ItemTouchHelper.ACTION_STATE_DRAG,
+					ItemTouchHelper.DOWN or ItemTouchHelper.UP
+				) or makeFlag(
+					ItemTouchHelper.ACTION_STATE_SWIPE,
+					ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
+				)
+			}
+		})
+		ith.attachToRecyclerView(binding.favourites)
+
+
 		return binding.root
 	}
 
+	fun refreshDepartures() {
+		viewModel.getDepartures(requireContext())
+		countdown.start()
+	}
+
+	override fun onResume() {
+		super.onResume()
+		viewModel.getFavourites(requireContext())
+	}
+
 	override fun onDestroyView() {
 		super.onDestroyView()
 		_binding = null
+		countdown.cancel()
 	}
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
index d42be675e2f6c4b569a608c3c0e66e7598f1b4ab..ba6da66f9308e7dc91bd81aca46cff37c3dffee1 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt
@@ -14,19 +14,30 @@ import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.launch
+import xyz.apiote.bimba.czwek.repo.Departure
+import xyz.apiote.bimba.czwek.repo.Favourite
 import xyz.apiote.bimba.czwek.repo.FeedInfo
 import xyz.apiote.bimba.czwek.repo.OfflineRepository
 import xyz.apiote.bimba.czwek.repo.OnlineRepository
 import xyz.apiote.bimba.czwek.repo.Queryable
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
 import xyz.apiote.bimba.czwek.settings.feeds.FeedsSettings
+import java.sql.SQLException
+import java.util.Optional
 
 class HomeViewModel : ViewModel() {
 	private val mutableQueryables = MutableLiveData<List<Queryable>>()
 	val queryables: LiveData<List<Queryable>> = mutableQueryables
 	var feeds: Map<String, FeedInfo>? = null
 	var feedsSettings: FeedsSettings? = null
+	private val mutableFavourites = MutableLiveData<List<Favourite>>()
+	val favourites: LiveData<List<Favourite>> = mutableFavourites
+	private val mutableDepartures = MutableLiveData<Map<String, Optional<Departure>>>()
+	val departures: LiveData<Map<String, Optional<Departure>>> = mutableDepartures
 
 	fun getQueryables(query: String, context: Context) {
 		viewModelScope.launch {
@@ -34,15 +45,87 @@ 			try {
 				getFeeds(context)
 				mutableQueryables.value = OnlineRepository().queryQueryables(query, context) ?: emptyList()
 			} catch (e: TrafficResponseException) {
-				// xxx intentionally no error showing in suggestions
+				// XXX intentionally no error showing in suggestions
 				Log.e("Suggestion", "$e")
 			}
 		}
 	}
 
+	fun getFavourites(context: Context) {
+		viewModelScope.launch {
+			try {
+				getFeeds(context)
+				val repository = OfflineRepository(context)
+				mutableFavourites.value =
+					repository.getFavourites(feedsSettings?.activeFeeds() ?: emptySet())
+				repository.close()
+			} catch (e: SQLException) {
+				Log.w("FavouritesForFavourite", "$e")
+			}
+			getDeparturesOnly(context)
+		}
+	}
+
+	fun getDepartures(context: Context) {
+		viewModelScope.launch {
+			getDeparturesOnly(context)
+		}
+	}
+
+	private suspend fun getDeparturesOnly(context: Context) {
+		coroutineScope {
+			if (favourites.value == null)
+				return@coroutineScope
+			mutableDepartures.value = favourites.value!!.map { favourite ->
+				async {
+					try {
+						val repository = OnlineRepository()
+						val stopDepartures =
+							repository.getDepartures(
+								favourite.feedID,
+								favourite.stopCode,
+								null,
+								context,
+								12  // XXX heuristics
+							)
+							stopDepartures?.let { sDs ->
+								if (sDs.departures.isEmpty()) {
+									Pair(favourite.feedID+favourite.stopCode, Optional.empty())
+								} else {
+									Pair(favourite.feedID+favourite.stopCode, Optional.ofNullable(sDs.departures.find { departure ->
+											favourite.lines.isEmpty() or favourite.lines.contains(
+												departure.vehicle.Line.name
+											)
+										}))
+								}
+						} ?: Pair(favourite.feedID+favourite.stopCode, Optional.empty())
+					} catch (e: TrafficResponseException) {
+						Log.w("DeparturesForFavourite", "$e")
+						Pair(favourite.feedID+favourite.stopCode, Optional.empty())
+					}
+				}
+			}.awaitAll().associate { it }
+		}
+	}
+
 	private suspend fun getFeeds(context: Context) {
-		feeds = OfflineRepository().getFeeds(context)
+		val repository = OfflineRepository(context)
+		feeds = repository.getFeeds(context)
+		repository.close()
 		feedsSettings = FeedsSettings.load(context)
+	}
+
+	fun saveFavourites(newFavourites: List<Favourite>, context: Context) {
+		viewModelScope.launch {
+			try {
+				val repository = OfflineRepository(context)
+				repository.saveFavourites(newFavourites.toSet())
+				mutableFavourites.value = newFavourites
+				repository.close()
+			} catch (e: SQLException) {
+				Log.w("FavouritesForFavourite", "$e")
+			}
+		}
 	}
 
 




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt
index 5ac3de0f96d50708abd7e8c71f2e31aaaa772966..42dcc1a330de20dc80aa99bab9e5755931c00351 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt
@@ -11,12 +11,12 @@ import android.content.res.Configuration.UI_MODE_NIGHT_MASK
 import android.content.res.Configuration.UI_MODE_NIGHT_UNDEFINED
 import android.content.res.Configuration.UI_MODE_NIGHT_YES
 import android.graphics.Bitmap
+import android.os.Build
 import android.os.Bundle
 import android.os.Handler
 import android.os.Looper
 import android.text.Html
 import android.text.method.LinkMovementMethod
-import android.util.Log
 import android.view.Gravity
 import android.view.LayoutInflater
 import android.view.View
@@ -27,6 +27,9 @@ import androidx.appcompat.content.res.AppCompatResources
 import androidx.coordinatorlayout.widget.CoordinatorLayout
 import androidx.core.content.edit
 import androidx.core.graphics.drawable.toBitmap
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updateLayoutParams
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
 import com.google.android.material.snackbar.Snackbar
@@ -81,7 +84,7 @@ 		val root: View = binding.root
 
 		binding.map.setTileSource(TileSourceFactory.MAPNIK)
 		if (((context?.resources?.configuration?.uiMode ?: UI_MODE_NIGHT_UNDEFINED)
-							and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES
+				and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES
 		) {
 			binding.map.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS)
 		}
@@ -129,12 +132,15 @@ 			false
 		}
 
 		val attributionOverlay = TextView(context).apply {
-			// TODO deprecated in API 24
-			text =
+			text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+				Html.fromHtml(getString(R.string.map_attribution), 0)
+			} else {
+				@Suppress("DEPRECATION")
 				Html.fromHtml(getString(R.string.map_attribution))
+			}
 			linksClickable = true
 			movementMethod = LinkMovementMethod.getInstance()
-			setPadding(10,10,10,10)
+			setPadding(10, 10, 10, 10)
 		}
 		val layoutParams = CoordinatorLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
 			gravity = Gravity.END or Gravity.BOTTOM
@@ -145,12 +151,22 @@ 			layoutParams
 		)
 		attributionOverlay.bringToFront()
 
+		ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			binding.floatingActionButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+				rightMargin += insets.right
+			}
+			attributionOverlay.updateLayoutParams<ViewGroup.MarginLayoutParams> {
+				rightMargin += insets.right
+			}
+			windowInsets
+		}
+
 		return root
 	}
 
 	private fun onMapMove() {
 		snack?.dismiss()
-		Log.i("ZOOM", "${binding.map.zoomLevelDouble}")
 		delayGetLocatables()
 	}
 
@@ -263,9 +279,9 @@ 		locationOverlay.disableMyLocation()
 		val centre = binding.map.mapCenter
 		context?.let { ctx ->
 			ctx.getSharedPreferences("shp", MODE_PRIVATE).edit(true) {
-				this.putFloat("mapCentreLat", centre.latitude.toFloat())
-				this.putFloat("mapCentreLon", centre.longitude.toFloat())
-				this.putFloat("mapZoom", binding.map.zoomLevelDouble.toFloat())
+				putFloat("mapCentreLat", centre.latitude.toFloat())
+				putFloat("mapCentreLon", centre.longitude.toFloat())
+				putFloat("mapZoom", binding.map.zoomLevelDouble.toFloat())
 			}
 		}
 		handler.removeCallbacks(workRunnable)




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
index 7b334c3d4efecdfa7b18e8d98c1de5bb9d8e1e6c..b9b2871dd953aab86b88c501729cc02801e8f705 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt
@@ -14,12 +14,14 @@ import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
+import android.widget.Button
 import android.widget.ImageView
 import android.widget.LinearLayout
 import android.widget.TextView
 import androidx.appcompat.widget.TooltipCompat
 import androidx.recyclerview.widget.DiffUtil
 import androidx.recyclerview.widget.RecyclerView
+import androidx.recyclerview.widget.RecyclerView.ViewHolder
 import com.google.android.material.bottomsheet.BottomSheetDialog
 import com.google.android.material.bottomsheet.BottomSheetDialogFragment
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -33,15 +35,16 @@ import org.osmdroid.views.overlay.TilesOverlay
 import org.osmdroid.views.overlay.gestures.RotationGestureOverlay
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.dpToPixelI
+import xyz.apiote.bimba.czwek.repo.Alert
 import xyz.apiote.bimba.czwek.repo.CongestionLevel
 import xyz.apiote.bimba.czwek.repo.Departure
+import xyz.apiote.bimba.czwek.repo.DepartureItem
 import xyz.apiote.bimba.czwek.repo.OccupancyStatus
 import xyz.apiote.bimba.czwek.repo.Vehicle
 import java.time.ZoneId
 import java.time.ZonedDateTime
 
-
-class BimbaDepartureViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+class BimbaDepartureViewHolder(itemView: View) : ViewHolder(itemView) {
 	val root: View = itemView.findViewById(R.id.departure)
 	val lineIcon: ImageView = itemView.findViewById(R.id.line_icon)
 	val departureTime: TextView = itemView.findViewById(R.id.departure_time)
@@ -71,6 +74,36 @@ 					departure.vehicle.Headsign
 				)
 
 			holder?.departureTime?.text = departure.statusText(context, showAsTime)
+		}
+	}
+}
+
+class BimbaAlertViewHolder(itemView: View) : ViewHolder(itemView) {
+	val root: View = itemView.findViewById(R.id.alerts)
+	val text: TextView = itemView.findViewById(R.id.alerts_text)
+	val moreButton: Button = itemView.findViewById(R.id.more_button)
+
+	companion object {
+		fun bind(
+			alerts: List<Alert>,
+			holder: BimbaAlertViewHolder?,
+			context: Context?
+		) {
+			val alertDescriptions = alerts.map { it.description }.filter { it != "" }
+				.joinToString(separator = "\n")
+			holder?.moreButton?.setOnClickListener{
+				MaterialAlertDialogBuilder(context!!)
+					.setTitle("Alerts")
+					.setPositiveButton(R.string.ok) { _, _ -> }
+					.setMessage(alertDescriptions)
+					.show()
+			}
+			holder?.moreButton?.visibility = if (alertDescriptions == "") View.GONE else View.VISIBLE
+			holder?.text?.text = alerts.map {
+				it.header.ifEmpty {
+					context!!.getString(R.string.alert_header)
+				}
+			}.toSet().joinToString(separator = "\n")
 		}
 	}
 }
@@ -78,82 +111,124 @@
 class BimbaDeparturesAdapter(
 	private val inflater: LayoutInflater,
 	private val context: Context?,
-	private var departures: List<Departure>,
+	private var items: List<DepartureItem>,
 	private val onClickListener: ((Departure) -> Unit)
 ) :
-	RecyclerView.Adapter<BimbaDepartureViewHolder>() {
+	RecyclerView.Adapter<ViewHolder>() {
 	var lastUpdate: ZonedDateTime =
 		ZonedDateTime.of(0, 1, 1, 0, 0, 0, 0, ZoneId.systemDefault())
 		private set
 	private var showAsTime: Boolean = false
 
 	inner class DiffUtilCallback(
-		private val oldDepartures: List<Departure>,
-		private val newDepartures: List<Departure>,
-		private val showAsTimeChanged: Boolean
+		private val oldDepartures: List<DepartureItem>,
+		private val newDepartures: List<DepartureItem>,
+		private val showAsTimeChanged: Boolean,
 	) : DiffUtil.Callback() {
 		override fun getOldListSize() = oldDepartures.size
 
 		override fun getNewListSize() = newDepartures.size
 
 		override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
-			oldDepartures[oldItemPosition].ID == newDepartures[newItemPosition].ID
+			(oldDepartures[oldItemPosition].departure?.ID
+				?: "alert") == (newDepartures[newItemPosition].departure?.ID ?: "alert")
 
 		override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
 			val oldDeparture = oldDepartures[oldItemPosition]
 			val newDeparture = newDepartures[newItemPosition]
-			return oldDeparture.vehicle.Line == newDeparture.vehicle.Line && oldDeparture.vehicle.Headsign == newDeparture.vehicle.Headsign &&
-				oldDeparture.statusText(context, false, lastUpdate) == newDeparture.statusText(
-				context,
+			return if (oldDeparture.departure != null && newDeparture.departure != null) {
+				oldDeparture.departure.vehicle.Line == newDeparture.departure.vehicle.Line &&
+					oldDeparture.departure.vehicle.Headsign == newDeparture.departure.vehicle.Headsign &&
+					oldDeparture.departure.statusText(
+						context,
+						false,
+						lastUpdate
+					) == newDeparture.departure.statusText(context, false) && !showAsTimeChanged
+			} else if (oldDeparture.alert.isNotEmpty() && newDeparture.alert.isEmpty()) {
+				oldDeparture.alert == newDeparture.alert
+			} else {
 				false
-			)  && !showAsTimeChanged
+			}
 		}
 	}
 
 	private var departuresPositions: MutableMap<String, Int> = HashMap()
 
 	init {
-		departures.forEachIndexed { i, departure ->
-			departuresPositions[departure.ID] = i
+		items.forEachIndexed { i, departure ->
+			departuresPositions[departure.departure?.ID ?: "alert"] = i
+		}
+	}
+
+	override fun getItemViewType(position: Int): Int {
+		return if (items[position].departure != null) {
+			0
+		} else {
+			1
 		}
 	}
 
-	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaDepartureViewHolder {
-		val rowView = inflater.inflate(R.layout.departure, parent, false)
-		return BimbaDepartureViewHolder(rowView)
+	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+		if (viewType == 0) {
+			val rowView = inflater.inflate(R.layout.departure, parent, false)
+			return BimbaDepartureViewHolder(rowView)
+		} else {
+			val rowView = inflater.inflate(R.layout.alert, parent, false)
+			return BimbaAlertViewHolder(rowView)
+		}
 	}
 
-	override fun onBindViewHolder(holder: BimbaDepartureViewHolder, position: Int) {
-		BimbaDepartureViewHolder.bind(
-			departures[position],
-			holder,
-			context,
-			showAsTime,
-			onClickListener
-		)
+	override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+		if (holder is BimbaDepartureViewHolder) {
+			BimbaDepartureViewHolder.bind(
+				items[position].departure!!,
+				holder,
+				context,
+				showAsTime,
+				onClickListener
+			)
+		} else {
+			BimbaAlertViewHolder.bind(items[position].alert, holder as BimbaAlertViewHolder, context)
+		}
 	}
 
-	override fun getItemCount(): Int = departures.size
+	override fun getItemCount(): Int = items.size
 
-	fun get(id: String): Departure? {
+	fun get(id: String): DepartureItem? {
 		val position = departuresPositions[id]
 		return if (position == null) {
 			null
 		} else {
-			departures[position]
+			items[position]
 		}
 	}
 
-	fun update(departures: List<Departure>, showAsTime: Boolean, areNewObserved: Boolean = false) {
+	fun update(
+		departures: List<DepartureItem>,
+		showAsTime: Boolean,
+		areNewObserved: Boolean = false,
+		leaveAlert: Boolean = false
+	) {
+		val newDepartures = if (leaveAlert && items.getOrNull(0)?.alert?.isNotEmpty() == true) {
+			listOf(items[0]) + departures
+		} else {
+			departures
+		}
 		val newPositions: MutableMap<String, Int> = HashMap()
-		departures.forEachIndexed { i, departure ->
-			newPositions[departure.ID] = i
+		newDepartures.forEachIndexed { i, departure ->
+			newPositions[departure.departure?.ID ?: "alert"] = i
 		}
-		val diff = DiffUtil.calculateDiff(DiffUtilCallback(this.departures, departures, this.showAsTime != showAsTime))
+		val diff = DiffUtil.calculateDiff(
+			DiffUtilCallback(
+				this.items,
+				newDepartures,
+				this.showAsTime != showAsTime
+			)
+		)
 
 		this.showAsTime = showAsTime
 
-		this.departures = departures
+		this.items = newDepartures
 		departuresPositions = newPositions
 		if (areNewObserved) {
 			lastUpdate = ZonedDateTime.now()
@@ -162,7 +237,7 @@ 		diff.dispatchUpdatesTo(this)
 	}
 
 	fun refreshItems() {
-		update(this.departures, showAsTime)
+		update(this.items, showAsTime)
 	}
 }
 
@@ -188,7 +263,7 @@ 	}
 
 	fun update(departure: Departure) {
 		this.departure = departure
-		this.view?.let { context?.let { ctx -> setContent(it, ctx, true) } }
+		view?.let { context?.let { ctx -> setContent(it, ctx, true) } }
 	}
 
 	private fun setContent(view: View, ctx: Context, updating: Boolean = false) {




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 3ace64fd176fe6079d89e6ecd848ed72e9a42ec6..783606925186535e8a5ba30761e3b1ae932b6b2d 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
@@ -12,15 +12,19 @@ import android.net.NetworkCapabilities
 import android.net.NetworkRequest
 import android.os.Build
 import android.os.Bundle
-import android.os.Handler
-import android.os.Looper
+import android.os.CountDownTimer
 import android.text.format.DateUtils
 import android.text.format.DateUtils.MINUTE_IN_MILLIS
 import android.view.View
+import android.widget.Toast
+import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.content.res.ResourcesCompat
+import androidx.core.view.ViewCompat
 import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
 import androidx.lifecycle.ViewModelProvider
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
@@ -29,14 +33,21 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import com.google.android.material.snackbar.Snackbar
 import com.google.android.material.timepicker.MaterialTimePicker
 import com.google.android.material.timepicker.TimeFormat
-import kotlinx.coroutines.Runnable
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.api.Error
+import xyz.apiote.bimba.czwek.api.Server
 import xyz.apiote.bimba.czwek.databinding.ActivityDeparturesBinding
-import xyz.apiote.bimba.czwek.repo.Departure
+import xyz.apiote.bimba.czwek.repo.DepartureItem
+import xyz.apiote.bimba.czwek.repo.Favourite
+import xyz.apiote.bimba.czwek.repo.OfflineRepository
 import xyz.apiote.bimba.czwek.repo.Stop
+import xyz.apiote.bimba.czwek.units.Millisecond
+import xyz.apiote.bimba.czwek.units.Second
+import xyz.apiote.bimba.czwek.units.Tim
 import java.time.Instant
-import java.time.LocalDate
 import java.time.LocalTime
 import java.time.ZoneId
 import java.time.ZonedDateTime
@@ -47,10 +58,6 @@ 	private val binding get() = _binding!!
 
 	private lateinit var adapter: BimbaDeparturesAdapter
 
-	private val handler = Handler(Looper.getMainLooper())
-	private var runnable = Runnable {}
-
-	private var openBottomSheet: DepartureBottomSheet? = null
 	private lateinit var snackbar: Snackbar
 
 	private lateinit var viewModel: DeparturesViewModel
@@ -62,66 +69,99 @@ 			.build()
 	private var timePickerStart: MaterialTimePicker? = null
 	private var timePickerEnd: MaterialTimePicker? = null
 	private var linePicker: MaterialAlertDialogBuilder? = null
-	private var date: LocalDate? = null
-	private val linesFilter = mutableMapOf<String, Boolean>()
 	private val linesFilterTemporary = mutableMapOf<String, Boolean>()
-	private var startTime: LocalTime = LocalTime.MIN
-	private var endTime: LocalTime = LocalTime.MAX
-	private var alertDescriptions: String = ""
+
+	// TODO [elizabeth] millisInFuture from header Cache-Control max-age
+	private val countdown =
+		object : CountDownTimer(Millisecond(Second(30)).millis, Millisecond(Tim(1)).millis) {
+			override fun onTick(millisUntilFinished: Long) {
+				val timsUntillFinished = Tim(Millisecond(millisUntilFinished))
+				binding.departuresUpdatesProgress.progress = timsUntillFinished.tims
+			}
+
+			override fun onFinish() {
+				getDepartures()
+			}
+		}
 
 	override fun onCreate(savedInstanceState: Bundle?) {
+		enableEdgeToEdge()
 		super.onCreate(savedInstanceState)
 		_binding = ActivityDeparturesBinding.inflate(layoutInflater)
 		setContentView(binding.root)
 
+		ViewCompat.setOnApplyWindowInsetsListener(binding.departuresRecycler) { v, windowInsets ->
+			windowInsets.displayCutout?.safeInsetRight?.let { v.updatePadding(right = it) }
+			windowInsets.displayCutout?.safeInsetLeft?.let { v.updatePadding(left = it) }
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			v.updatePadding(bottom = insets.bottom)
+			WindowInsetsCompat.CONSUMED
+		}
+		ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			v.updatePadding(right = insets.right, left = insets.left)
+			windowInsets
+		}
+
 		viewModel = ViewModelProvider(this)[DeparturesViewModel::class.java]
 
+		getLine()?.let {
+			viewModel.mutableLinesFilter.value = mapOf(Pair(it, true))
+		}
+
+		getLines()?.associate { Pair(it, true) }?.let {
+			if (it.isNotEmpty()) {
+				viewModel.mutableLinesFilter.value = it
+			}
+		}
+
 		linePicker = MaterialAlertDialogBuilder(this)
 			.setTitle(resources.getString(R.string.title_select_line))
 			.setNegativeButton(R.string.clear_date_selection) { _, _ ->
-				linesFilter.clear()
-				getDepartures()
+				viewModel.mutableLinesFilter.value = emptyMap()
+				//getDepartures()
 			}
 			.setPositiveButton(R.string.ok) { _, _ ->
-				linesFilterTemporary.forEach { linesFilter[it.key] = it.value }
-				getDepartures()
+				viewModel.mutableLinesFilter.value = emptyMap()
+				viewModel.mutableLinesFilter.value = linesFilterTemporary
+				//getDepartures()
 			}
 
-		binding.moreButton.setOnClickListener {
-			MaterialAlertDialogBuilder(this)
-				.setTitle("Alerts")
-				.setPositiveButton(R.string.ok) { _, _ -> }
-				.setMessage(alertDescriptions)
-				.show()
+		viewModel.linesFilter.observe(this) {
+			// TODO if is before we got departures, do nothing
+			val departures = viewModel.departures.value?.departures ?: emptyList()
+			updateItems(departures
+				.filter { d ->
+					it.values.all { !it } or (it[d.vehicle.Line.name] ?: false)
+				}
+				.filter { d ->
+					val t = LocalTime.of(d.time.Hour.toInt(), d.time.Minute.toInt())
+					t >= viewModel.startTime && t <= viewModel.endTime
+				}.map { DepartureItem(it) },
+				null,
+				true
+			)
 		}
 
 		viewModel.departures.observe(this) { stopDepartures ->
+			val items = mutableListOf<DepartureItem>()
 			if (stopDepartures.alerts.isNotEmpty()) {
-				binding.alerts.visibility = View.VISIBLE
-				binding.alertsText.text = stopDepartures.alerts.map {
-					it.header.ifEmpty {
-						getString(R.string.alert_header)
-					}
-				}.toSet().joinToString(separator = "\n")
-				alertDescriptions = stopDepartures.alerts.map { it.description }.filter { it != "" }
-					.joinToString(separator = "\n")
-				binding.moreButton.visibility = if (alertDescriptions == "") View.GONE else View.VISIBLE
-
-			} else {
-				binding.alerts.visibility = View.GONE
+				items.add(DepartureItem(stopDepartures.alerts))
 			}
-			updateItems(
-				stopDepartures.departures
-					.filter { d ->
-						linesFilter.values.all { !it } or (linesFilter[d.vehicle.Line.name] ?: false)
-					}
-					.filter { d ->
-						val t = LocalTime.of(d.time.Hour.toInt(), d.time.Minute.toInt())
-						t >= startTime && t <= endTime
-					},
-				stopDepartures.stop
-			)
-			openBottomSheet?.departureID()?.let { adapter.get(it) }?.let { openBottomSheet?.update(it) }
+			items.addAll(stopDepartures.departures
+				.filter { d ->
+					viewModel.linesFilter.value?.let { filter ->
+						filter.values.all { !it } or (filter[d.vehicle.Line.name] ?: false)
+					} ?: true
+				}
+				.filter { d ->
+					val t = LocalTime.of(d.time.Hour.toInt(), d.time.Minute.toInt())
+					t >= viewModel.startTime && t <= viewModel.endTime
+				}.map { DepartureItem(it) })
+			updateItems(items, stopDepartures.stop)
+			viewModel.openBottomSheet?.departureID()?.let { adapter.get(it) }
+				?.let { it.departure?.let { departure -> viewModel.openBottomSheet?.update(departure) } }
+
 
 			val lines = stopDepartures.departures.map { it.vehicle.Line.name }.sortedWith { s1, s2 ->
 				val s1n = s1.toIntOrNull()
@@ -132,7 +172,8 @@ 				} else {
 					s1.compareTo(s2)
 				}
 			}.toSet().toTypedArray()
-			val selections = lines.map { linesFilter.getOrDefault(it, false) }.toBooleanArray()
+			val selections =
+				lines.map { viewModel.linesFilter.value?.getOrDefault(it, false) ?: false }.toBooleanArray()
 
 			linePicker?.setMultiChoiceItems(lines, selections) { _, which, checked ->
 				linesFilterTemporary[lines[which]] = checked
@@ -144,20 +185,31 @@ 		}
 
 		binding.departuresAppBar.menu.findItem(R.id.departures_filter_bytime).setEnabled(false)
 
+		// TODO async esp. with Online
+		if (runBlocking {
+				val repository = OfflineRepository(this@DeparturesActivity)
+				val f = repository.getFavourite(
+					getCode() ?: ""
+				)
+				repository.close()
+				f
+			} != null) {
+			binding.departuresAppBar.menu.findItem(R.id.favourite).setIcon(R.drawable.favourite_full)
+		}
 
 		datePicker.addOnNegativeButtonClickListener {
-			date = null
-			startTime = LocalTime.MIN
-			endTime = LocalTime.MAX
+			viewModel.date = null
+			viewModel.startTime = LocalTime.MIN
+			viewModel.endTime = LocalTime.MAX
 			binding.departuresAppBar.menu.findItem(R.id.departures_filter_bytime).setEnabled(false)
 			getDepartures(true)
 		}
 		datePicker.addOnPositiveButtonClickListener {
-			if (date == null) {
-				startTime = LocalTime.MIN
-				endTime = LocalTime.MAX
+			if (viewModel.date == null) {
+				viewModel.startTime = LocalTime.MIN
+				viewModel.endTime = LocalTime.MAX
 			}
-			date = Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())
+			viewModel.date = Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())
 				.toLocalDate()
 			binding.departuresAppBar.menu.findItem(R.id.departures_filter_bytime).setEnabled(true)
 			getDepartures(true)
@@ -169,8 +221,33 @@ 			val tf = ResourcesCompat.getFont(this@DeparturesActivity, R.font.yellowcircle8)
 			setCollapsedTitleTypeface(tf)
 			setExpandedTitleTypeface(tf)
 		}
+
 		binding.departuresAppBar.setOnMenuItemClickListener {
 			when (it.itemId) {
+				R.id.favourite -> {
+					if (!viewModel.linesFilter.value.isNullOrEmpty() && viewModel.linesFilter.value!!.any { filter -> filter.value }) {
+						MaterialAlertDialogBuilder(this).setIcon(
+							AppCompatResources.getDrawable(
+								this,
+								R.drawable.filter
+							)
+						)
+							.setTitle("Filtered departures")
+							.setMessage("Do you want to save a favourite filtered with selected lines?")
+							.setPositiveButton(R.string.filtered) { _, _ ->
+								saveFavourite(viewModel.linesFilter.value!!.keys)
+							}
+							.setNegativeButton(R.string.unfiltered) { _, _ ->
+								saveFavourite(setOf())
+							}
+							.setNeutralButton(R.string.cancel) { _, _ -> }
+							.show()
+					} else {
+						saveFavourite(setOf())
+					}
+					true
+				}
+
 				R.id.departures_calendar -> {
 					datePicker.show(supportFragmentManager, "datePicker")
 					true
@@ -178,7 +255,9 @@ 				}
 
 				R.id.departures_filter_byline -> {
 					linesFilterTemporary.clear()
-					linesFilter.forEach { filter -> linesFilterTemporary[filter.key] = filter.value }
+					viewModel.linesFilter.value?.forEach { filter ->
+						linesFilterTemporary[filter.key] = filter.value
+					}
 					linePicker?.show()
 					true
 				}
@@ -187,30 +266,30 @@ 				R.id.departures_filter_bytime -> {
 					timePickerStart =
 						MaterialTimePicker.Builder().setTitleText(R.string.title_select_time_start)
 							.setTimeFormat(TimeFormat.CLOCK_24H)
-							.setHour(startTime.hour)
-							.setMinute(startTime.minute)
+							.setHour(viewModel.startTime.hour)
+							.setMinute(viewModel.startTime.minute)
 							.setNegativeButtonText(R.string.clear_date_selection)
 							.build()
 					timePickerEnd = MaterialTimePicker.Builder().setTitleText(R.string.title_select_time_end)
 						.setTimeFormat(TimeFormat.CLOCK_24H)
-						.setHour(endTime.hour)
-						.setMinute(endTime.minute)
+						.setHour(viewModel.endTime.hour)
+						.setMinute(viewModel.endTime.minute)
 						.setNegativeButtonText(R.string.clear_date_selection)
 						.build()
 					timePickerEnd!!.addOnPositiveButtonClickListener {
-						endTime = LocalTime.of(timePickerEnd!!.hour, timePickerEnd!!.minute)
+						viewModel.endTime = LocalTime.of(timePickerEnd!!.hour, timePickerEnd!!.minute)
 						getDepartures(true)
 					}
 					timePickerEnd!!.addOnNegativeButtonClickListener {
-						endTime = LocalTime.MAX
+						viewModel.endTime = LocalTime.MAX
 						getDepartures(true)
 					}
 					timePickerStart!!.addOnPositiveButtonClickListener {
-						startTime = LocalTime.of(timePickerStart!!.hour, timePickerStart!!.minute)
+						viewModel.startTime = LocalTime.of(timePickerStart!!.hour, timePickerStart!!.minute)
 						timePickerEnd!!.show(supportFragmentManager, "timePickerEnd")
 					}
 					timePickerStart!!.addOnNegativeButtonClickListener {
-						startTime = LocalTime.MIN
+						viewModel.startTime = LocalTime.MIN
 						timePickerEnd!!.show(supportFragmentManager, "timePickerEnd")
 					}
 					timePickerStart!!.show(supportFragmentManager, "timePickerStart")
@@ -240,8 +319,8 @@ 		)
 		adapter = BimbaDeparturesAdapter(layoutInflater, this, listOf()) {
 			DepartureBottomSheet(it).apply {
 				show(supportFragmentManager, DepartureBottomSheet.TAG)
-				openBottomSheet = this
-				setOnCancel { openBottomSheet = null }
+				viewModel.openBottomSheet = this
+				setOnCancel { viewModel.openBottomSheet = null }
 			}
 		}
 		binding.departuresRecycler.adapter = adapter
@@ -269,28 +348,21 @@ 			connectivityManager.registerNetworkCallback(request, networkCallback)
 		}
 	}
 
-	override fun onStart() {
-		super.onStart()
-		linesFilter.clear()
-		getLine()?.let {
-			linesFilter[it] = true
-		}
-	}
-
 	override fun onResume() {
 		super.onResume()
 		getDepartures()
+		viewModel.openBottomSheet?.show(supportFragmentManager, DepartureBottomSheet.TAG)
 	}
 
 	override fun onPause() {
+		viewModel.openBottomSheet?.dismiss()
 		super.onPause()
-		handler.removeCallbacks(runnable)
+		countdown.cancel()
 	}
 
 	override fun onStop() {
 		super.onStop()
-		handler.removeCallbacks(runnable)
-		handler.removeCallbacksAndMessages(null)
+		countdown.cancel()
 	}
 
 	private fun getName(): String {
@@ -308,17 +380,24 @@ 			else -> null
 		}
 	}
 
+	private fun getLines(): List<String>? {
+		return when (intent?.action) {
+			null -> intent?.extras?.getStringArray("linesFilter")?.toList()
+			else -> null
+		}
+	}
+
+	private fun getCode() = intent?.extras?.getString("code")
+
 	fun getDepartures(force: Boolean = false) {
+		binding.departuresUpdatesProgress.isIndeterminate = true
 		if (force) {
 			showLoading()
 		} else {
 			adapter.refreshItems()
 			setupSnackbar()
 		}
-		viewModel.getDepartures(this, date, force)
-		handler.removeCallbacks(runnable)
-		runnable = Runnable { getDepartures() }
-		handler.postDelayed(runnable, 30 * 1000)
+		viewModel.getDepartures(this, viewModel.date, force)
 	}
 
 	private fun setupSnackbar() {
@@ -343,6 +422,7 @@
 	private fun showError(error: Error) {
 		binding.departuresProgress.visibility = View.GONE
 		binding.departuresRecycler.visibility = View.GONE
+		binding.departuresUpdatesProgress.visibility = View.GONE
 		binding.errorImage.visibility = View.VISIBLE
 		binding.errorText.visibility = View.VISIBLE
 
@@ -358,17 +438,30 @@
 	private fun showLoading() {
 		binding.departuresOverlay.visibility = View.VISIBLE
 		binding.departuresProgress.visibility = View.VISIBLE
-		binding.departuresRecycler.visibility = View.GONE
 		binding.errorImage.visibility = View.GONE
 		binding.errorText.visibility = View.GONE
 	}
 
-	private fun updateItems(departures: List<Departure>, stop: Stop) {
+	private fun updateItems(
+		departures: List<DepartureItem>,
+		stop: Stop?,
+		leaveAlert: Boolean = false
+	) {
 		setupSnackbar()
+		binding.departuresRecycler.scrollToPosition(0)
 		binding.departuresProgress.visibility = View.GONE
-		adapter.update(departures, date != null, true)
+		// TODO [elizabeth] max, progress from header Cache-Control max-age
+		binding.departuresUpdatesProgress.apply {
+			visibility = View.VISIBLE
+			isIndeterminate = false
+			max = Tim(Second(30)).tims
+			progress = Tim(Second(30)).tims
+		}
+		countdown.cancel()
+		countdown.start()
+		adapter.update(departures, viewModel.date != null, true, leaveAlert)
 		binding.collapsingLayout.apply {
-			title = stop.name
+			stop?.let { title = it.name }
 		}
 		if (departures.isEmpty()) {
 			binding.errorImage.visibility = View.VISIBLE
@@ -392,4 +485,33 @@ 			binding.departuresRecycler.visibility = View.VISIBLE
 		}
 		// todo [3.2; traffic] stop info
 	}
-}
\ No newline at end of file
+
+	private fun saveFavourite(linesFilter: Set<String>) {
+		val context = this
+		val feedID = intent.extras?.getString("feedID")
+		val code = intent?.extras?.getString("code")
+		if (feedID == null || code == null) {
+			Toast.makeText(this, R.string.cannot_save_favourite, Toast.LENGTH_LONG).show()
+			return
+		}
+		binding.departuresAppBar.menu.findItem(R.id.favourite).setIcon(R.drawable.favourite_full)
+		MainScope().launch {
+			val repo = OfflineRepository(context)
+			val feedName = repo.getFeeds(context, Server.get(context))?.get(feedID)?.name
+			if (feedName == null) {
+				Toast.makeText(context, R.string.cannot_save_favourite, Toast.LENGTH_LONG).show()
+				return@launch
+			}
+			val favourite = (repo.getFavourite(code) ?: Favourite(
+				null,
+				feedID,
+				feedName,
+				code,
+				getName(),
+				linesFilter.toList()
+			)).copy(lines = linesFilter.toList())
+			repo.saveFavourite(favourite)
+			repo.close()
+		}
+	}
+}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
index b973174eeb0b9325f3c5282aaa682545ac5187e4..9d3b81505bb8907710dcf14bb6d543a1c5c1e3bf 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt
@@ -23,6 +23,7 @@ import xyz.apiote.bimba.czwek.repo.QrLocation
 import xyz.apiote.bimba.czwek.repo.StopDepartures
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
 import java.time.LocalDate
+import java.time.LocalTime
 
 class DeparturesViewModel : ViewModel() {
 	private val _departures = MutableLiveData<StopDepartures>()
@@ -32,7 +33,17 @@ 	val error: LiveData = _error
 	var requestedItemsNumber = 12
 	var allItemsRequested = false
 	private var feed: FeedInfo? = null
+	var openBottomSheet: DepartureBottomSheet? = null
 	private lateinit var code: String
+	val mutableLinesFilter = MutableLiveData<Map<String, Boolean>>()
+	val linesFilter: LiveData<Map<String, Boolean>> = mutableLinesFilter
+
+	// TODO observe in activity, maybe refreshing and not getting departures is enough
+	var startTime: LocalTime = LocalTime.MIN
+	var endTime: LocalTime = LocalTime.MAX
+
+	// TODO observe in activity, refreshing is not enough
+	var date: LocalDate? = null
 
 	fun getDepartures(context: Context, date: LocalDate?, force: Boolean) {
 		MainScope().launch {
@@ -52,9 +63,9 @@ 						context,
 						requestedItemsNumber
 					)
 				stopDepartures?.let {
-					if (stopDepartures.departures.isEmpty()) {  // TODO other error for empty than not-found
-						val (string, image) = mapHttpError(404)
-						throw TrafficResponseException(404, "", Error(404, string, image))
+					if (stopDepartures.departures.isEmpty()) {
+						val (string, image) = mapHttpError(44)
+						throw TrafficResponseException(44, "", Error(44, string, image))
 					}
 					_departures.value = it
 				}
@@ -69,13 +80,15 @@ 	}
 
 	private suspend fun getFeed(context: Context): FeedInfo {
 		val intent = (context as Activity).intent
-		var feeds = OfflineRepository().getFeeds(context)
+		val repository = OfflineRepository(context)
+		var feeds = repository.getFeeds(context)
 		if (feeds.isNullOrEmpty()) {
 			feeds = OnlineRepository().getFeeds(context)
 			if (feeds != null) {
-				OfflineRepository().saveFeedCache(context, feeds)
+				repository.saveFeedCache(context, feeds)
 			}
 		}
+		repository.close()
 		return when (intent.action) {
 			Intent.ACTION_VIEW -> {
 				val feed = feeds?.values?.find { it.qrHost == intent.data?.host }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt
index f8be699529927e36494d50aa63eeeb58f94595f1..8f29cb95067119e2ee24b1688138d347ad55b74b 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt
@@ -9,6 +9,7 @@ import android.os.Bundle
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import xyz.apiote.bimba.czwek.dashboard.MainActivity
+import xyz.apiote.bimba.czwek.repo.migrateDB
 import xyz.apiote.bimba.czwek.settings.feeds.migrateFeedsSettings
 
 class FirstRunActivity : AppCompatActivity() {
@@ -19,6 +20,7 @@
 		val preferences = getSharedPreferences("shp", MODE_PRIVATE)
 
 		migrateFeedsSettings(this)
+		migrateDB(this)
 
 		val intent = if (preferences.getBoolean("firstRun", true)) {
 			Intent(this, OnboardingActivity::class.java)




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt
index 4656071ec4e4d07508c2520557cbbdd9d46e7604..7833875ef94ce6fe166c7f4536871d0827d49e19 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/OnboardingActivity.kt
@@ -12,8 +12,12 @@ import android.text.SpannableStringBuilder
 import android.text.style.RelativeSizeSpan
 import android.text.style.StyleSpan
 import android.widget.Button
+import androidx.activity.enableEdgeToEdge
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.databinding.ActivityOnboardingBinding
 import xyz.apiote.bimba.czwek.settings.ServerChooserActivity
@@ -30,10 +34,16 @@ 			}
 		}
 
 	override fun onCreate(savedInstanceState: Bundle?) {
+		enableEdgeToEdge()
 		super.onCreate(savedInstanceState)
 		_binding = ActivityOnboardingBinding.inflate(layoutInflater)
 		setContentView(binding.root)
 
+		ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			v.updatePadding(right = insets.right, left = insets.left, bottom = insets.bottom)
+			windowInsets
+		}
 
 		prepareButton(
 			binding.buttonSimple,




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt
index 358a7bbd7ed88d19af4e2e9fdb6a26ff7774cb6f..e90ce512e3b7eba9e635c9514268ee246ddc9db8 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Departure.kt
@@ -22,6 +22,20 @@ import java.time.ZonedDateTime
 import java.time.format.DateTimeFormatter
 import java.time.temporal.ChronoUnit
 
+
+class DepartureItem {
+	private constructor(d: Departure?, a: List<Alert>) {
+		departure = d
+		alert = a
+	}
+
+	constructor(d: Departure) : this(d, emptyList())
+	constructor(a: List<Alert>) : this(null, a)
+
+	val departure: Departure?
+	val alert: List<Alert>
+}
+
 enum class AlertCause {
 	UNKNOWN, OTHER, TECHNICAL_PROBLEM, STRIKE, DEMONSTRATION, ACCIDENT, HOLIDAY, WEATHER, MAINTENANCE,
 	CONSTRUCTION, POLICE_ACTIVITY, MEDICAL_EMERGENCY;
@@ -61,7 +75,7 @@ 				AlertEffectV1.SIGNIFICANT_DELAYS -> valueOf("SIGNIFICANT_DELAYS")
 				AlertEffectV1.DETOUR -> valueOf("DETOUR")
 				AlertEffectV1.ADDITIONAL_SERVICE -> valueOf("ADDITIONAL_SERVICE")
 				AlertEffectV1.MODIFIED_SERVICE -> valueOf("MODIFIED_SERVICE")
-				AlertEffectV1.STOP_MOVED  -> valueOf("STOP_MOVED")
+				AlertEffectV1.STOP_MOVED -> valueOf("STOP_MOVED")
 				AlertEffectV1.NONE -> valueOf("NONE")
 				AlertEffectV1.ACCESSIBILITY_ISSUE -> valueOf("ACCESSIBILITY_ISSUE")
 			}
@@ -76,7 +90,13 @@ 	val url: String,
 	val cause: AlertCause,
 	val effect: AlertEffect
 ) {
-	constructor(a: AlertV1) : this(a.header, a.Description, a.Url, AlertCause.of(a.Cause), AlertEffect.of(a.Effect))
+	constructor(a: AlertV1) : this(
+		a.header,
+		a.Description,
+		a.Url,
+		AlertCause.of(a.Cause),
+		AlertEffect.of(a.Effect)
+	)
 }
 
 data class StopDepartures(




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a24d42ebccfb157547772a58006dcfcb0f561d05
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt
@@ -0,0 +1,3 @@
+package xyz.apiote.bimba.czwek.repo
+
+data class Favourite(val sequence: Int?, val feedID: String, val feedName: String, val stopCode: String, val stopName: String, val lines: List<String>)
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt
index 7d70dbc8aa3e2cedc903e6e19ea399841623f2a1..95fa5fa0c75f0c3e765eefd64c22091021260eb8 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
@@ -17,6 +17,12 @@ 	fun id(): String
 }
 
 interface Repository {
+	suspend fun getFavourite(stopCode: String): Favourite?
+	suspend fun getFavourites(feedIDs: Set<String> = emptySet()): List<Favourite>
+
+	suspend fun saveFavourite(favourite: Favourite)
+	suspend fun saveFavourites(favourites: Set<Favourite>)
+
 	suspend fun getFeeds(
 		context: Context,
 		server: Server = Server.get(context)




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
index 6f9849cfc8b086ec4809afba0ad14e6218b4f58f..165e9c932064611d7cf5be4f334a473356886eb9 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt
@@ -5,7 +5,9 @@
 package xyz.apiote.bimba.czwek.repo
 
 import android.content.Context
+import android.database.sqlite.SQLiteDatabase
 import androidx.core.content.edit
+import androidx.core.database.sqlite.transaction
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import xyz.apiote.bimba.czwek.api.Server
@@ -15,7 +17,10 @@ import java.io.File
 import java.net.URLEncoder
 import java.time.LocalDate
 
-class OfflineRepository : Repository {
+class OfflineRepository(context: Context) : Repository {
+	private val db =
+		SQLiteDatabase.openOrCreateDatabase(context.getDatabasePath("favourites").path, null)
+
 	fun saveFeedCache(context: Context, feedInfos: Map<String, FeedInfo>) {
 		val file = File(
 			context.filesDir, URLEncoder.encode(Server.get(context).apiPath, "utf-8")
@@ -33,6 +38,94 @@ 		stream.flush()
 		stream.close()
 	}
 
+	override suspend fun getFavourite(stopCode: String): Favourite? {
+		val cursor =
+			db.rawQuery(
+				"select sequence, stop_name, feed_id, feed_name, lines from favourites where stop_code = ?",
+				listOf(stopCode).toTypedArray()
+			)
+		if (cursor.count == 0) {
+			return null
+		}
+		cursor.moveToNext()
+		val f = Favourite(
+			cursor.getInt(0),
+			cursor.getString(2),
+			cursor.getString(3),
+			stopCode,
+			cursor.getString(1),
+			cursor.getString(4).split("||").filter { it != "" }
+		)
+		cursor.close()
+		return f
+	}
+
+	override suspend fun getFavourites(feedIDs: Set<String>): List<Favourite> {
+		val whereClause = if (feedIDs.isNotEmpty()) {
+			feedIDs.indices.joinToString(prefix = "where feed_id in (", postfix = ")") { "?" }
+		} else {
+			""
+		}
+		val cursor =
+			db.rawQuery(
+				"select sequence, stop_name, feed_id, feed_name, stop_code, lines from favourites $whereClause order by sequence",
+				feedIDs.toTypedArray()
+			)
+		val l = mutableListOf<Favourite>()
+		while (cursor.moveToNext()) {
+			l.add(
+				Favourite(
+					cursor.getInt(0),
+					cursor.getString(2),
+					cursor.getString(3),
+					cursor.getString(4),
+					cursor.getString(1),
+					cursor.getString(5).split("||").filter { it != "" }
+				)
+			)
+		}
+		cursor.close()
+		return l
+	}
+
+	override suspend fun saveFavourite(favourite: Favourite) {
+		val sequence = favourite.sequence ?: run {
+			val cursor =
+				db.rawQuery("select max(ROWID) from favourites", emptyArray<String?>())
+			val s = if (cursor.count == 0) {
+				0
+			} else {
+				cursor.moveToNext()
+				cursor.getInt(0)
+			}
+			cursor.close()
+			s
+		}
+		db.execSQL(
+			"insert into favourites(sequence, feed_id, feed_name, stop_code, stop_name, lines) values (?, ?,?,?,?,?) on conflict(feed_id, stop_code) do update set stop_name = ?, lines = ?, sequence = ?",
+			arrayOf(
+				sequence,
+				favourite.feedID,
+				favourite.feedName,
+				favourite.stopCode,
+				favourite.stopName,
+				favourite.lines.joinToString(separator = "||"),
+				favourite.stopName,
+				favourite.lines.joinToString(separator = "||"),
+				favourite.sequence
+			)
+		)
+	}
+
+	override suspend fun saveFavourites(favourites: Set<Favourite>) {
+		db.execSQL("delete from favourites")
+		db.transaction {
+			favourites.forEach {
+				saveFavourite(it)
+			}
+		}
+	}
+
 	@Suppress("RedundantNullableReturnType")
 	override suspend fun getFeeds(
 		context: Context,
@@ -108,4 +201,13 @@ 	): List? {
 		TODO("Not yet implemented")
 	}
 
+	fun close() {
+		db.close()
+	}
+}
+
+
+fun migrateDB(context: Context) {
+	val db = SQLiteDatabase.openOrCreateDatabase(context.getDatabasePath("favourites").path, null)
+	db.execSQL("create table if not exists favourites(sequence integer, feed_id text, feed_name text, stop_code text, stop_name text, lines text, primary key(feed_id, stop_code))")
 }




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 8432f937e713904e92c77440c6d634607f8167fe..1c3f36595fe1c6bc646a137d9475294d59f0f9d1 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
@@ -50,6 +50,22 @@
 // todo [3.2] in Repository check if responses are BARE or HTML
 
 class OnlineRepository : Repository {
+	override suspend fun getFavourite(stopCode: String): Favourite? {
+		TODO("Not yet implemented; waits for ampelmänchen")
+	}
+
+	override suspend fun getFavourites(feedIDs: Set<String>): List<Favourite> {
+		TODO("Not yet implemented; waits for ampelmänchen")
+	}
+
+	override suspend fun saveFavourite(favourite: Favourite) {
+		TODO("Not yet implemented; waits for ampelmänchen")
+	}
+
+	override suspend fun saveFavourites(favourites: Set<Favourite>) {
+		TODO("Not yet implemented; waits for ampelmänchen")
+	}
+
 	override suspend fun getFeeds(
 		context: Context,
 		server: Server




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt
index 128a2199a685110c6bfd442bf81ed6509cbda12f..280697cf61759672e9809e22babc5fda4b001137 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/LineGraphActivity.kt
@@ -7,8 +7,12 @@
 import android.os.Bundle
 import android.util.Log
 import android.view.View
+import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
 import androidx.viewpager.widget.ViewPager
 import com.google.android.material.tabs.TabLayout
 import kotlinx.coroutines.MainScope
@@ -24,10 +28,17 @@ 	private lateinit var binding: ActivityLineGraphBinding
 	private lateinit var sectionsPagerAdapter: SectionsPagerAdapter
 
 	override fun onCreate(savedInstanceState: Bundle?) {
+		enableEdgeToEdge()
 		super.onCreate(savedInstanceState)
 
 		binding = ActivityLineGraphBinding.inflate(layoutInflater)
 		setContentView(binding.root)
+
+		ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			v.updatePadding(top = insets.top, left = insets.left, right = insets.right)
+			windowInsets
+		}
 
 		val lineName = intent.getStringExtra("lineName")!!
 		val lineID = intent.getStringExtra("lineID")!!




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 d18cb129731d39ea38fdf1878605754b52088caa..5575f8547ed6f8abfeacabc82767927c814a74ac 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
@@ -89,7 +89,7 @@ 				setImageDrawable(stop.icon(context!!))
 				contentDescription = context.getString(R.string.stop_content_description)
 			}
 			holder?.title?.text = stop.name
-			if ((feedsSettings?.activeFeeds() ?: 0) > 1) {
+			if ((feedsSettings?.activeFeedsCount() ?: 0) > 1) {
 				holder?.feedName?.visibility = View.VISIBLE
 				holder?.feedName?.text = feeds?.get(stop.feedID)?.name ?: ""
 			}
@@ -115,7 +115,7 @@ 				setImageDrawable(line.icon(context!!))
 				contentDescription = line.type.name
 				colorFilter = null
 			}
-			if ((feedsSettings?.activeFeeds() ?: 0) > 1) {
+			if ((feedsSettings?.activeFeedsCount() ?: 0) > 1) {
 				holder?.feedName?.visibility = View.VISIBLE
 				holder?.feedName?.text = feeds?.get(line.feedID)?.name ?: ""
 			}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
index 95054348f2de4faf524e27245737e784f1222b45..f8729039653a32172c5b25a51668ff9ddfb15a6e 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt
@@ -14,9 +14,14 @@ import android.os.Handler
 import android.os.Looper
 import android.util.Log
 import android.view.View
+import android.view.ViewGroup.MarginLayoutParams
+import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
-import androidx.core.view.WindowCompat
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updateLayoutParams
+import androidx.core.view.updatePadding
 import androidx.recyclerview.widget.LinearLayoutManager
 import kotlinx.coroutines.MainScope
 import kotlinx.coroutines.Runnable
@@ -45,18 +50,31 @@ 	private val handler = Handler(Looper.getMainLooper())
 	private var runnable = Runnable {}
 
 	override fun onCreate(savedInstanceState: Bundle?) {
+		enableEdgeToEdge()
 		super.onCreate(savedInstanceState)
 		_binding = ActivityResultsBinding.inflate(layoutInflater)
 		setContentView(binding.root)
 
+		ViewCompat.setOnApplyWindowInsetsListener(binding.resultsRecycler) { v, windowInsets ->
+
+			windowInsets.displayCutout?.safeInsetLeft?.let {
+				v.updateLayoutParams<MarginLayoutParams> {
+					leftMargin = it
+				}
+			}
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			v.updatePadding(right = insets.right, left = insets.left)
+			windowInsets
+		}
+		ViewCompat.setOnApplyWindowInsetsListener(binding.topAppBar) { v, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			v.updatePadding(right = insets.right, left = insets.left)
+			windowInsets
+		}
+
 		binding.resultsRecycler.layoutManager = LinearLayoutManager(this)
 		adapter = BimbaResultsAdapter(layoutInflater, this, listOf())
 		binding.resultsRecycler.adapter = adapter
-		setSupportActionBar(binding.topAppBar)
-
-		WindowCompat.setDecorFitsSystemWindows(window, false)
-
-
 
 		when (getMode()) {
 			Mode.MODE_LOCATION -> {
@@ -135,7 +153,9 @@ 	}
 
 	private suspend fun getFeeds() {
 		if (adapter.feeds.isNullOrEmpty()) {
-			adapter.feeds = OfflineRepository().getFeeds(this)
+			val repository = OfflineRepository(this)
+			adapter.feeds = repository.getFeeds(this)
+			repository.close()
 		}
 		if (adapter.feedsSettings == null) {
 			adapter.feedsSettings = FeedsSettings.load(this)




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt
index 84c6285e11884e3a4ff364dbe0b27f128f519785..ffb32d9486fbf7056c3629add6fdf2d58550898e 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ui/LineGraphFragment.kt
@@ -14,6 +14,9 @@ import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
 import dev.bandb.graphview.AbstractGraphAdapter
@@ -51,6 +54,12 @@ 		inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
 	): View {
 
 		_binding = FragmentLineGraphBinding.inflate(inflater, container, false)
+
+		ViewCompat.setOnApplyWindowInsetsListener(binding.recycler) { v, windowInsets ->
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			v.updatePadding(right = insets.right, left = insets.left, top = insets.top, bottom = insets.bottom)
+			windowInsets
+		}
 
 		val configuration = SugiyamaConfiguration.Builder()
 			.setLevelSeparation(100)




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt
index 2780cb83d4dea6903fe6a720c6ad8c95622ddc0b..223dfeee4be34ef5e501c4760e382eb3342bdf24 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt
@@ -4,17 +4,19 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 package xyz.apiote.bimba.czwek.settings
 
-import android.content.Context
 import android.content.Intent
 import android.content.SharedPreferences
 import android.graphics.Color
-import android.net.ConnectivityManager
 import android.os.Bundle
 import android.util.Log
+import androidx.activity.enableEdgeToEdge
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.content.edit
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
 import androidx.core.widget.addTextChangedListener
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import kotlinx.coroutines.Dispatchers
@@ -44,7 +46,9 @@
 	private lateinit var preferences: SharedPreferences
 
 	override fun onCreate(savedInstanceState: Bundle?) {
+		enableEdgeToEdge()
 		super.onCreate(savedInstanceState)
+
 		preferences = getSharedPreferences("shp", MODE_PRIVATE)
 
 		if (intent.getBooleanExtra("simple", false)) {
@@ -53,6 +57,12 @@ 			checkServer(true)
 		} else {
 			_binding = ActivityServerChooserBinding.inflate(layoutInflater)
 			setContentView(binding.root)
+
+			ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, windowInsets ->
+				val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+				v.updatePadding(right = insets.right, left = insets.left, top = insets.top)
+				windowInsets
+			}
 
 			preferences.edit(true) {
 				putBoolean("inFeedsTransaction", true)
@@ -122,9 +132,9 @@ 		title: Int, description: Int, icon: Int, onPositive: (() -> Unit)?
 	) {
 		MaterialAlertDialogBuilder(this).setIcon(AppCompatResources.getDrawable(this, icon))
 			.setTitle(getString(title)).setMessage(getString(description))
-			.setNegativeButton(resources.getString(R.string.cancel)) { _, _ -> }.apply {
+			.setNegativeButton(R.string.cancel) { _, _ -> }.apply {
 				if (onPositive != null) {
-					setPositiveButton(resources.getString(R.string.cont)) { _, _ ->
+					setPositiveButton(R.string.cont) { _, _ ->
 						onPositive()
 					}
 				}
@@ -132,7 +142,6 @@ 			}.show()
 	}
 
 	private fun checkServer(isSimple: Boolean) {
-		val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
 		MainScope().launch {
 			val result = getBimba(this@ServerChooserActivity, Server.get(this@ServerChooserActivity))
 			if (result.error != null) {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt
index c01d13f54ea0addd2b246d3d070d96aac3aed5f0..09f4dd40f4c2eca59f1f092a66042214f88f14a6 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedChooserActivity.kt
@@ -7,9 +7,13 @@
 import android.content.Intent
 import android.os.Bundle
 import android.view.View
+import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.content.edit
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
 import androidx.core.widget.doAfterTextChanged
 import androidx.lifecycle.ViewModelProvider
 import androidx.recyclerview.widget.LinearLayoutManager
@@ -32,9 +36,23 @@
 	private lateinit var adapter: BimbaFeedInfoAdapter
 
 	override fun onCreate(savedInstanceState: Bundle?) {
+		enableEdgeToEdge()
 		super.onCreate(savedInstanceState)
 		_binding = ActivityFeedChooserBinding.inflate(layoutInflater)
 		setContentView(binding.root)
+
+		ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, windowInsets ->
+			windowInsets.displayCutout?.safeInsetRight?.let { binding.resultsRecycler.updatePadding(right = it) }
+			windowInsets.displayCutout?.safeInsetLeft?.let { binding.resultsRecycler.updatePadding(left = it) }
+			val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+			v.updatePadding(
+				right = insets.right,
+				left = insets.left,
+				top = insets.top,
+				bottom = insets.bottom
+			)
+			windowInsets
+		}
 
 		viewModel = ViewModelProvider(this)[FeedsViewModel::class.java]
 




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt
index 91921e1025e74c19ad73d9d75cd4e39d5668bfec..632af91e8e3286638d6614460593ed54b59dcc18 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt
@@ -15,8 +15,9 @@
 @Serializable
 @OptIn(ExperimentalStdlibApi::class)
 data class FeedsSettings(val settings: MutableMap<String, FeedSettings>) {
-	fun activeFeeds() = settings.count { it.value.enabled && it.value.useOnline }
-	fun getIDs() = settings.filter { it.value.enabled && it.value.useOnline }.keys.joinToString(",")
+	fun activeFeedsCount() = settings.count { it.value.enabled && it.value.useOnline }
+	fun activeFeeds() = settings.filter { it.value.enabled && it.value.useOnline }.keys
+	fun getIDs() = activeFeeds().joinToString(",")
 
 	fun save(context: Context, server: Server) {
 		val doc = KBson().dump(serializer(), this).toHexString()




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt
index 6d5fb983e6baf603ce0237922088462d9450f328..c178b2c8ba05f338651ef3d93bb0580ac2f6a3a1 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt
@@ -51,8 +51,10 @@ 		var error: Error? = null
 		MainScope().launch {
 			withContext(coroutineContext) {
 				launch {
+					val repository = OfflineRepository(context)
 					offlineFeeds =
-						OfflineRepository().getFeeds(context)
+						repository.getFeeds(context)
+					repository.close()
 					if (!offlineFeeds.isNullOrEmpty()) {
 						_feeds.value = offlineFeeds!!
 					}
@@ -70,10 +72,12 @@ 				}
 			}
 			if (offlineFeeds.isNullOrEmpty() && error != null) {
 				_error.value = error!!
-			}  else{
+			} else {
 				joinFeeds(offlineFeeds, onlineFeeds).let { joinedFeeds ->
 					_feeds.value = joinedFeeds
-					OfflineRepository().saveFeedCache(context, joinedFeeds)
+					val repository = OfflineRepository(context)
+					repository.saveFeedCache(context, joinedFeeds)
+					repository.close()
 				}
 			}
 		}




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/units/Time.kt b/app/src/main/java/xyz/apiote/bimba/czwek/units/Time.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9456eae31f27301d561789f4e07ca44dd9519bef
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/units/Time.kt
@@ -0,0 +1,17 @@
+package xyz.apiote.bimba.czwek.units
+
+interface TimeUnit
+
+data class Second(val secs: Long) {
+	constructor(t: Tim) : this(t.tims.toLong() * 25 / 144)
+}
+
+data class Millisecond(val millis: Long) {
+	constructor(t: Tim) : this(t.tims.toLong() * 25 * 1000 / 144)
+	constructor(s: Second) : this(s.secs * 1000)
+}
+
+data class Tim(val tims: Int) {
+	constructor(s: Second) : this((s.secs * 144 / 25).toInt())
+	constructor(m: Millisecond) : this((m.millis * 144 / 25 / 1000).toInt())
+}
\ No newline at end of file




diff --git a/app/src/main/res/drawable/error_departures.xml b/app/src/main/res/drawable/error_departures.xml
new file mode 100644
index 0000000000000000000000000000000000000000..dfec75446a44e14d388b991b40bf57d6c7f6bbed
--- /dev/null
+++ b/app/src/main/res/drawable/error_departures.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+	android:width="24dp"
+	android:height="24dp"
+	android:viewportWidth="24"
+	android:viewportHeight="24"
+	android:tint="?attr/colorOnSurface">
+	<path
+		android:fillColor="@android:color/white"
+		android:pathData="M21.19,21.19L2.81,2.81L1.39,4.22L4,6.83V16c0,0.88 0.39,1.67 1,2.22V20c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h8v1c0,0.55 0.45,1 1,1h1c0.05,0 0.09,-0.02 0.14,-0.03l1.64,1.64L21.19,21.19zM7.5,17C6.67,17 6,16.33 6,15.5C6,14.67 6.67,14 7.5,14S9,14.67 9,15.5C9,16.33 8.33,17 7.5,17zM6,11V8.83L8.17,11H6zM8.83,6L5.78,2.95C7.24,2.16 9.48,2 12,2c4.42,0 8,0.5 8,4v10c0,0.35 -0.08,0.67 -0.19,0.98L13.83,11H18V6H8.83z"/>
+</vector>
\ No newline at end of file




diff --git a/app/src/main/res/drawable/error_locality.xml b/app/src/main/res/drawable/error_locality.xml
new file mode 100644
index 0000000000000000000000000000000000000000..78add21082685570637e641f93757bae7c99c26e
--- /dev/null
+++ b/app/src/main/res/drawable/error_locality.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+	android:width="24dp"
+	android:height="24dp"
+	android:viewportWidth="24"
+	android:viewportHeight="24"
+	android:tint="?attr/colorOnSurface">
+	<path
+		android:fillColor="@android:color/white"
+		android:pathData="M14,10V3.26C13.35,3.09 12.68,3 12,3c-4.2,0 -8,3.22 -8,8.2c0,3.32 2.67,7.25 8,11.8c5.33,-4.55 8,-8.48 8,-11.8c0,-0.41 -0.04,-0.81 -0.09,-1.2H14zM12,13c-1.1,0 -2,-0.9 -2,-2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2C14,12.1 13.1,13 12,13z"/>
+	<path
+		android:fillColor="@android:color/white"
+		android:pathData="M22.54,2.88l-1.42,-1.42l-2.12,2.13l-2.12,-2.13l-1.42,1.42l2.13,2.12l-2.13,2.12l1.42,1.42l2.12,-2.13l2.12,2.13l1.42,-1.42l-2.13,-2.12z"/>
+</vector>
\ No newline at end of file




diff --git a/app/src/main/res/drawable/favourite_empty.xml b/app/src/main/res/drawable/favourite_empty.xml
new file mode 100644
index 0000000000000000000000000000000000000000..94a481425f0379f97bd860b0886a16f648c2abcb
--- /dev/null
+++ b/app/src/main/res/drawable/favourite_empty.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+	android:width="24dp"
+	android:height="24dp"
+	android:viewportWidth="24"
+	android:viewportHeight="24"
+	android:tint="?attr/colorOnSurface">
+	<path
+		android:fillColor="@android:color/white"
+		android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
+</vector>
\ No newline at end of file




diff --git a/app/src/main/res/drawable/favourite_full.xml b/app/src/main/res/drawable/favourite_full.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fc3f1e83de9cde90c2a6181e7c16bdb4c2310a33
--- /dev/null
+++ b/app/src/main/res/drawable/favourite_full.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+SPDX-FileCopyrightText: Google
+
+SPDX-License-Identifier: Apache-2.0
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+	android:width="24dp"
+	android:height="24dp"
+	android:tint="?attr/colorOnSurface"
+	android:viewportWidth="24"
+	android:viewportHeight="24">
+	<path
+		android:fillColor="@android:color/white"
+		android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
+</vector>
\ No newline at end of file




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




diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index c4a4b92fc7e9ffa3068acb27897972c4c71ef6aa..8869f8b60d73a3890d711e643e79f2bec0ec2d2c 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -12,21 +12,6 @@ 	android:id="@+id/container"
 	android:layout_width="match_parent"
 	android:layout_height="match_parent">
 
-	<androidx.coordinatorlayout.widget.CoordinatorLayout
-		android:layout_width="match_parent"
-		android:layout_height="match_parent">
-
-		<com.google.android.material.appbar.AppBarLayout
-			android:layout_width="match_parent"
-			android:layout_height="wrap_content">
-
-			<com.google.android.material.appbar.MaterialToolbar
-				android:id="@+id/top_app_bar"
-				android:layout_width="match_parent"
-				android:layout_height="?attr/actionBarSize"
-				app:title="" />
-		</com.google.android.material.appbar.AppBarLayout>
-
 		<androidx.constraintlayout.widget.ConstraintLayout
 			android:layout_width="match_parent"
 			android:layout_height="match_parent">
@@ -53,7 +38,6 @@ 				app:layout_constraintEnd_toEndOf="parent"
 				app:layout_constraintStart_toStartOf="parent"
 				app:menu="@menu/bottom_nav_menu" />
 		</androidx.constraintlayout.widget.ConstraintLayout>
-	</androidx.coordinatorlayout.widget.CoordinatorLayout>
 
 	<com.google.android.material.navigation.NavigationView
 		android:id="@+id/navigation_drawer"




diff --git a/app/src/main/res/layout/activity_results.xml b/app/src/main/res/layout/activity_results.xml
index c4813dace5058c3fffcf5bb325f8ddd75ba24e33..92b758af8e0a7d62e6b0a9ac97749e09f7b443a5 100644
--- a/app/src/main/res/layout/activity_results.xml
+++ b/app/src/main/res/layout/activity_results.xml
@@ -13,6 +13,20 @@ 	android:layout_width="match_parent"
 	android:layout_height="match_parent"
 	android:paddingBottom="16dp">
 
+	<com.google.android.material.appbar.AppBarLayout
+		android:id="@+id/app_bar_layout"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:fitsSystemWindows="true">
+
+		<com.google.android.material.appbar.MaterialToolbar
+			android:id="@+id/top_app_bar"
+			android:layout_width="match_parent"
+			android:layout_height="?attr/actionBarSize"
+			app:title="@string/title_activity_results" />
+
+	</com.google.android.material.appbar.AppBarLayout>
+
 	<androidx.constraintlayout.widget.ConstraintLayout
 		android:id="@+id/results_overlay"
 		android:layout_width="match_parent"
@@ -56,20 +70,6 @@ 			app:layout_constraintTop_toBottomOf="@+id/error_image"
 			tool:text="No connection" />
 
 	</androidx.constraintlayout.widget.ConstraintLayout>
-
-	<com.google.android.material.appbar.AppBarLayout
-		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		android:fitsSystemWindows="true"
-		app:liftOnScroll="true">
-
-		<com.google.android.material.appbar.MaterialToolbar
-			android:id="@+id/top_app_bar"
-			android:layout_width="match_parent"
-			android:layout_height="?attr/actionBarSize"
-			app:title="@string/title_activity_results" />
-
-	</com.google.android.material.appbar.AppBarLayout>
 
 	<androidx.recyclerview.widget.RecyclerView
 		android:id="@+id/results_recycler"




diff --git a/app/src/main/res/layout/alert.xml b/app/src/main/res/layout/alert.xml
new file mode 100644
index 0000000000000000000000000000000000000000..643fd32e34735a592742d869500910aa13314b87
--- /dev/null
+++ b/app/src/main/res/layout/alert.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tool="http://schemas.android.com/tools"
+	android:id="@+id/alerts"
+	android:layout_width="match_parent"
+	android:layout_height="64dp"
+	android:layout_marginStart="8dp"
+	android:layout_marginTop="8dp"
+	android:layout_marginEnd="8dp"
+	android:backgroundTint="@color/safety">
+
+	<androidx.constraintlayout.widget.ConstraintLayout
+		android:layout_width="match_parent"
+		android:layout_height="match_parent">
+
+		<ImageView
+			android:id="@+id/imageView"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginStart="8dp"
+			android:importantForAccessibility="no"
+			android:src="@drawable/warning"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="@+id/alerts_text"
+			app:tint="@color/black" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/alerts_text"
+			tool:text="Warning: Serious blockade on Piastowska towards Wojska Polskiego. Lines 5, 14, 163 diverted. Change for other means of transport, e.g. lines \n\naaaaa"
+			android:layout_width="0dp"
+			android:layout_height="0dp"
+			android:layout_marginStart="8dp"
+			android:layout_marginTop="8dp"
+			android:layout_marginEnd="8dp"
+			android:ellipsize="end"
+			android:maxLines="2"
+			android:textColor="@color/black"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toStartOf="@+id/more_button"
+			app:layout_constraintStart_toEndOf="@+id/imageView"
+			app:layout_constraintTop_toTopOf="parent" />
+
+		<com.google.android.material.button.MaterialButton
+			android:id="@+id/more_button"
+			style="@style/Widget.Material3.Button.TextButton"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:text="@string/more"
+			android:textColor="@color/link"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toEndOf="parent" />
+	</androidx.constraintlayout.widget.ConstraintLayout>
+</com.google.android.material.card.MaterialCardView>
\ No newline at end of file




diff --git a/app/src/main/res/layout/favourite.xml b/app/src/main/res/layout/favourite.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7d47ac03dc672895ee185d4c471ffb19cf4847b7
--- /dev/null
+++ b/app/src/main/res/layout/favourite.xml
@@ -0,0 +1,94 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+SPDX-FileCopyrightText: Adam Evyčędo
+
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tool="http://schemas.android.com/tools"
+	android:id="@+id/favourite"
+	android:layout_marginTop="8dp"
+	android:layout_marginBottom="8dp"
+	android:layout_width="match_parent"
+	android:layout_height="wrap_content">
+
+	<androidx.constraintlayout.widget.ConstraintLayout
+		android:layout_width="match_parent"
+		android:layout_height="match_parent"
+		android:paddingBottom="16dp">
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/feed_name"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginStart="16dp"
+			android:layout_marginTop="16dp"
+			android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="parent"
+			tool:text="GB" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/stop_name"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginStart="16dp"
+			android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toBottomOf="@+id/feed_name"
+			tool:text="Westminster" />
+
+		<ImageView
+			android:id="@+id/line_icon"
+			android:layout_width="24dp"
+			android:layout_height="24dp"
+			android:layout_marginStart="8dp"
+			app:layout_constraintBottom_toTopOf="@+id/departure_headsign"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="@+id/departure_time"
+			tool:ignore="ContentDescription"
+			tool:srcCompat="@drawable/bus_black" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/departure_time"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginEnd="8dp"
+			android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintTop_toTopOf="@+id/departure_line"
+			tool:text="1hr" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/departure_full_time"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginTop="4dp"
+			android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
+			app:layout_constraintEnd_toEndOf="@+id/departure_time"
+			app:layout_constraintTop_toBottomOf="@+id/departure_time"
+			tool:text="18:55" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/departure_line"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:layout_marginStart="8dp"
+			android:layout_marginTop="8dp"
+			android:text="@string/loading"
+			android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium"
+			app:layout_constraintStart_toEndOf="@+id/line_icon"
+			app:layout_constraintTop_toBottomOf="@+id/stop_name"
+			tool:text="Metropolitan" />
+
+		<com.google.android.material.textview.MaterialTextView
+			android:id="@+id/departure_headsign"
+			android:layout_width="wrap_content"
+			android:layout_height="wrap_content"
+			android:textAppearance="@style/TextAppearance.Material3.BodySmall"
+			app:layout_constraintStart_toStartOf="@+id/departure_line"
+			app:layout_constraintTop_toBottomOf="@+id/departure_line"
+			tool:text="» Tower Hill" />
+
+	</androidx.constraintlayout.widget.ConstraintLayout>
+</com.google.android.material.card.MaterialCardView>
\ No newline at end of file




diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml
index eb621f767365a76f5fa26f83738325eaefa828dc..925487e2b450251a216845ce8573e76fb1c4acb8 100644
--- a/app/src/main/res/layout/fragment_home.xml
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -9,14 +9,15 @@
 <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:id="@+id/fragment_root"
 	android:layout_width="match_parent"
 	android:layout_height="match_parent"
 	android:tag="@string/title_home"
 	tool:context="xyz.apiote.bimba.czwek.dashboard.ui.home.HomeFragment">
 
-	<com.google.android.material.appbar.AppBarLayout
+	<androidx.constraintlayout.widget.ConstraintLayout
 		android:layout_width="match_parent"
-		android:layout_height="wrap_content">
+		android:layout_height="match_parent">
 
 		<com.google.android.material.search.SearchBar
 			android:id="@+id/search_bar"
@@ -31,32 +32,30 @@ 			app:layout_constraintStart_toStartOf="parent"
 			app:layout_constraintTop_toTopOf="parent"
 			app:navigationIcon="@drawable/menu"
 			app:useDrawerArrowDrawable="true" />
-	</com.google.android.material.appbar.AppBarLayout>
 
-	<com.google.android.material.search.SearchView
-		android:id="@+id/search_view"
-		android:layout_width="match_parent"
-		android:layout_height="match_parent"
-		android:hint="@string/search_placeholder"
-		app:layout_anchor="@id/search_bar">
-
-		<androidx.recyclerview.widget.RecyclerView
-			android:id="@+id/suggestions_recycler"
+		<com.google.android.material.search.SearchView
+			android:id="@+id/search_view"
 			android:layout_width="match_parent"
 			android:layout_height="match_parent"
-			app:layout_behavior="@string/appbar_scrolling_view_behavior" />
-	</com.google.android.material.search.SearchView>
+			android:hint="@string/search_placeholder"
+			app:layout_anchor="@id/search_bar"
+			app:layout_constraintBottom_toBottomOf="parent"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toTopOf="parent">
 
-	<androidx.constraintlayout.widget.ConstraintLayout
-		android:layout_width="match_parent"
-		android:layout_height="match_parent">
+			<androidx.recyclerview.widget.RecyclerView
+				android:id="@+id/suggestions_recycler"
+				android:layout_width="match_parent"
+				android:layout_height="match_parent" />
+		</com.google.android.material.search.SearchView>
 
 		<ImageView
 			android:id="@+id/inari"
 			android:layout_width="0dp"
 			android:layout_height="0dp"
 			android:layout_marginStart="16dp"
-			android:layout_marginTop="64dp"
+			android:layout_marginTop="16dp"
 			android:layout_marginEnd="16dp"
 			android:layout_marginBottom="16dp"
 			android:alpha="0.25"
@@ -67,6 +66,17 @@ 			app:layout_constraintStart_toStartOf="parent"
 			app:layout_constraintTop_toTopOf="parent"
 			tool:ignore="ContentDescription,ImageContrastCheck" />
 
+		<androidx.recyclerview.widget.RecyclerView
+			android:id="@+id/favourites"
+			android:layout_width="0dp"
+			android:layout_height="0dp"
+			android:layout_marginStart="16dp"
+			android:layout_marginTop="16dp"
+			android:layout_marginEnd="16dp"
+			app:layout_constraintBottom_toBottomOf="@id/inari"
+			app:layout_constraintEnd_toEndOf="parent"
+			app:layout_constraintStart_toStartOf="parent"
+			app:layout_constraintTop_toBottomOf="@id/search_bar" />
 	</androidx.constraintlayout.widget.ConstraintLayout>
 
 	<com.google.android.material.floatingactionbutton.FloatingActionButton
@@ -77,7 +87,6 @@ 		android:layout_gravity="bottom|end"
 		android:layout_margin="16dp"
 		android:contentDescription="@string/home_fab_description"
 		android:src="@drawable/gps_black"
-		app:layout_constraintBottom_toBottomOf="parent"
-		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_behavior="com.google.android.material.floatingactionbutton.FloatingActionButton$Behavior"
 		tool:ignore="ImageContrastCheck" />
 </androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file




diff --git a/app/src/main/res/menu/departures_menu.xml b/app/src/main/res/menu/departures_menu.xml
index 2a4d18f7c839b628af7e21dcae639ed287fb05e5..0c3f27a935ef2b0bca568758f44f8d641d5d3c6c 100644
--- a/app/src/main/res/menu/departures_menu.xml
+++ b/app/src/main/res/menu/departures_menu.xml
@@ -9,6 +9,12 @@
 <menu xmlns:android="http://schemas.android.com/apk/res/android"
 	xmlns:app="http://schemas.android.com/apk/res-auto">
 	<item
+		android:id="@+id/favourite"
+		android:icon="@drawable/favourite_empty"
+		app:showAsAction="always"
+		android:contentDescription="@string/favourite_content_description"
+		android:title="@string/favourite" />
+	<item
 		android:id="@+id/departures_filter"
 		android:icon="@drawable/filter"
 		app:showAsAction="ifRoom"




diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fd0d12a8a9e5c7a3b6851551306b5d4ef7820137..19afd0e9e16147ad1463025d7342a239b206452a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -126,5 +126,15 @@ 	Select end time
 	<string name="more">More</string>
 	<string name="alert_header">Status updates</string>
 	<string name="map_attribution"><![CDATA[© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors]]></string>
+	<string name="favourite_content_description">Save as favourite</string>
+	<string name="favourite">Favourite</string>
+	<string name="filtered">Filtered</string>
+	<string name="unfiltered">Unfiltered</string>
+	<string name="cannot_save_favourite">Couldn’t save the favourite</string>
+	<string name="no_next_departures">no more departures</string>
+	<string name="error_44">No more departures</string>
+	<string name="loading">loading…</string>
+	<string name="favourite_deleted">Favourite deleted</string>
+	<string name="undo">Undo</string>
 
 </resources>




diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000000000000000000000000000000000000..df879f01ea8436856a0eed72136b1a4b85951084
--- /dev/null
+++ b/app/src/main/res/values-fr/strings.xml
@@ -0,0 +1,119 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="app_name">Bimba</string>
+    <string name="title_home">Accueil</string>
+    <string name="home_fab_description">Icône GPS</string>
+    <string name="save">Enregistrer</string>
+    <string name="cont">Continuer</string>
+    <string name="search_placeholder">Arrêts, lignes, ou Plus Codes</string>
+    <string name="error_401">Un jeton est nécessaire pour utiliser ce serveur</string>
+    <string name="error_403">Le jeton fourni est incorrect</string>
+    <string name="error_50x">Il y a eu une erreur sur le serveur. Réessayez plus tard</string>
+    <string name="error_connecting">Erreur lors de la connection au serveur. Réessayez plus tard</string>
+    <string name="error_unknown">Erreur inconnue</string>
+    <string name="waiting_position">En attente de la position</string>
+    <string name="vehicle_headsign">%1$s » %2$s</string>
+    <string name="congestion_unknown">Inconnu</string>
+    <string name="congestion_smooth">Fluide</string>
+    <string name="occupancy_standing_only">Uniquement debout</string>
+    <string name="congestion_jams">Embouteillages</string>
+    <string name="departure_departed">Parti</string>
+    <string name="at_time">à %1$02d:%2$02d</string>
+    <string name="departure_headsign_content_description">vers %1$s</string>
+    <string name="on_demand">à la demande</string>
+    <string name="no_boarding">pas d\'embarquement</string>
+    <string name="on_boarding">Embarquement</string>
+    <string name="boarding">Embarquement possible</string>
+    <string name="line_headsign">» %1$s</string>
+    <string name="stops_nearby">Arrêts à proximité</string>
+    <string name="line_headsigns_content_description">entre %1$s et %2$s</string>
+    <string name="line_headsign_content_description">vers %1$s</string>
+    <string name="line_headsigns">%1$s «» %2$s</string>
+    <string name="bimba_server_token_hint">Jeton</string>
+    <string name="tickets_sold_content_description">Tickets vendus à bord</string>
+    <string name="usb_charging_content_description">Charge USB</string>
+    <string name="voice_announcements_content_description">Annonces vocales</string>
+    <string name="stop_content_description">Arrêt</string>
+    <string name="onboarding_advanced">Avancé</string>
+    <string name="title_feeds">Horaires</string>
+    <string name="error">Erreur</string>
+    <string name="onboarding_advanced_action">Choisir un serveur</string>
+    <string name="server_private_question">Ce serveur est privé et aucun jeton n\'a été fourni</string>
+    <string name="stops_near_code">Arrêts à proximité de %1$s</string>
+    <string name="vehicle_headsign_content_description">%1$s vers %2$s</string>
+    <string name="title_about">A propos</string>
+    <string name="website_button_description">Lien vers le site</string>
+    <string name="use_online_feed">Utiliser le flux en ligne</string>
+    <string name="error_406">La version de l\'application est incompatible avec le serveur</string>
+    <string name="departures_snackbar">Dernière mise à jour : %1$s</string>
+    <string name="title_filter">Filtrer</string>
+    <string name="cancel">Annuler</string>
+    <string name="more">Plus</string>
+    <string name="title_voyage">Voyage</string>
+    <string name="title_activity_results">Résultats</string>
+    <string name="title_map">Carte</string>
+    <string name="no_departures">Aucun départ</string>
+    <string name="bimba_server_address_hint">Serveur</string>
+    <string name="air_condition_content_description">Air conditionné</string>
+    <string name="bicycles_allowed_content_description">Vélos autorisés</string>
+    <string name="show_departures">Montrer les départs</string>
+    <string name="bimba_server_continue_button">Continuer</string>
+    <string name="realtime_content_description">Départ en temps réel</string>
+    <string name="open_in_maps_app">Ouvrir dans une application de cartes</string>
+    <string name="departure_now">Maintenant</string>
+    <string name="results_for">Résultats pour ‘%1$s’</string>
+    <string name="at_time_realtime">à %1$02d:%2$02d:%3$02d</string>
+    <string name="wheelchair_content_description">Le véhicule est accessible pour les fauteuils roulants</string>
+    <string name="departure_momentarily">Prochainement</string>
+    <string name="error_offline">Vous êtes hors ligne. Connectez-vous à Internet</string>
+    <string name="off_boarding">Débarquement</string>
+    <string name="error_gps">Impossible d\'obtenir la position actuelle</string>
+    <string name="speed_in_km_per_h">%1$.3f km/h</string>
+    <string name="occupancy_unknown">Inconnu</string>
+    <string name="occupancy_few_seats">Peu de sièges</string>
+    <string name="occupancy_crowded">Encombré</string>
+    <string name="occupancy_empty">Vide</string>
+    <string name="occupancy_many_seats">Nombreux sièges</string>
+    <string name="occupancy_full">Rempli</string>
+    <string name="no_map_app">Aucune application de carte installée</string>
+    <string name="code_button_description">Lien vers le code source</string>
+    <string name="mastodon_button_description">Lien vers Mastodon</string>
+    <string name="title_select_date">Choisir le jour du départ</string>
+    <string name="title_select_line">Choisir une ligne</string>
+    <string name="last_update">Dernière mise à jour : %1$s</string>
+    <string name="title_servers">Serveurs</string>
+    <string name="title_cities">Localités</string>
+    <string name="information_may_be_outdated">L\'information peut être obsolète</string>
+    <string name="ok">OK</string>
+    <string name="no_location_message">L\'accès à la position est nécéssaire pour trouver les arrêts à proximité et afficher la position actuelle sur la carte. Les autres fonctions peuvent fonctionner sans cela. L\'accès peut être activé ou désactiver dans les paramètres du système à tout moment.</string>
+    <string name="stop_stub_on_demand_in_zone">Arrêt à la demande dans la zone %1$s</string>
+    <string name="stop_stub_in_zone">Arrêt dans la zone %1$s</string>
+    <string name="stop_stub_on_demand">Arrêt à la demande</string>
+    <string name="departure_headsign">» %1$s</string>
+    <string name="translation_button_description">Lien vers le service de traduction</string>
+    <string name="app_description">Assistant de transport public libre ; un horaire dans votre poche.</string>
+    <string name="error_41">Cette localité n\'est pas couverte par le serveur</string>
+    <string name="current_timetable_validity">Horraire valide : du %1$s au %2$s</string>
+    <string name="filter_localities">Filtrer les localités</string>
+    <string name="stop_from_qr_code">Code QR d\'arrêt</string>
+    <string name="title_filter_byline">Filtrer par ligne</string>
+    <string name="title_filter_bytime">Filtrer par horaire</string>
+    <string name="title_select_time_end">Sélectionner l\'heure d\'arrivée</string>
+    <string name="title_select_time_start">Sélectionner l\'heure de départ</string>
+    <string name="credits">Police yellowcircle8 (https://git.apiote.xyz/yellowcircle8.git) basée sur Railway Sans © Greg Fleming, OFL-1.1 https://github.com/davelab6/Railway-Sans
+\n
+\n Icône Mastodon (https://github.com/mastodon/joinmastodon) © Mastodon contributors, AGPL-3.0-or-later
+\n
+\n Logo Bimba créé par https://github.com/tebriz159
+\n
+\n Icônes Material © Google, Apache-2.0
+\n
+\n Données cartographiques © OpenStreetMap contributors, ODbL-1.0</string>
+    <string name="error_400">L\'application a effectué une requête mal formulée</string>
+    <string name="error_404">Pas trouvé</string>
+    <string name="error_429">Limite dépassée. Réessayez plus tard</string>
+    <string name="onboarding_simple_action">Choisir la localité</string>
+    <string name="no_location_access">Accès à la position non autorisé</string>
+    <string name="seatbelts_everyone">En voiture tout le monde!</string>
+    <string name="onboarding_simple">Simple</string>
+</resources>
\ No newline at end of file




diff --git a/bimba.svg b/bimba.svg
new file mode 100644
index 0000000000000000000000000000000000000000..8bfa5182d6af0ad1a4cfa32f9383bcb8c1844c9a
--- /dev/null
+++ b/bimba.svg
@@ -0,0 +1,158 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!--
+SPDX-FileCopyrightText: https://github.com/tebriz159
+
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+<svg
+   viewBox="0 0 91.999998 92"
+   id="vector"
+   version="1.1"
+   sodipodi:docname="bimba.svg"
+   width="92"
+   height="92"
+   inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
+   inkscape:export-filename="bimba.png"
+   inkscape:export-xdpi="534.26086"
+   inkscape:export-ydpi="534.26086"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:dc="http://purl.org/dc/elements/1.1/">
+  <metadata
+     id="metadata176">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs174" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1504"
+     inkscape:window-height="1002"
+     id="namedview172"
+     showgrid="false"
+     fit-margin-top="8"
+     fit-margin-left="8"
+     fit-margin-bottom="8"
+     fit-margin-right="8"
+     inkscape:zoom="8.5108696"
+     inkscape:cx="46"
+     inkscape:cy="-26.965517"
+     inkscape:window-x="0"
+     inkscape:window-y="0"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="vector"
+     inkscape:showpageshadow="0"
+     inkscape:pagecheckerboard="0"
+     inkscape:deskcolor="#505050" />
+  <rect
+     id="rect178"
+     width="92"
+     height="92"
+     x="0"
+     y="0"
+     style="display:inline;fill:#3a3a3b;fill-opacity:1;stroke-width:0.436372"
+     ry="0" />
+  <g
+     id="group_1"
+     transform="matrix(0.16904335,0,0,0.16904335,-7.9999769,-8)"
+     style="display:inline">
+    <g
+       id="group"
+       transform="translate(173.03255,173.03255)">
+      <path
+         id="path"
+         d="m 62.962,292.5 v 0 c -3.7,-0.9 -5.7,-3.8 -4.3,-6.5 l 32.3,-62.9 c 1.3,-2.6 5.5,-4 9.2,-3 v 0 c 3.7,0.9 5.7,3.8 4.3,6.5 l -32.3,62.9 c -1.3,2.6 -5.4,4 -9.2,3 z"
+         inkscape:connector-curvature="0"
+         style="fill:#54af39" />
+      <path
+         id="path_1"
+         d="m 229.662,292.5 v 0 c 3.7,-0.9 5.7,-3.8 4.3,-6.5 l -32.3,-62.9 c -1.3,-2.6 -5.5,-4 -9.2,-3 v 0 c -3.7,0.9 -5.7,3.8 -4.3,6.5 l 32.3,62.9 c 1.3,2.6 5.5,4 9.2,3 z"
+         inkscape:connector-curvature="0"
+         style="fill:#54af39" />
+      <path
+         id="path_2"
+         d="m 151.362,36.9 -0.6,0.2 c -1.8,0.7 -3.8,-0.3 -4.5,-2.1 l -9.6,-26.7 c -0.7,-1.8 0.3,-3.8 2.1,-4.5 l 0.6,-0.2 c 1.8,-0.7 3.8,0.3 4.5,2.1 l 9.6,26.7 c 0.7,1.9 -0.3,3.9 -2.1,4.5 z"
+         inkscape:connector-curvature="0"
+         style="fill:#54af39" />
+      <path
+         id="path_3"
+         d="m 180.662,3.8 v 0 c 0,2.1 -1.7,3.8 -3.8,3.8 h -61 c -2.1,0 -3.8,-1.7 -3.8,-3.8 v 0 c 0,-2.1 1.7,-3.8 3.8,-3.8 h 61 c 2.1,0 3.8,1.7 3.8,3.8 z"
+         inkscape:connector-curvature="0"
+         style="fill:#54af39" />
+      <path
+         id="path_4"
+         d="m 218.762,236.7 h -144.9 c -13.3,0 -24,-10.8 -24,-24 v -108 c 0,-41.3 33.5,-74.9 74.9,-74.9 h 43.3 c 41.3,0 74.9,33.5 74.9,74.9 v 108 c -0.1,13.2 -10.9,24 -24.2,24 z"
+         inkscape:connector-curvature="0"
+         style="fill:#54af39" />
+      <path
+         id="path_5"
+         d="m 212.562,146.2 h -132.5 c -5.6,0 -10.2,-4.5 -10.2,-10.2 v -34.9 c 0,-16.9 13.7,-30.6 30.6,-30.6 h 91.7 c 16.9,0 30.6,13.7 30.6,30.6 V 136 c -0.1,5.7 -4.6,10.2 -10.2,10.2 z"
+         inkscape:connector-curvature="0"
+         style="fill:#ffffff" />
+      <path
+         id="path_6"
+         d="m 161.462,55.5 h -30.3 c -3.2,0 -5.7,-2.6 -5.7,-5.7 v 0 c 0,-3.2 2.6,-5.7 5.7,-5.7 h 30.3 c 3.2,0 5.7,2.6 5.7,5.7 v 0 c 0,3.1 -2.6,5.7 -5.7,5.7 z"
+         inkscape:connector-curvature="0"
+         style="fill:#ffffff" />
+      <path
+         id="path_7"
+         d="m 87.062,191.9 m -14.8,0 c 0,-3.924 1.56,-7.691 4.335,-10.465 2.774,-2.775 6.541,-4.335 10.465,-4.335 3.924,0 7.691,1.56 10.465,4.335 2.775,2.774 4.335,6.541 4.335,10.465 0,3.924 -1.56,7.691 -4.335,10.465 -2.774,2.775 -6.541,4.335 -10.465,4.335 -3.924,0 -7.691,-1.56 -10.465,-4.335 -2.775,-2.774 -4.335,-6.541 -4.335,-10.465"
+         inkscape:connector-curvature="0"
+         style="fill:#ffffff" />
+      <path
+         id="path_8"
+         d="m 205.662,191.9 m -14.8,0 c 0,-3.924 1.56,-7.691 4.335,-10.465 2.774,-2.775 6.541,-4.335 10.465,-4.335 3.924,0 7.691,1.56 10.465,4.335 2.775,2.774 4.335,6.541 4.335,10.465 0,3.924 -1.56,7.691 -4.335,10.465 -2.774,2.775 -6.541,4.335 -10.465,4.335 -3.924,0 -7.691,-1.56 -10.465,-4.335 -2.775,-2.774 -4.335,-6.541 -4.335,-10.465"
+         inkscape:connector-curvature="0"
+         style="fill:#ffffff" />
+    </g>
+  </g>
+  <g
+     aria-label="Bimba"
+     style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:56px;line-height:1.25;font-family:Railway;-inkscape-font-specification:Railway;letter-spacing:0px;word-spacing:0px;display:none;fill:#ffffff;fill-opacity:1;stroke:none"
+     id="text1004"
+     transform="translate(-8.114,-14.86441)">
+    <path
+       d="m 116.40217,69.760412 c 0,0.896 -0.256,1.792 -0.704,2.624 -0.448,0.832 -1.024,1.536 -1.792,2.176 -0.768,0.64 -1.6,1.088 -2.624,1.472 -1.024,0.384 -2.048,0.512 -3.2,0.512 h -7.936 v -14.848 h 3.392 4.416 c 1.152,0 2.24,0.256 3.264,0.64 1.024,0.448 1.92,1.024 2.688,1.728 0.768,0.768 1.344,1.6 1.792,2.56 0.448,1.024 0.704,2.048 0.704,3.136 z m -4.672,-19.968 c 0,0.768 -0.192,1.536 -0.512,2.176 -0.384,0.64 -0.832,1.216 -1.408,1.728 -0.576,0.512 -1.216,0.896 -1.984,1.152 -0.768,0.32 -1.536,0.448 -2.368,0.448 h -5.312 v -10.752 h 5.12 c 0.832,0 1.664,0.192 2.432,0.448 0.768,0.256 1.472,0.64 2.112,1.088 0.576,0.512 1.024,1.024 1.408,1.664 0.32,0.64 0.512,1.344 0.512,2.048 z m 11.584,19.904 c 0,-2.688 -0.768,-5.184 -2.24,-7.424 -1.536,-2.24 -3.456,-3.968 -5.824,-5.248 0.96,-0.96 1.728,-2.048 2.304,-3.328 0.512,-1.216 0.832,-2.496 0.832,-3.904 0,-1.6 -0.384,-3.136 -1.024,-4.544 -0.704,-1.408 -1.664,-2.624 -2.816,-3.712 -1.216,-1.024 -2.624,-1.856 -4.224,-2.496 -1.6,-0.576 -3.264,-0.896 -5.056,-0.896 H 93.746168 v 44.8 h 14.272002 c 2.048,0 4.032,-0.32 5.888,-1.024 1.856,-0.704 3.456,-1.664 4.864,-2.88 1.408,-1.152 2.496,-2.56 3.328,-4.224 0.768,-1.6 1.216,-3.328 1.216,-5.12 z"
+       style="font-size:64px"
+       id="path4709"
+       inkscape:connector-curvature="0" />
+    <path
+       d="m 134.45016,82.944412 v -30.336 h -6.336 v 30.336 z m 1.344,-38.272 -4.48,-4.544 -4.48,4.544 4.48,4.48 z"
+       style="font-size:64px"
+       id="path4711"
+       inkscape:connector-curvature="0" />
+    <path
+       d="m 188.59409,82.944412 v -18.048 c 0,-1.664 -0.384,-3.328 -1.024,-4.864 -0.704,-1.536 -1.6,-2.88 -2.752,-4.032 -1.152,-1.152 -2.496,-2.048 -4.032,-2.752 -1.536,-0.64 -3.072,-1.024 -4.736,-1.024 -2.048,0 -4.032,0.448 -5.888,1.216 -1.92,0.832 -3.712,1.984 -5.376,3.392 -1.216,-1.344 -2.624,-2.432 -4.224,-3.328 -1.664,-0.832 -3.392,-1.28 -5.184,-1.28 -1.472,0 -2.88,0.192 -4.16,0.576 -1.344,0.384 -2.56,0.96 -3.712,1.6 v -1.856 h -6.592 v 30.4 h 6.528 v -21.504 c 0.768,-0.768 1.792,-1.408 3.008,-1.92 1.216,-0.448 2.56,-0.704 4.032,-0.704 0.96,0 1.856,0.192 2.752,0.512 0.832,0.32 1.6,0.768 2.24,1.28 0.64,0.576 1.088,1.216 1.472,1.984 0.384,0.768 0.576,1.536 0.576,2.304 v 18.048 h 6.4 v -20.096 c 0.96,-1.216 2.048,-2.176 3.328,-2.944 1.216,-0.704 2.496,-1.088 3.904,-1.088 0.96,0 1.856,0.192 2.752,0.512 0.832,0.32 1.6,0.768 2.24,1.28 0.64,0.576 1.088,1.216 1.472,1.984 0.384,0.768 0.576,1.536 0.576,2.304 v 18.048 z"
+       style="font-size:64px"
+       id="path4713"
+       inkscape:connector-curvature="0" />
+    <path
+       d="m 217.90603,67.776412 c 0,1.28 -0.256,2.496 -0.768,3.648 -0.512,1.152 -1.152,2.176 -1.984,3.008 -0.896,0.896 -1.856,1.536 -2.944,2.048 -1.152,0.512 -2.304,0.704 -3.584,0.704 -1.536,0 -3.008,-0.256 -4.352,-0.768 -1.408,-0.512 -2.56,-1.216 -3.52,-2.112 v -12.736 c 0.96,-0.896 2.112,-1.6 3.456,-2.112 1.344,-0.512 2.816,-0.832 4.416,-0.832 1.28,0 2.432,0.256 3.584,0.768 1.088,0.512 2.048,1.152 2.944,1.984 0.832,0.832 1.472,1.792 1.984,2.88 0.512,1.152 0.768,2.304 0.768,3.52 z m 6.4,0 c 0,-2.112 -0.448,-4.096 -1.28,-6.016 -0.832,-1.856 -1.92,-3.52 -3.328,-4.928 -1.408,-1.408 -3.072,-2.496 -4.992,-3.328 -1.92,-0.832 -3.904,-1.28 -6.016,-1.28 -1.472,0 -2.88,0.192 -4.224,0.576 -1.344,0.384 -2.56,0.96 -3.712,1.6 v -16.256 h -6.4 v 44.8 h 6.4 v -1.472 c 1.152,0.704 2.368,1.216 3.712,1.6 1.28,0.384 2.688,0.512 4.16,0.512 2.112,0 4.096,-0.384 6.016,-1.216 1.92,-0.832 3.584,-1.984 4.992,-3.392 1.408,-1.408 2.56,-3.136 3.392,-5.056 0.832,-1.92 1.28,-3.968 1.28,-6.144 z"
+       style="font-size:64px"
+       id="path4715"
+       inkscape:connector-curvature="0" />
+    <path
+       d="m 246.57798,76.160412 c -0.896,0.512 -1.856,0.96 -2.88,1.28 -1.024,0.32 -2.048,0.448 -2.944,0.448 -1.664,0 -3.136,-0.192 -4.288,-0.576 -1.152,-0.384 -1.728,-1.216 -1.728,-2.624 0,-0.896 0.256,-1.664 0.896,-2.304 0.64,-0.576 1.472,-1.024 2.56,-1.344 1.024,-0.32 2.304,-0.576 3.776,-0.704 1.408,-0.128 2.944,-0.192 4.608,-0.192 z m 6.4,6.784 v -18.88 c 0,-2.816 -0.448,-5.12 -1.344,-6.848 -0.896,-1.728 -1.92,-3.072 -3.2,-3.968 -1.28,-0.896 -2.624,-1.472 -4.032,-1.728 -1.408,-0.256 -2.688,-0.448 -3.776,-0.448 -1.536,0 -3.072,0.256 -4.672,0.768 -1.664,0.512 -3.072,1.088 -4.224,1.856 l -2.432,8.064 h 0.64 c 1.792,-1.6 3.52,-2.688 5.184,-3.328 1.664,-0.64 3.392,-0.96 5.248,-0.96 1.216,0 2.24,0.192 3.072,0.448 0.768,0.32 1.408,0.768 1.92,1.344 0.448,0.576 0.768,1.28 0.96,2.176 0.128,0.896 0.256,1.856 0.256,2.944 -3.328,0 -6.144,0.32 -8.448,0.832 -2.304,0.576 -4.16,1.344 -5.568,2.24 -1.408,0.96 -2.432,2.048 -3.072,3.328 -0.64,1.28 -0.896,2.624 -0.896,4.032 0,1.28 0.256,2.432 0.832,3.456 0.512,1.024 1.28,1.984 2.24,2.752 0.896,0.832 1.984,1.472 3.264,1.92 1.28,0.448 2.624,0.64 4.096,0.64 1.152,0 2.368,-0.192 3.776,-0.576 1.408,-0.384 2.624,-0.896 3.776,-1.664 v 1.6 z"
+       style="font-size:64px"
+       id="path4717"
+       inkscape:connector-curvature="0" />
+  </g>
+</svg>




diff --git a/metadata/en-GB/changelogs/28.txt b/metadata/en-GB/changelogs/28.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d809d57cf88dd3aea767adf91b0362ca0174683a
--- /dev/null
+++ b/metadata/en-GB/changelogs/28.txt
@@ -0,0 +1,9 @@
+Changes in version 3.4
+* Added countdown to show next update of departures
+* Fixed persisting filters and bottom sheets across screen changes
+* Changed layout of alerts above departures
+* Fixed phantom search results
+* Fixed edge-to-edge and landscape layouts
+* Added simple favourites
+* Added French translation (by ArnaudDvs)
+




diff --git a/metadata/en-GB/images/phoneScreenshots/dash_w_fav.png b/metadata/en-GB/images/phoneScreenshots/dash_w_fav.png
new file mode 100644
index 0000000000000000000000000000000000000000..cb5c6df2d6613b95031e864a2e691de790eb9e84
Binary files /dev/null and b/metadata/en-GB/images/phoneScreenshots/dash_w_fav.png differ




diff --git a/metadata/en-US/changelogs/28.txt b/metadata/en-US/changelogs/28.txt
new file mode 100644
index 0000000000000000000000000000000000000000..cb8a27715209ea9d00bbeee41297eea86c78bd6a
--- /dev/null
+++ b/metadata/en-US/changelogs/28.txt
@@ -0,0 +1,8 @@
+Changes in version 3.4
+* Added countdown to show next update of departures
+* Fixed persisting filters and bottom sheets across screen changes
+* Changed layout of alerts above departures
+* Fixed phantom search results
+* Fixed edge-to-edge and landscape layouts
+* Added simple favorites
+* Added French translation (by ArnaudDvs)




diff --git a/metadata/en-US/images/phoneScreenshots/dash_w_fav.png b/metadata/en-US/images/phoneScreenshots/dash_w_fav.png
new file mode 100644
index 0000000000000000000000000000000000000000..cb5c6df2d6613b95031e864a2e691de790eb9e84
Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/dash_w_fav.png differ




diff --git a/metadata/fr-FR/changelogs/20.txt b/metadata/fr-FR/changelogs/20.txt
new file mode 100644
index 0000000000000000000000000000000000000000..aa3a5c91697382ea5b00c3252d51aad6a3319d0b
--- /dev/null
+++ b/metadata/fr-FR/changelogs/20.txt
@@ -0,0 +1,5 @@
+La version 3.0 a été complètement réécrite, et présente une toute nouvelle architecture.
+
+* Les favoris sont supprimés
+* Les arrêts et véhicules sont indiqués sur une carte
+* L'interface est désormais Material3




diff --git a/metadata/fr-FR/changelogs/21.txt b/metadata/fr-FR/changelogs/21.txt
new file mode 100644
index 0000000000000000000000000000000000000000..aa3a5c91697382ea5b00c3252d51aad6a3319d0b
--- /dev/null
+++ b/metadata/fr-FR/changelogs/21.txt
@@ -0,0 +1,5 @@
+La version 3.0 a été complètement réécrite, et présente une toute nouvelle architecture.
+
+* Les favoris sont supprimés
+* Les arrêts et véhicules sont indiqués sur une carte
+* L'interface est désormais Material3




diff --git a/metadata/fr-FR/changelogs/22.txt b/metadata/fr-FR/changelogs/22.txt
new file mode 100644
index 0000000000000000000000000000000000000000..5a6ae1abe9e941b410ef81fa1dc70ab2fa3fa5c3
--- /dev/null
+++ b/metadata/fr-FR/changelogs/22.txt
@@ -0,0 +1,11 @@
+Caractéristiques de la version 3.1 :
+* icône de lancement monochrome pour Android 12,
+* thème Material You avec couleurs du système,
+* nouvelle barre de recherche,
+* recherche par code Open Location,
+* glissement dans la minicarte,
+* traduction en polonais et en italien,
+* gestion de multiples flux,
+* codes QR dans la Métropole GZM,
+* Recherche par ligne (et sélection d'arrêt sur graphes),
+* mise à jour des dépendances, corrections de bugs, et refactorisation du code.




diff --git a/metadata/fr-FR/changelogs/23.txt b/metadata/fr-FR/changelogs/23.txt
new file mode 100644
index 0000000000000000000000000000000000000000..47cca9d523ae18d4118e7fa1f3c2f4102807ecca
--- /dev/null
+++ b/metadata/fr-FR/changelogs/23.txt
@@ -0,0 +1 @@
+La version 3.1.1 corrige le bouton "Continuer" lors du choix du serveur




diff --git a/metadata/fr-FR/changelogs/24.txt b/metadata/fr-FR/changelogs/24.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0855dbc3062b5827cd01bc882cbce227ba727f9b
--- /dev/null
+++ b/metadata/fr-FR/changelogs/24.txt
@@ -0,0 +1,10 @@
+Changements dans la version 3.2 :
+* Ajout d'un écran "A propos"
+* le défilement vers le bas charge plus de départs
+* les informations de localités et les paramètres sont mis en cache hors ligne
+* les listes mettent seulement à jour les éléments modifiés
+* les résultats de recherche incluent le nom de la localité
+* La liste des localités peut être filtrée (plus de 12 éléments)
+* changement des icônes d'arrêts
+* les icônes sur la carte sont mises à l'échelle
+* Bimba est conforme REUSE




diff --git a/metadata/fr-FR/changelogs/28.txt b/metadata/fr-FR/changelogs/28.txt
new file mode 100644
index 0000000000000000000000000000000000000000..cb8a27715209ea9d00bbeee41297eea86c78bd6a
--- /dev/null
+++ b/metadata/fr-FR/changelogs/28.txt
@@ -0,0 +1,8 @@
+Changes in version 3.4
+* Added countdown to show next update of departures
+* Fixed persisting filters and bottom sheets across screen changes
+* Changed layout of alerts above departures
+* Fixed phantom search results
+* Fixed edge-to-edge and landscape layouts
+* Added simple favorites
+* Added French translation (by ArnaudDvs)




diff --git a/metadata/fr-FR/full_description.txt b/metadata/fr-FR/full_description.txt
new file mode 100644
index 0000000000000000000000000000000000000000..eff6644da8451bb0b3e02f91d14b41d468b746c6
--- /dev/null
+++ b/metadata/fr-FR/full_description.txt
@@ -0,0 +1,9 @@
+Bimba vous permet de consulter les horaires des transports publics, avec départs en temps réels dans plusieurs villes.
+
+Caractéristiques :
+* consulter les départs par arrêts,
+* chercher les arrêts à proximité,
+* chercher des arrêts avec un code QR (dans les horaires sélectionnés),
+* chercher les arrêts sur une ligne,
+* caractéristiques des véhicules (en fonction des informations disponibles),
+* véhicules et arrêts sur la carte en temps réel,




diff --git a/metadata/fr-FR/short_description.txt b/metadata/fr-FR/short_description.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9d264b9a83281e1cd943525e6aa9e0e0096768ea
--- /dev/null
+++ b/metadata/fr-FR/short_description.txt
@@ -0,0 +1 @@
+Assistant de transports publics libre ; un horaire dans votre poche.




diff --git a/metadata/fr-FR/title.txt b/metadata/fr-FR/title.txt
new file mode 100644
index 0000000000000000000000000000000000000000..038d5e6047bc863c8fd2210be761c396b834e447
--- /dev/null
+++ b/metadata/fr-FR/title.txt
@@ -0,0 +1 @@
+Bimba




diff --git a/metadata/pl-PL/changelogs/28.txt b/metadata/pl-PL/changelogs/28.txt
new file mode 100644
index 0000000000000000000000000000000000000000..13d42a664af2a78825953c59dfc204b945f0a680
--- /dev/null
+++ b/metadata/pl-PL/changelogs/28.txt
@@ -0,0 +1,8 @@
+Zmiany w wersji 3.4
+* Dodano odliczanie do następnej aktualizacji odjazdów
+* Naprawiono zachowywanie filtrów bottom sheets pomiędzy zmianami orientacji
+* Zmieniono układ alertów nad odjazdami
+* Naprawiono fantomowe wyniki wyszukiwania
+* Naprawiono układy horyzontalne i krawędź-do-krawędzi
+* Dodano proste ulubione przystanki
+* Dodano francuskie tłumaczenie (ArnaudDvs)




diff --git a/metadata/pl-PL/images/phoneScreenshots/dash_w_fav.png b/metadata/pl-PL/images/phoneScreenshots/dash_w_fav.png
new file mode 100644
index 0000000000000000000000000000000000000000..f774583c27d590632b3d8b77bde9c88907d163b6
Binary files /dev/null and b/metadata/pl-PL/images/phoneScreenshots/dash_w_fav.png differ




diff --git a/release.sh b/release.sh
index 76d0c0530391fe1a3d0cb63b0d404cd4e6135e95..2929171a1ce309602ad7733a1b8914e677fa95fe 100755
--- a/release.sh
+++ b/release.sh
@@ -102,7 +102,7 @@ 		exit 1
 	fi
 	git add app/build.gradle
 	git add metadata/
-	git commit -S -m "release version $newVersionName ($newVersionCode)"
+	git commit -S -m "release version $newVersionName ($newVersionCode)" || true
 	echo 'pushing …'
 	git push
 	git switch master