Bimba.git

commit ac22ab61a7e71bcae8ff03cfbcc55952c074b7c9

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

wip; getting favourites from sqlite

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


diff --git a/app/build.gradle b/app/build.gradle
index 95ad937cd17c833225b63ce882c5893c84872f58..7ed02a6c02fd63bbd76f6bb7c31bd03912a1bba5 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 {
@@ -49,6 +50,10 @@         viewBinding true
     }
     namespace 'xyz.apiote.bimba.czwek'
     buildToolsVersion = '34.0.0'
+
+    sourceSets {
+        main.java.srcDirs += 'src/main/proto'
+    }
 }
 
 dependencies {




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 e984a11476a6821e66bd4cc898785c56136f6b80..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
@@ -151,7 +151,7 @@ 						)
 					)
 						.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..2990a44df86f9df529610830fcf50f7a8e3b5402
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt
@@ -0,0 +1,116 @@
+// 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.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.util.Optional
+
+// TODO deleting favourite
+// TODO drag-drop sorting
+// TODO diffUtil
+class BimbaFavouritesAdapter(
+	private var favourites: List<Favourite>,
+	private var departures: List<Optional<Departure>?>,
+	private val inflater: LayoutInflater,
+	private val context: Context
+) :
+	RecyclerView.Adapter<FavouriteViewHolder>() {
+	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.getOrNull(position))
+	}
+
+	fun updateFavourites(favourites: List<Favourite>) {
+		this.favourites = favourites
+		departures = favourites.map { Optional.empty() }
+		notifyDataSetChanged()
+	}
+
+	fun updateDepartures(departures: List<Departure?>) {
+		this.departures = departures.map { Optional.ofNullable(it) }
+		notifyDataSetChanged()
+	}
+}
+
+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.no_next_departures)
+				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.loading)
+				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 6e29e6b47a85f9f90535ab1bce25b0fe086fb11d..85c205de059b0edc5e79eb81120daa5339261724 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,6 +5,7 @@
 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
@@ -23,12 +24,26 @@ 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.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(60)).millis, Millisecond(Second(10)).millis) {
+			override fun onTick(millisUntilFinished: Long) {
+			}
+
+			override fun onFinish() {
+				refreshDepartures()
+			}
+		}
 
 	override fun onCreateView(
 		inflater: LayoutInflater,
@@ -37,13 +52,23 @@ 		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)
 		}
+		viewModel.favourites.observe(viewLifecycleOwner) {
+			favouritesAdapter.updateFavourites(it)
+		}
+		viewModel.departures.observe(viewLifecycleOwner) {
+			favouritesAdapter.updateDepartures(it)
+		}
+
+		countdown.start()
+
+		binding.searchView.setupWithSearchBar(binding.searchBar)
 
 		val onBackPressedCallback = object :
 			OnBackPressedCallback(binding.searchView.currentTransitionState == TransitionState.SHOWN) {
@@ -71,7 +96,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) {
@@ -111,11 +136,28 @@ 			}
 			windowInsets
 		}
 
+		binding.favourites.layoutManager = LinearLayoutManager(context)
+		favouritesAdapter = BimbaFavouritesAdapter(listOf(), listOf(), layoutInflater, requireContext())
+		binding.favourites.adapter = favouritesAdapter
+
+		viewModel.getFavourites(requireContext())
+
 		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..4d483fbff8df25337bb52fbc93aa69c3045131e7 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,7 +14,14 @@ 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.api.Error
+import xyz.apiote.bimba.czwek.api.mapHttpError
+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
@@ -27,6 +34,10 @@ 	private val mutableQueryables = MutableLiveData>()
 	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<List<Departure?>>()
+	val departures: LiveData<List<Departure?>> = mutableDepartures
 
 	fun getQueryables(query: String, context: Context) {
 		viewModelScope.launch {
@@ -34,14 +45,65 @@ 			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())
+			} catch (e: TrafficResponseException) {
+				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()) {
+								val (string, image) = mapHttpError(44)
+								throw TrafficResponseException(44, "", Error(44, string, image))
+							}
+							sDs.departures.find {departure -> favourite.lines.isEmpty() or favourite.lines.contains(departure.vehicle.Line.name) }
+						}
+					} catch (e: TrafficResponseException) {
+						Log.w("DeparturesForFavourite", "$e")
+						null
+					}
+				}
+			}.awaitAll()
+		}
+	}
+
 	private suspend fun getFeeds(context: Context) {
-		feeds = OfflineRepository().getFeeds(context)
+		feeds = OfflineRepository(context).getFeeds(context)
 		feedsSettings = FeedsSettings.load(context)
 	}
 




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 5de90c71a88961a4d3bfaab1a44276da62cd189c..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,6 +11,7 @@ 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
@@ -83,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)
 		}
@@ -131,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




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 c4e0c76fc338134ce0c4aebe9abef6fbfb0a5b26..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
@@ -206,23 +206,29 @@
 	fun update(
 		departures: List<DepartureItem>,
 		showAsTime: Boolean,
-		areNewObserved: Boolean = false
+		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 ->
+		newDepartures.forEachIndexed { i, departure ->
 			newPositions[departure.departure?.ID ?: "alert"] = i
 		}
 		val diff = DiffUtil.calculateDiff(
 			DiffUtilCallback(
 				this.items,
-				departures,
+				newDepartures,
 				this.showAsTime != showAsTime
 			)
 		)
 
 		this.showAsTime = showAsTime
 
-		this.items = departures
+		this.items = newDepartures
 		departuresPositions = newPositions
 		if (areNewObserved) {
 			lastUpdate = ZonedDateTime.now()




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 a84d76aaee54c1de7f43a086a45b106a46c7ea7e..9d48f90b5f84b54f504c43afada054b7e34869ab 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
@@ -16,6 +16,7 @@ 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
@@ -32,10 +33,16 @@ 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.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.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
@@ -97,21 +104,45 @@ 			windowInsets
 		}
 
 		viewModel = ViewModelProvider(this)[DeparturesViewModel::class.java]
+
 		getLine()?.let {
-			viewModel.linesFilter[it] = true
+			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) { _, _ ->
-				viewModel.linesFilter.clear()
-				getDepartures()
+				viewModel.mutableLinesFilter.value = emptyMap()
+				//getDepartures()
 			}
 			.setPositiveButton(R.string.ok) { _, _ ->
-				linesFilterTemporary.forEach { viewModel.linesFilter[it.key] = it.value }
-				getDepartures()
+				viewModel.mutableLinesFilter.value = emptyMap()
+				viewModel.mutableLinesFilter.value = linesFilterTemporary
+				//getDepartures()
 			}
 
+		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()) {
@@ -119,8 +150,9 @@ 				items.add(DepartureItem(stopDepartures.alerts))
 			}
 			items.addAll(stopDepartures.departures
 				.filter { d ->
-					viewModel.linesFilter.values.all { !it } or (viewModel.linesFilter[d.vehicle.Line.name]
-						?: false)
+					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())
@@ -130,6 +162,7 @@ 			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()
 				val s2n = s2.toIntOrNull()
@@ -139,7 +172,8 @@ 				} else {
 					s1.compareTo(s2)
 				}
 			}.toSet().toTypedArray()
-			val selections = lines.map { viewModel.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
@@ -151,6 +185,14 @@ 		}
 
 		binding.departuresAppBar.menu.findItem(R.id.departures_filter_bytime).setEnabled(false)
 
+		// TODO async esp. with Online
+		if (runBlocking {
+				OfflineRepository(this@DeparturesActivity).getFavourite(
+					getCode() ?: ""
+				)
+			} != null) {
+			binding.departuresAppBar.menu.findItem(R.id.favourite).setIcon(R.drawable.favourite_full)
+		}
 
 		datePicker.addOnNegativeButtonClickListener {
 			viewModel.date = null
@@ -176,8 +218,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
@@ -185,7 +252,7 @@ 				}
 
 				R.id.departures_filter_byline -> {
 					linesFilterTemporary.clear()
-					viewModel.linesFilter.forEach { filter ->
+					viewModel.linesFilter.value?.forEach { filter ->
 						linesFilterTemporary[filter.key] = filter.value
 					}
 					linePicker?.show()
@@ -247,10 +314,10 @@ 				}
 			}
 		)
 		adapter = BimbaDeparturesAdapter(layoutInflater, this, listOf()) {
-				DepartureBottomSheet(it).apply {
-					show(supportFragmentManager, DepartureBottomSheet.TAG)
-					viewModel.openBottomSheet = this
-					setOnCancel { viewModel.openBottomSheet = null }
+			DepartureBottomSheet(it).apply {
+				show(supportFragmentManager, DepartureBottomSheet.TAG)
+				viewModel.openBottomSheet = this
+				setOnCancel { viewModel.openBottomSheet = null }
 			}
 		}
 		binding.departuresRecycler.adapter = adapter
@@ -310,6 +377,15 @@ 			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) {
@@ -363,8 +439,9 @@ 		binding.errorImage.visibility = View.GONE
 		binding.errorText.visibility = View.GONE
 	}
 
-	private fun updateItems(departures: List<DepartureItem>, stop: Stop) {
+	private fun updateItems(departures: List<DepartureItem>, stop: Stop?, leaveAlert: Boolean = false) {
 		setupSnackbar()
+		binding.departuresRecycler.scrollToPosition(0)
 		binding.departuresProgress.visibility = View.GONE
 		// TODO [elizabeth] max, progress from header Cache-Control max-age
 		binding.departuresUpdatesProgress.apply {
@@ -375,9 +452,9 @@ 			progress = Tim(Second(30)).tims
 		}
 		countdown.cancel()
 		countdown.start()
-		adapter.update(departures, viewModel.date != null, true)
+		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
@@ -401,4 +478,31 @@ 			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(
+				feedID,
+				feedName,
+				code,
+				getName(),
+				linesFilter.toList()
+			)).copy(lines = linesFilter.toList())
+			repo.saveFavourite(favourite)
+		}
+	}
+}




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 6f2272afe8fe7a9dec1164df13fc8273aa438fb4..21950564af8d8c98ac0010be649143bcb7e3d6e6 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
@@ -35,12 +35,12 @@ 	var allItemsRequested = false
 	private var feed: FeedInfo? = null
 	var openBottomSheet: DepartureBottomSheet? = null
 	private lateinit var code: String
-	// TODO observe in activity, maybe refreshing and not getting departures is enough
-	val linesFilter = mutableMapOf<String, Boolean>()
+	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, maybe refreshing and not getting departures is enough
+	// TODO observe in activity, refreshing is not enough
 	var date: LocalDate? = null
 
 	fun getDepartures(context: Context, date: LocalDate?, force: Boolean) {
@@ -61,9 +61,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
 				}
@@ -78,11 +78,11 @@ 	}
 
 	private suspend fun getFeed(context: Context): FeedInfo {
 		val intent = (context as Activity).intent
-		var feeds = OfflineRepository().getFeeds(context)
+		var feeds = OfflineRepository(context).getFeeds(context)
 		if (feeds.isNullOrEmpty()) {
 			feeds = OnlineRepository().getFeeds(context)
 			if (feeds != null) {
-				OfflineRepository().saveFeedCache(context, feeds)
+				OfflineRepository(context).saveFeedCache(context, feeds)
 			}
 		}
 		return when (intent.action) {




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/repo/Favourite.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e77f790d1a24d664c9f08ec2dbdab4c6c902a2f5
--- /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 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..6db3f554e6eb6b110e5984f36e66f3584dfe1fba 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,11 @@ 	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 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..1f1f6b72c86d6e4b0d22cc8a8f1acb2c9c9c73eb 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,6 +5,7 @@
 package xyz.apiote.bimba.czwek.repo
 
 import android.content.Context
+import android.database.sqlite.SQLiteDatabase
 import androidx.core.content.edit
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
@@ -15,7 +16,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 +37,69 @@ 		stream.flush()
 		stream.close()
 	}
 
+	override suspend fun getFavourite(stopCode: String): Favourite? {
+		val cursor =
+			db.rawQuery(
+				"select 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.getString(1),
+			cursor.getString(2),
+			stopCode,
+			cursor.getString(0),
+			cursor.getString(2).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 stop_name, feed_id, feed_name, stop_code, lines from favourites $whereClause",
+				feedIDs.toTypedArray()
+			)
+		val l = mutableListOf<Favourite>()
+		while (cursor.moveToNext()) {
+			l.add(
+				Favourite(
+					cursor.getString(1),
+					cursor.getString(2),
+					cursor.getString(3),
+					cursor.getString(0),
+					cursor.getString(4).split("||").filter { it != "" }
+				)
+			)
+		}
+		cursor.close()
+		return l
+	}
+
+	override suspend fun saveFavourite(favourite: Favourite) {
+		db.execSQL(
+			"insert into favourites(feed_id, feed_name, stop_code, stop_name, lines) values (?,?,?,?,?) on conflict(feed_id, stop_code) do update set stop_name = ?, lines = ?",
+			arrayOf(
+				favourite.feedID,
+				favourite.feedName,
+				favourite.stopCode,
+				favourite.stopName,
+				favourite.lines.joinToString(separator = "||"),
+				favourite.stopName,
+				favourite.lines.joinToString(separator = "||")
+			)
+		)
+	}
+
 	@Suppress("RedundantNullableReturnType")
 	override suspend fun getFeeds(
 		context: Context,
@@ -107,5 +174,10 @@ 		context: Context
 	): List<Queryable>? {
 		TODO("Not yet implemented")
 	}
+}
 
+
+fun migrateDB(context: Context) {
+	val db = SQLiteDatabase.openOrCreateDatabase(context.getDatabasePath("favourites").path, null)
+	db.execSQL("create table if not exists favourites(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..2e9221f818e5c8af264195212ffca2b425fdd68e 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,18 @@
 // 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 getFeeds(
 		context: Context,
 		server: Server




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 fe83369df4782fe86f711d835f11bc427d3d9bae..387f9b58407aecb312d1875780b0d24104c1d7b0 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
@@ -153,7 +153,7 @@ 	}
 
 	private suspend fun getFeeds() {
 		if (adapter.feeds.isNullOrEmpty()) {
-			adapter.feeds = OfflineRepository().getFeeds(this)
+			adapter.feeds = OfflineRepository(this).getFeeds(this)
 		}
 		if (adapter.feedsSettings == null) {
 			adapter.feedsSettings = FeedsSettings.load(this)




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 ce3a7df9ef1132e3a0770d837b341475da295535..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,11 +4,9 @@ // 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
@@ -134,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()
 					}
 				}
@@ -144,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/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..95ea23d25bf9938f5d220360ba71245508a06c1e 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
@@ -52,7 +52,7 @@ 		MainScope().launch {
 			withContext(coroutineContext) {
 				launch {
 					offlineFeeds =
-						OfflineRepository().getFeeds(context)
+						OfflineRepository(context).getFeeds(context)
 					if (!offlineFeeds.isNullOrEmpty()) {
 						_feeds.value = offlineFeeds!!
 					}
@@ -73,7 +73,7 @@ 				_error.value = error!!
 			}  else{
 				joinFeeds(offlineFeeds, onlineFeeds).let { joinedFeeds ->
 					_feeds.value = joinedFeeds
-					OfflineRepository().saveFeedCache(context, joinedFeeds)
+					OfflineRepository(context).saveFeedCache(context, joinedFeeds)
 				}
 			}
 		}




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_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/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 d2f52f4c6ae0429875e78dcf95f6498c2d8e7ed7..2f7c7394a3a1c5cbfd16861d7efc19b876056cfb 100644
--- a/app/src/main/res/layout/fragment_home.xml
+++ b/app/src/main/res/layout/fragment_home.xml
@@ -6,7 +6,7 @@
 SPDX-License-Identifier: GPL-3.0-or-later
 -->
 
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
 	xmlns:app="http://schemas.android.com/apk/res-auto"
 	xmlns:tool="http://schemas.android.com/tools"
 	android:layout_width="match_parent"
@@ -14,68 +14,73 @@ 	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
+	<com.google.android.material.search.SearchBar
+		android:id="@+id/search_bar"
 		android:layout_width="match_parent"
-		android:layout_height="wrap_content">
-
-		<com.google.android.material.search.SearchBar
-			android:id="@+id/search_bar"
-			android:layout_width="match_parent"
-			android:layout_height="wrap_content"
-			android:layout_marginStart="8dp"
-			android:layout_marginTop="8dp"
-			android:layout_marginEnd="8dp"
-			android:hint="@string/search_placeholder"
-			app:layout_constraintEnd_toEndOf="parent"
-			app:layout_constraintStart_toStartOf="parent"
-			app:layout_constraintTop_toTopOf="parent"
-			app:navigationIcon="@drawable/menu"
-			app:useDrawerArrowDrawable="true" />
-	</com.google.android.material.appbar.AppBarLayout>
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="8dp"
+		android:layout_marginEnd="8dp"
+		android:hint="@string/search_placeholder"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"
+		app:navigationIcon="@drawable/menu"
+		app:useDrawerArrowDrawable="true" />
 
 	<com.google.android.material.search.SearchView
 		android:id="@+id/search_view"
 		android:layout_width="match_parent"
 		android:layout_height="match_parent"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"
+		app:layout_constraintBottom_toBottomOf="parent"
 		android:hint="@string/search_placeholder"
 		app:layout_anchor="@id/search_bar">
 
 		<androidx.recyclerview.widget.RecyclerView
 			android:id="@+id/suggestions_recycler"
 			android:layout_width="match_parent"
-			android:layout_height="match_parent"
-			app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+			android:layout_height="match_parent" />
 	</com.google.android.material.search.SearchView>
 
-	<androidx.constraintlayout.widget.ConstraintLayout
-		android:layout_width="match_parent"
-		android:layout_height="match_parent">
-
-		<ImageView
-			android:id="@+id/inari"
-			android:layout_width="0dp"
-			android:layout_height="0dp"
-			android:layout_marginStart="16dp"
-			android:layout_marginTop="64dp"
-			android:layout_marginEnd="16dp"
-			android:layout_marginBottom="16dp"
-			android:alpha="0.25"
-			android:src="@drawable/inari"
-			app:layout_constraintBottom_toBottomOf="parent"
-			app:layout_constraintEnd_toEndOf="parent"
-			app:layout_constraintStart_toStartOf="parent"
-			app:layout_constraintTop_toTopOf="parent"
-			tool:ignore="ContentDescription,ImageContrastCheck" />
+	<ImageView
+		android:id="@+id/inari"
+		android:layout_width="0dp"
+		android:layout_height="0dp"
+		android:layout_marginStart="16dp"
+		android:layout_marginEnd="16dp"
+		android:layout_marginTop="16dp"
+		android:layout_marginBottom="16dp"
+		android:alpha="0.25"
+		android:src="@drawable/inari"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"
+		tool:ignore="ContentDescription,ImageContrastCheck" />
 
-	</androidx.constraintlayout.widget.ConstraintLayout>
+	<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" />
 
 	<com.google.android.material.floatingactionbutton.FloatingActionButton
 		android:id="@+id/floating_action_button"
 		android:layout_width="wrap_content"
 		android:layout_height="wrap_content"
-		android:layout_gravity="bottom|end"
 		android:layout_margin="16dp"
 		android:contentDescription="@string/home_fab_description"
 		android:src="@drawable/gps_black"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent"
 		tool:ignore="ImageContrastCheck" />
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file
+</androidx.constraintlayout.widget.ConstraintLayout>
\ 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..5394c875cd6eb6152241dd20bf5b03e618c6ae4f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -126,5 +126,13 @@ 	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 next departures</string>
+	<string name="error_44">No more departures</string>
+	<string name="loading">Loading…</string>
 
 </resources>




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