Bimba.git

commit 83525b8c15e6d18f23e32ee2ba83f013a92368c3

Author: Adam <git@apiote.xyz>

split onboarding into simple/advanced and server then feeds

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


diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 6e86b57b4ced88504298a3b09380d33dd243e046..9df27e64cd94320f13c1b8e8bf9a32115899eaa9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -19,10 +19,20 @@ 		android:supportsRtl="true"
 		android:theme="@style/Theme.Bimba.Style"
 		tools:targetApi="31">
 		<activity
-			android:name=".feeds.FeedChooserActivity"
+			android:name=".settings.ServerChooserActivity"
+			android:exported="false">
+			<meta-data
+				android:name="android.app.lib_name"
+				android:value="" />
+		</activity>
+		<activity
+			android:name=".onboarding.OnboardingActivity">
+		</activity>
+		<activity
+			android:name=".settings.feeds.FeedChooserActivity"
 			android:exported="false" />
 		<activity
-			android:name=".FirstRunActivity"
+			android:name=".onboarding.FirstRunActivity"
 			android:exported="true"
 			android:theme="@style/Theme.Bimba.Splash">
 			<intent-filter>




diff --git a/app/src/main/java/ml/adamsprogs/bimba/FirstRunActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/FirstRunActivity.kt
deleted file mode 100644
index 7cd423c91d4152df677f57e69b27d07bb473d234..0000000000000000000000000000000000000000
--- a/app/src/main/java/ml/adamsprogs/bimba/FirstRunActivity.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package ml.adamsprogs.bimba
-
-import android.content.Intent
-import androidx.appcompat.app.AppCompatActivity
-import android.os.Bundle
-import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
-import ml.adamsprogs.bimba.dashboard.MainActivity
-import ml.adamsprogs.bimba.feeds.FeedChooserActivity
-
-class FirstRunActivity : AppCompatActivity() {
-	override fun onCreate(savedInstanceState: Bundle?) {
-		installSplashScreen()
-		super.onCreate(savedInstanceState)
-
-		val preferences = getSharedPreferences("shp", MODE_PRIVATE)
-		val intent = if (preferences.getBoolean("firstRun", true)) {
-			Intent(this, FeedChooserActivity::class.java)
-		} else {
-			Intent(this, MainActivity::class.java)
-		}
-		startActivity(intent)
-		finish()
-	}
-}
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt
index 99a5abc5900a4e81e2955d885f3eb2ae76032a02..b074d8b82c53f55f1ccadf3ae79987693d583418 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt
@@ -5,6 +5,7 @@ import android.graphics.*
 import android.graphics.drawable.BitmapDrawable
 import android.graphics.drawable.Drawable
 import android.graphics.drawable.LayerDrawable
+import android.text.format.DateFormat
 import android.text.format.DateUtils
 import androidx.appcompat.content.res.AppCompatResources
 import androidx.core.graphics.ColorUtils.HSLToColor
@@ -51,6 +52,10 @@ 	val offsetSign: Int,
 	val offsetH: Int,
 	val offsetM: Int
 ) {
+	fun toString(context: Context): String {
+		return DateFormat.getDateFormat(context).format(Date(year, month, day))
+	}
+
 	companion object {
 		fun of(s: String): DateTime {
 			val (oH, oM) = if (s[19] == 'Z') {




diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt
index 676af3a60b6b9d8c5d09ea3ab0c679246f640f5d..8a6042c6b82b03ac27a0bcba0dcf901bfbe9fca1 100644
--- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt
+++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt
@@ -10,6 +10,7 @@ import androidx.activity.result.ActivityResultLauncher
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.content.ContextCompat
+import androidx.core.content.edit
 import androidx.core.view.WindowCompat
 import androidx.core.view.get
 import androidx.fragment.app.Fragment
@@ -40,6 +41,10 @@ 	override fun onCreate(savedInstanceState: Bundle?) {
 		super.onCreate(savedInstanceState)
 		binding = ActivityMainBinding.inflate(layoutInflater)
 		setContentView(binding.root)
+
+		getSharedPreferences("shp", MODE_PRIVATE).edit(true) {
+			putBoolean("firstRun", false)
+		}
 
 		supportFragmentManager.registerFragmentLifecycleCallbacks(
 			object : FragmentLifecycleCallbacks() {




diff --git a/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedChooserActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedChooserActivity.kt
deleted file mode 100644
index 7b31fa6a0eaff1d039aa770a45e1cca311315c06..0000000000000000000000000000000000000000
--- a/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedChooserActivity.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-package ml.adamsprogs.bimba.feeds
-
-import android.content.Context
-import android.content.Intent
-import android.content.SharedPreferences
-import android.net.ConnectivityManager
-import androidx.appcompat.app.AppCompatActivity
-import android.os.Bundle
-import android.util.Log
-import android.view.View
-import androidx.core.content.edit
-import androidx.core.widget.addTextChangedListener
-import androidx.recyclerview.widget.LinearLayoutManager
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import ml.adamsprogs.bimba.R
-import ml.adamsprogs.bimba.api.FeedsResponse
-import ml.adamsprogs.bimba.api.FeedsSuccess
-import ml.adamsprogs.bimba.api.Server
-import ml.adamsprogs.bimba.api.getFeeds
-import ml.adamsprogs.bimba.dashboard.MainActivity
-import ml.adamsprogs.bimba.databinding.ActivityFeedChooserBinding
-import java.io.InputStream
-
-// todo split into server+token -> dialog if token not given and (rate limited or access denied), [3.1] check .well-known -> feeds choosing
-
-
-class FeedChooserActivity : AppCompatActivity() {
-	private var _binding: ActivityFeedChooserBinding? = null
-	private val binding get() = _binding!!
-
-	private lateinit var adapter: BimbaFeedInfoAdapter
-	private lateinit var preferences: SharedPreferences
-
-	override fun onCreate(savedInstanceState: Bundle?) {
-		super.onCreate(savedInstanceState)
-		_binding = ActivityFeedChooserBinding.inflate(layoutInflater)
-		setContentView(binding.root)
-
-		preferences = getSharedPreferences("shp", MODE_PRIVATE)
-
-		setUpRecycler()
-
-		binding.button.setOnClickListener {
-			getServer(
-				binding.serverField.editText!!.text.toString(),
-				binding.tokenField.editText!!.text.toString()
-			)
-		}
-
-		binding.serverField.editText!!.apply {
-			setText(preferences.getString("host", "bimba.apiote.xyz"))
-			addTextChangedListener { editable ->
-				binding.button.apply {
-					text = context.getString(R.string.cont)
-					isEnabled = !editable.isNullOrBlank()
-					setOnClickListener {
-						getServer(
-							editable!!.toString(),
-							binding.tokenField.editText!!.text.toString()
-						)
-					}
-				}
-			}
-		}
-	}
-
-	private fun setUpRecycler() {
-		binding.resultsRecycler.layoutManager = LinearLayoutManager(this)
-		adapter = BimbaFeedInfoAdapter(layoutInflater, listOf(), this) {
-			Log.v("FeedInfo", "clicked: $it")
-			// todo show bottom sheet with attribution and info
-		}
-		binding.resultsRecycler.adapter = adapter
-	}
-
-	private fun getServer(host: String, token: String) {
-		assert(binding.serverField.editText!!.text.isNotEmpty())
-
-		preferences.edit(true) {
-			putString("server", host)
-			putString("token", token)
-		}
-
-		binding.progress.visibility = View.VISIBLE
-		binding.resultsRecycler.visibility = View.GONE
-		binding.feedInfo.visibility = View.GONE
-
-		MainScope().launch {
-			val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
-			val feedsResult = getFeeds(cm, Server(host, token, ""))
-			val response = if (feedsResult.stream != null) {
-				unmarshallFeedsResponse(feedsResult.stream)
-			} else {
-				null
-			}
-			if (feedsResult.error != null) {
-				binding.feedInfo.text = getString(feedsResult.error.stringResource)
-				Log.w("FeedChooser", "$feedsResult")
-				Log.w("FeedChooser", "$response")
-			} else {
-				updateItems(response as FeedsSuccess)  // todo(error-handling) handle parsing error (not bimba server, wrong API version)
-				binding.button.apply {
-					text = context.getString(R.string.save)
-					setOnClickListener {
-						moveOn()
-					}
-				}
-			}
-		}
-	}
-
-	private fun moveOn() {
-		val intent = Intent(this, MainActivity::class.java)
-		startActivity(intent)
-		val wasFirstRun = preferences.getBoolean("firstRun", true)
-		preferences.edit(true) {
-			putBoolean("firstRun", false)
-			putString("server", binding.serverField.editText!!.text.toString())
-			putString("token", binding.tokenField.editText!!.text.toString())
-		}
-		if (wasFirstRun) {
-			finish()
-		}
-	}
-
-	private suspend fun unmarshallFeedsResponse(stream: InputStream): FeedsResponse {
-		return withContext(Dispatchers.IO) {
-			FeedsResponse.unmarshal(stream)
-		}
-	}
-
-	private fun updateItems(response: FeedsSuccess) {
-		binding.progress.visibility = View.GONE
-		binding.resultsRecycler.visibility = View.VISIBLE
-		binding.feedInfo.visibility = View.VISIBLE
-		binding.feedInfo.text = // todo(ui) remove after splitting
-			if (response.rateLimited) {
-				getString(R.string.server_info_rate_limited, response.adminContact)
-			} else {
-				getString(R.string.server_info_not_rate_limited, response.adminContact)
-			}
-
-		adapter.update(response.feeds)
-	}
-}
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedInfos.kt b/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedInfos.kt
deleted file mode 100644
index 34a1d1f84653bd4fdcc920d056a371d3f6d75842..0000000000000000000000000000000000000000
--- a/app/src/main/java/ml/adamsprogs/bimba/feeds/FeedInfos.kt
+++ /dev/null
@@ -1,79 +0,0 @@
-package ml.adamsprogs.bimba.feeds
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.Context.MODE_PRIVATE
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.TextView
-import androidx.core.content.edit
-import androidx.recyclerview.widget.RecyclerView
-import com.google.android.material.materialswitch.MaterialSwitch
-import ml.adamsprogs.bimba.R
-import ml.adamsprogs.bimba.api.FeedInfo
-
-
-class BimbaFeedInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
-	val root: View = itemView.findViewById(R.id.feed)
-	val switch: MaterialSwitch = itemView.findViewById(R.id.feed_switch)
-	val name: TextView = itemView.findViewById(R.id.feed_name)
-
-	companion object {
-		fun bind(
-			feed: FeedInfo,
-			context: Context,
-			holder: BimbaFeedInfoViewHolder?,
-			onClickListener: (FeedInfo) -> Unit
-		) {
-			val shp = context.getSharedPreferences("shp", MODE_PRIVATE)
-			val host = shp.getString("host", "bimba.apiote.xyz")!!
-			val enabledFeeds =
-				shp.getString("${host}_feeds", "")!!.split(",").associateWith { }.toMutableMap()
-
-			holder?.root?.setOnClickListener {
-				onClickListener(feed)
-			}
-			holder?.name?.text = feed.name
-			holder?.switch?.apply {
-				isChecked = feed.id in enabledFeeds
-				setOnCheckedChangeListener { _, isChecked ->
-					if (isChecked) {
-						enabledFeeds[feed.id] = Unit
-					} else {
-						enabledFeeds.remove(feed.id)
-					}
-					shp.edit(true) {
-						putString("${host}_feeds", enabledFeeds.map { it.key }.filter { it != "" }.joinToString(separator = ","))
-					}
-				}
-			}
-		}
-	}
-}
-
-class BimbaFeedInfoAdapter(
-	private val inflater: LayoutInflater,
-	private var feeds: List<FeedInfo>,
-	private val context: Context,
-	private val onClickListener: ((FeedInfo) -> Unit)
-) :
-	RecyclerView.Adapter<BimbaFeedInfoViewHolder>() {
-	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaFeedInfoViewHolder {
-		val rowView = inflater.inflate(R.layout.feedinfo, parent, false)
-		return BimbaFeedInfoViewHolder(rowView)
-	}
-
-	override fun onBindViewHolder(holder: BimbaFeedInfoViewHolder, position: Int) {
-		BimbaFeedInfoViewHolder.bind(feeds[position], context, holder, onClickListener)
-	}
-
-	override fun getItemCount(): Int = feeds.size
-
-	@SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil
-	fun update(items: List<FeedInfo>) {
-		feeds = items
-		notifyDataSetChanged()
-	}
-
-}
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/onboarding/FirstRunActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/onboarding/FirstRunActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b289c3268b2254d98093af0186d65d5613160f80
--- /dev/null
+++ b/app/src/main/java/ml/adamsprogs/bimba/onboarding/FirstRunActivity.kt
@@ -0,0 +1,23 @@
+package ml.adamsprogs.bimba.onboarding
+
+import android.content.Intent
+import androidx.appcompat.app.AppCompatActivity
+import android.os.Bundle
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import ml.adamsprogs.bimba.dashboard.MainActivity
+
+class FirstRunActivity : AppCompatActivity() {
+	override fun onCreate(savedInstanceState: Bundle?) {
+		installSplashScreen()
+		super.onCreate(savedInstanceState)
+
+		val preferences = getSharedPreferences("shp", MODE_PRIVATE)
+		val intent = if (preferences.getBoolean("firstRun", true)) {
+			Intent(this, OnboardingActivity::class.java)
+		} else {
+			Intent(this, MainActivity::class.java)
+		}
+		startActivity(intent)
+		finish()
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/onboarding/OnboardingActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/onboarding/OnboardingActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f49ba2470f879f08cb2bdea516719d1aa3f83565
--- /dev/null
+++ b/app/src/main/java/ml/adamsprogs/bimba/onboarding/OnboardingActivity.kt
@@ -0,0 +1,73 @@
+package ml.adamsprogs.bimba.onboarding
+
+import android.content.Intent
+import android.graphics.Typeface
+import android.os.Bundle
+import android.text.Spannable
+import android.text.SpannableStringBuilder
+import android.text.style.RelativeSizeSpan
+import android.text.style.StyleSpan
+import android.widget.Button
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import ml.adamsprogs.bimba.R
+import ml.adamsprogs.bimba.databinding.ActivityOnboardingBinding
+import ml.adamsprogs.bimba.settings.ServerChooserActivity
+
+class OnboardingActivity : AppCompatActivity() {
+	private var _binding: ActivityOnboardingBinding? = null
+	private val binding get() = _binding!!
+
+	private val activityLauncher =
+		registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+			if (!getSharedPreferences("shp", MODE_PRIVATE).getBoolean("firstRun", true)) {
+				finish()
+			}
+		}
+
+	override fun onCreate(savedInstanceState: Bundle?) {
+		super.onCreate(savedInstanceState)
+		_binding = ActivityOnboardingBinding.inflate(layoutInflater)
+		setContentView(binding.root)
+
+
+		prepareButton(
+			binding.buttonSimple,
+			getString(R.string.onboarding_simple),
+			getString(R.string.onboarding_simple_action),
+			true
+		)
+		prepareButton(
+			binding.buttonAdvanced,
+			getString(R.string.onboarding_advanced),
+			getString(R.string.onboarding_advanced_action),
+			false
+		)
+	}
+
+	private fun prepareButton(button: Button, title: String, description: String, simple: Boolean) {
+		button.text = SpannableStringBuilder().apply {
+			append(
+				title,
+				StyleSpan(Typeface.BOLD),
+				Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
+			)
+			append("\n")
+			append(
+				description,
+				RelativeSizeSpan(.75f),
+				Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
+			)
+		}
+		button.setOnClickListener {
+			moveOn(simple)
+		}
+	}
+
+	private fun moveOn(simple: Boolean) {
+		val intent = Intent(this, ServerChooserActivity::class.java).apply {
+			putExtra("simple", simple)
+		}
+		activityLauncher.launch(intent)
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/settings/ServerChooserActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/settings/ServerChooserActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9b66339ee929d6672d077cfdf83188b16f1e833b
--- /dev/null
+++ b/app/src/main/java/ml/adamsprogs/bimba/settings/ServerChooserActivity.kt
@@ -0,0 +1,137 @@
+package ml.adamsprogs.bimba.settings
+
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.net.ConnectivityManager
+import androidx.appcompat.app.AppCompatActivity
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.content.res.AppCompatResources
+import androidx.core.content.edit
+import androidx.core.widget.addTextChangedListener
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import ml.adamsprogs.bimba.R
+import ml.adamsprogs.bimba.api.FeedsResponse
+import ml.adamsprogs.bimba.api.FeedsSuccess
+import ml.adamsprogs.bimba.api.Server
+import ml.adamsprogs.bimba.api.getFeeds
+import ml.adamsprogs.bimba.databinding.ActivityServerChooserBinding
+import ml.adamsprogs.bimba.settings.feeds.FeedChooserActivity
+
+class ServerChooserActivity : AppCompatActivity() {
+	private var _binding: ActivityServerChooserBinding? = null
+	private val binding get() = _binding!!
+
+	private val activityLauncher =
+		registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+			if (!preferences.getBoolean("inFeedsTransaction", true)) {
+				finish()
+			}
+		}
+
+	private lateinit var preferences: SharedPreferences
+
+	override fun onCreate(savedInstanceState: Bundle?) {
+		super.onCreate(savedInstanceState)
+		preferences = getSharedPreferences("shp", MODE_PRIVATE)
+
+		if (intent.getBooleanExtra("simple", false)) {
+			setServer("bimba.apiote.xyz", "")
+			runFeedsActivity()
+			finish()
+		}
+
+		_binding = ActivityServerChooserBinding.inflate(layoutInflater)
+		setContentView(binding.root)
+
+		preferences.edit(true){
+			putBoolean("inFeedsTransaction", true)
+		}
+
+		binding.button.isEnabled = false
+		binding.serverField.editText!!.addTextChangedListener { editable ->
+			binding.button.isEnabled = !editable.isNullOrBlank()
+		}
+
+		binding.button.setOnClickListener {
+			setServer(
+				binding.serverField.editText!!.text.toString(),
+				binding.tokenField.editText!!.text.toString()
+			)
+			checkServer()
+		}
+	}
+
+	private fun showDialog(
+		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 {
+				if (onPositive != null) {
+					setPositiveButton(resources.getString(R.string.cont)) { _, _ ->
+						onPositive()
+					}
+				}
+			}
+			.show()
+	}
+
+	private fun checkServer() {
+		// todo [api-freeze] check is really bimba (/.well-known/bimba)
+		MainScope().launch {
+			val result = getFeeds(
+				getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
+				Server.get(this@ServerChooserActivity)
+			)
+			if (result.error != null) {
+				Log.w("ServerChooser", "$result")
+				showDialog(R.string.error, result.error.stringResource, result.error.imageResource, null)
+				return@launch
+			}
+
+			val response = withContext(Dispatchers.IO) {
+				FeedsResponse.unmarshal(result.stream!!)
+			}
+			if (response is FeedsSuccess) {
+				val token = preferences.getString("token", "")
+				if (response.rateLimited && token == "") {
+					showDialog(R.string.rate_limit, R.string.server_rate_limited_question, R.drawable.error_limit) {
+						runFeedsActivity()
+					}
+					return@launch
+				}
+				if (response.private && token == "") {
+					showDialog(R.string.error, R.string.server_private_question, R.drawable.error_sec, null)
+					return@launch
+				}
+				runFeedsActivity()
+			} else {
+				// todo error handling
+				// todo [api-freeze] check api version
+			}
+		}
+	}
+
+	private fun setServer(hostname: String, token: String) {
+		preferences.edit(true) {
+			putString("host", hostname)
+			putString("token", token)
+		}
+	}
+
+	private fun runFeedsActivity() {
+		activityLauncher.launch(Intent(this, FeedChooserActivity::class.java))
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedChooserActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedChooserActivity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d3527ffcc914b5ed7fb8e9cdd1989b7218849aad
--- /dev/null
+++ b/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedChooserActivity.kt
@@ -0,0 +1,89 @@
+package ml.adamsprogs.bimba.settings.feeds
+
+import android.content.Context
+import android.content.Intent
+import android.net.ConnectivityManager
+import androidx.appcompat.app.AppCompatActivity
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import androidx.core.content.edit
+import androidx.recyclerview.widget.LinearLayoutManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import ml.adamsprogs.bimba.api.FeedsResponse
+import ml.adamsprogs.bimba.api.FeedsSuccess
+import ml.adamsprogs.bimba.api.Server
+import ml.adamsprogs.bimba.api.getFeeds
+import ml.adamsprogs.bimba.dashboard.MainActivity
+import ml.adamsprogs.bimba.databinding.ActivityFeedChooserBinding
+
+class FeedChooserActivity : AppCompatActivity() {
+	private var _binding: ActivityFeedChooserBinding? = null
+	private val binding get() = _binding!!
+
+	private lateinit var adapter: BimbaFeedInfoAdapter
+
+	override fun onCreate(savedInstanceState: Bundle?) {
+		super.onCreate(savedInstanceState)
+		_binding = ActivityFeedChooserBinding.inflate(layoutInflater)
+		setContentView(binding.root)
+
+		setUpRecycler()
+		getServer()
+
+		binding.button.setOnClickListener {
+			moveOn()
+		}
+	}
+
+	private fun setUpRecycler() {
+		binding.resultsRecycler.layoutManager = LinearLayoutManager(this)
+		adapter = BimbaFeedInfoAdapter(layoutInflater, listOf(), this) {
+			FeedBottomSheet(it).show(supportFragmentManager, FeedBottomSheet.TAG)
+		}
+		binding.resultsRecycler.adapter = adapter
+	}
+
+	private fun getServer() {
+		binding.progress.visibility = View.VISIBLE
+		binding.resultsRecycler.visibility = View.GONE
+
+		MainScope().launch {
+			val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+			val feedsResult = getFeeds(cm, Server.get(this@FeedChooserActivity))
+			if (feedsResult.error != null) {
+				Log.w("FeedChooser", "$feedsResult")
+				return@launch
+			}
+			val response = withContext(Dispatchers.IO) {
+				FeedsResponse.unmarshal(feedsResult.stream!!)
+			}
+			if (response is FeedsSuccess) {
+				updateItems(response)
+			} else {
+				// todo error handling
+			}
+		}
+	}
+
+	private fun moveOn() {
+		val preferences = getSharedPreferences("shp", MODE_PRIVATE)
+		preferences.edit(true) {
+			putBoolean("inFeedsTransaction", false)
+		}
+		if (preferences.getBoolean("firstRun", true)) {
+			val intent = Intent(this, MainActivity::class.java)
+			startActivity(intent)
+		}
+		finish()
+	}
+
+	private fun updateItems(response: FeedsSuccess) {
+		binding.progress.visibility = View.GONE
+		binding.resultsRecycler.visibility = View.VISIBLE
+		adapter.update(response.feeds)
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedInfos.kt b/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedInfos.kt
new file mode 100644
index 0000000000000000000000000000000000000000..380ad2a4060a5ab92be796789ebacc837ea14be1
--- /dev/null
+++ b/app/src/main/java/ml/adamsprogs/bimba/settings/feeds/FeedInfos.kt
@@ -0,0 +1,107 @@
+package ml.adamsprogs.bimba.settings.feeds
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Context.MODE_PRIVATE
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.core.content.edit
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
+import com.google.android.material.materialswitch.MaterialSwitch
+import ml.adamsprogs.bimba.R
+import ml.adamsprogs.bimba.api.FeedInfo
+
+
+class BimbaFeedInfoViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+	val root: View = itemView.findViewById(R.id.feed)
+	val switch: MaterialSwitch = itemView.findViewById(R.id.feed_switch)
+	val name: TextView = itemView.findViewById(R.id.feed_name)
+
+	companion object {
+		fun bind(
+			feed: FeedInfo,
+			context: Context,
+			holder: BimbaFeedInfoViewHolder?,
+			onClickListener: (FeedInfo) -> Unit
+		) {
+			val shp = context.getSharedPreferences("shp", MODE_PRIVATE)
+			val host = shp.getString("host", "bimba.apiote.xyz")!!
+			val enabledFeeds =
+				shp.getString("${host}_feeds", "")!!.split(",").associateWith { }.toMutableMap()
+
+			holder?.root?.setOnClickListener {
+				onClickListener(feed)
+			}
+			holder?.name?.text = feed.name
+			holder?.switch?.apply {
+				isChecked = feed.id in enabledFeeds
+				setOnCheckedChangeListener { _, isChecked ->
+					if (isChecked) {
+						enabledFeeds[feed.id] = Unit
+					} else {
+						enabledFeeds.remove(feed.id)
+					}
+					shp.edit(true) {
+						putString(
+							"${host}_feeds",
+							enabledFeeds.map { it.key }.filter { it != "" }.joinToString(separator = ",")
+						)
+					}
+				}
+			}
+		}
+	}
+}
+
+class BimbaFeedInfoAdapter(
+	private val inflater: LayoutInflater,
+	private var feeds: List<FeedInfo>,
+	private val context: Context,
+	private val onClickListener: ((FeedInfo) -> Unit)
+) :
+	RecyclerView.Adapter<BimbaFeedInfoViewHolder>() {
+	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BimbaFeedInfoViewHolder {
+		val rowView = inflater.inflate(R.layout.feedinfo, parent, false)
+		return BimbaFeedInfoViewHolder(rowView)
+	}
+
+	override fun onBindViewHolder(holder: BimbaFeedInfoViewHolder, position: Int) {
+		BimbaFeedInfoViewHolder.bind(feeds[position], context, holder, onClickListener)
+	}
+
+	override fun getItemCount(): Int = feeds.size
+
+	@SuppressLint("NotifyDataSetChanged") // todo [3.1] DiffUtil
+	fun update(items: List<FeedInfo>) {
+		feeds = items
+		notifyDataSetChanged()
+	}
+}
+
+class FeedBottomSheet(private var feed: FeedInfo) : BottomSheetDialogFragment() {
+	companion object {
+		const val TAG = "DepartureBottomSheet"
+	}
+
+	override fun onCreateView(
+		inflater: LayoutInflater,
+		container: ViewGroup?,
+		savedInstanceState: Bundle?
+	): View {
+		val content = inflater.inflate(R.layout.feed_bottom_sheet, container, false)
+		content.findViewById<TextView>(R.id.title).text = feed.name
+		content.findViewById<TextView>(R.id.description).text = feed.description
+		content.findViewById<TextView>(R.id.attribution).text = feed.attribution
+		content.findViewById<TextView>(R.id.update_time).text =
+			getString(R.string.last_update, if (context != null) {
+				feed.lastUpdate.toString(requireContext())
+			} else {
+				feed.lastUpdate.let { "${it.year}-${it.month}-${it.day}" }
+			})
+		return content
+	}
+}
\ No newline at end of file




diff --git a/app/src/main/res/layout/activity_feed_chooser.xml b/app/src/main/res/layout/activity_feed_chooser.xml
index 8d8a85d5706a3ddc0ecc787289988d9ffc1ead1f..7a6ba5a4d211a0a4e4760be337cd55b20e930879 100644
--- a/app/src/main/res/layout/activity_feed_chooser.xml
+++ b/app/src/main/res/layout/activity_feed_chooser.xml
@@ -4,60 +4,7 @@ 	xmlns:app="http://schemas.android.com/apk/res-auto"
 	xmlns:tools="http://schemas.android.com/tools"
 	android:layout_width="match_parent"
 	android:layout_height="match_parent"
-	tools:context=".feeds.FeedChooserActivity">
-
-	<com.google.android.material.textfield.TextInputLayout
-		android:id="@+id/server_field"
-		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="16dp"
-		android:layout_marginTop="16dp"
-		android:layout_marginEnd="16dp"
-		android:hint="@string/bimba_server_address_hint"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toTopOf="parent">
-
-		<com.google.android.material.textfield.TextInputEditText
-			android:layout_width="match_parent"
-			android:layout_height="wrap_content" />
-
-	</com.google.android.material.textfield.TextInputLayout>
-
-	<com.google.android.material.textfield.TextInputLayout
-		android:id="@+id/token_field"
-		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="16dp"
-		android:layout_marginTop="16dp"
-		android:layout_marginEnd="16dp"
-		android:hint="@string/bimba_server_token_hint"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@id/server_field">
-
-		<com.google.android.material.textfield.TextInputEditText
-			android:layout_width="match_parent"
-			android:layout_height="wrap_content" />
-
-	</com.google.android.material.textfield.TextInputLayout>
-
-	<Button
-		android:id="@+id/button"
-		android:layout_width="wrap_content"
-		android:layout_height="wrap_content"
-		android:layout_marginTop="16dp"
-		android:text="@string/bimba_server_continue_button"
-		app:layout_constraintEnd_toEndOf="@+id/token_field"
-		app:layout_constraintStart_toStartOf="@+id/token_field"
-		app:layout_constraintTop_toBottomOf="@+id/token_field" />
-
-	<com.google.android.material.divider.MaterialDivider
-		android:id="@+id/divider"
-		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		android:layout_marginTop="16dp"
-		app:layout_constraintTop_toBottomOf="@+id/button" />
+	tools:context=".settings.feeds.FeedChooserActivity">
 
 	<com.google.android.material.progressindicator.CircularProgressIndicator
 		android:id="@+id/progress"
@@ -68,33 +15,29 @@ 		android:visibility="gone"
 		app:layout_constraintBottom_toBottomOf="parent"
 		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/divider" />
-
-	<com.google.android.material.textview.MaterialTextView
-		android:id="@+id/feed_info"
-		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
-		android:layout_marginStart="8dp"
-		android:layout_marginTop="16dp"
-		android:layout_marginEnd="8dp"
-		android:autoLink="web|email"
-		android:text=""
-		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
-		android:visibility="gone"
-		app:layout_constraintEnd_toEndOf="parent"
-		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/divider" />
+		app:layout_constraintTop_toTopOf="parent" />
 
 	<androidx.recyclerview.widget.RecyclerView
 		android:id="@+id/results_recycler"
 		android:layout_width="match_parent"
-		android:layout_height="wrap_content"
+		android:layout_height="0dp"
 		android:layout_marginStart="8dp"
 		android:layout_marginTop="8dp"
 		android:layout_marginEnd="8dp"
-		android:visibility="gone"
+		android:layout_marginBottom="8dp"
 		app:layout_behavior="@string/appbar_scrolling_view_behavior"
+		app:layout_constraintBottom_toTopOf="@+id/button"
 		app:layout_constraintEnd_toEndOf="parent"
 		app:layout_constraintStart_toStartOf="parent"
-		app:layout_constraintTop_toBottomOf="@+id/feed_info" />
+		app:layout_constraintTop_toTopOf="parent" />
+
+	<Button
+		android:id="@+id/button"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginEnd="16dp"
+		android:layout_marginBottom="16dp"
+		android:text="@string/save"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent" />
 </androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml
new file mode 100644
index 0000000000000000000000000000000000000000..15b97bbb6b05d7328e0d71068d0d5c6fb412d81d
--- /dev/null
+++ b/app/src/main/res/layout/activity_onboarding.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent"
+	tools:context=".onboarding.OnboardingActivity">
+
+	<androidx.constraintlayout.widget.Guideline
+		android:id="@+id/guideline2"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:orientation="horizontal"
+		app:layout_constraintGuide_percent=".125" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginEnd="8dp"
+		android:text="@string/onboarding_question"
+		android:textAlignment="center"
+		android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="@+id/guideline2" />
+
+	<Button
+		android:id="@+id/button_simple"
+		style="?attr/materialButtonOutlinedStyle"
+		android:layout_width="200dp"
+		android:layout_height="100dp"
+		android:layout_marginBottom="16dp"
+		app:cornerRadius="10dp"
+		app:layout_constraintBottom_toTopOf="@+id/guideline"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent" />
+
+	<androidx.constraintlayout.widget.Guideline
+		android:id="@+id/guideline"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:orientation="horizontal"
+		app:layout_constraintGuide_percent=".5" />
+
+	<Button
+		android:id="@+id/button_advanced"
+		style="?attr/materialButtonOutlinedStyle"
+		android:layout_width="200dp"
+		android:layout_height="100dp"
+		android:layout_marginTop="16dp"
+		app:cornerRadius="10dp"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/guideline" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginEnd="8dp"
+		android:layout_marginBottom="16dp"
+		android:text="@string/seatbelts_everyone"
+		android:textAlignment="center"
+		android:textAppearance="@style/TextAppearance.Material3.BodySmall"
+		android:textStyle="italic"
+		app:layout_constraintBottom_toBottomOf="parent"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent" />
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/activity_server_chooser.xml b/app/src/main/res/layout/activity_server_chooser.xml
new file mode 100644
index 0000000000000000000000000000000000000000..125c84dbbf76f24c4616ccd63ffd235340216fb6
--- /dev/null
+++ b/app/src/main/res/layout/activity_server_chooser.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tools="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent"
+	tools:context=".settings.ServerChooserActivity">
+
+	<com.google.android.material.textfield.TextInputLayout
+		android:id="@+id/server_field"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="16dp"
+		android:layout_marginTop="16dp"
+		android:layout_marginEnd="16dp"
+		android:hint="@string/bimba_server_address_hint"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent">
+
+		<com.google.android.material.textfield.TextInputEditText
+			android:layout_width="match_parent"
+			android:layout_height="wrap_content" />
+
+	</com.google.android.material.textfield.TextInputLayout>
+
+	<com.google.android.material.textfield.TextInputLayout
+		android:id="@+id/token_field"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="16dp"
+		android:layout_marginTop="16dp"
+		android:layout_marginEnd="16dp"
+		android:hint="@string/bimba_server_token_hint"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@id/server_field">
+
+		<com.google.android.material.textfield.TextInputEditText
+			android:layout_width="match_parent"
+			android:layout_height="wrap_content" />
+
+	</com.google.android.material.textfield.TextInputLayout>
+
+	<Button
+		android:id="@+id/button"
+		android:layout_width="wrap_content"
+		android:layout_height="wrap_content"
+		android:layout_marginTop="16dp"
+		android:text="@string/bimba_server_continue_button"
+		app:layout_constraintEnd_toEndOf="@+id/token_field"
+		app:layout_constraintStart_toStartOf="@+id/token_field"
+		app:layout_constraintTop_toBottomOf="@+id/token_field" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file




diff --git a/app/src/main/res/layout/feed_bottom_sheet.xml b/app/src/main/res/layout/feed_bottom_sheet.xml
new file mode 100644
index 0000000000000000000000000000000000000000..41e7751df918a60fbb5f4f62925ae1705581ca86
--- /dev/null
+++ b/app/src/main/res/layout/feed_bottom_sheet.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto"
+	xmlns:tool="http://schemas.android.com/tools"
+	android:layout_width="match_parent"
+	android:layout_height="match_parent"
+	android:paddingBottom="16dp">
+
+	<com.google.android.material.bottomsheet.BottomSheetDragHandleView
+		android:id="@+id/drag_handle"
+		android:layout_width="match_parent"
+		android:layout_height="wrap_content"
+		app:layout_constraintTop_toTopOf="parent" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/title"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="48dp"
+		android:layout_marginEnd="8dp"
+		android:textAlignment="center"
+		android:textAppearance="@style/TextAppearance.Material3.DisplaySmall"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toTopOf="parent"
+		tool:text="Poznań ZTM" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/description"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="16dp"
+		android:layout_marginEnd="8dp"
+		android:textAlignment="center"
+		android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/title"
+		tool:text="Feed for Poznań" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/attribution"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="16dp"
+		android:layout_marginEnd="8dp"
+		android:textAlignment="center"
+		android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/description"
+		tool:text="(c) Poznań" />
+
+	<com.google.android.material.textview.MaterialTextView
+		android:id="@+id/update_time"
+		android:layout_width="0dp"
+		android:layout_height="wrap_content"
+		android:layout_marginStart="8dp"
+		android:layout_marginTop="16dp"
+		android:layout_marginEnd="8dp"
+		android:textAlignment="center"
+		android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
+		app:layout_constraintEnd_toEndOf="parent"
+		app:layout_constraintStart_toStartOf="parent"
+		app:layout_constraintTop_toBottomOf="@+id/attribution"
+		tool:text="Last update: 2023-01-01" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file




diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c20bcf31f1e57cb276fd5864b46b8265e8dcfe62..5195ff82dbe7883a66e34b3aeaa4a6ef63888584 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -69,10 +69,16 @@ 	USB charging
 	<string name="show_departures">Show departures</string>
 	<string name="open_in_maps_app">Open in maps app</string>
 	<string name="stop_content_description">stop</string>
-	<string name="seatbelts_everyone">Seatbelts, everyone!</string>
+	<string name="seatbelts_everyone">Seatbelts, everyone!</string> <!-- taken from ‘Magic School Bus’. Should be translated like in the series -->
 	<string name="onboarding_question">How would you like to start?</string>
 	<string name="onboarding_simple">Simple</string>
 	<string name="onboarding_simple_action">choose cities</string>
 	<string name="onboarding_advanced">Advanced</string>
-	<string name="onboarding_simple_advanced">choose server</string> <!-- taken from ‘Magic School Bus’. Should be translated like in the series -->
+	<string name="onboarding_advanced_action">choose server</string>
+	<string name="cancel">Cancel</string>
+	<string name="error">Error</string>
+	<string name="rate_limit">Server is rate-limited</string>
+	<string name="server_rate_limited_question">This server is rate-limited and no token was given. Do you want to continue?</string>
+	<string name="server_private_question">This server is private and no token was given</string>
+	<string name="last_update">Last update: %s</string>
 </resources>
\ No newline at end of file