Bimba.git

commit b248b0da5a8612b1a1ce081347164d71c9fb9cda

Author: Adam <git@apiote.xyz>

search by position and related concepts

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


diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/DashboardViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/DashboardViewModel.kt
index b06ba0824486a44e7fa53987d5a7a55a6bc0b50d..6acbe27969f244d5917f458f5f24867189b522df 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/DashboardViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/DashboardViewModel.kt
@@ -7,8 +7,10 @@
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.ViewModel
+import com.google.android.material.chip.Chip
 import com.google.android.material.textfield.TextInputEditText
 import xyz.apiote.bimba.czwek.repo.Place
+import xyz.apiote.bimba.czwek.search.Query
 
 class DashboardViewModel : ViewModel() {
 	companion object {
@@ -35,9 +37,23 @@ 	fun set(source: String, place: Place) {
 		mutableData[source]!!.value = place
 	}
 
+	fun unset(source: String) {
+		mutableData[source]!!.value = null
+	}
+
 	val spans = mutableMapOf(
 		ORIGIN_KEY to "",
 		DEST_KEY to ""
+	)
+
+	val positionChips = mutableMapOf<String, Chip?>(
+		ORIGIN_KEY to null,
+		DEST_KEY to null
+	)
+
+	val positionQueries = mutableMapOf<String, Query?>(
+		ORIGIN_KEY to null,
+		DEST_KEY to null
 	)
 
 	val textInputs = mutableMapOf<String, TextInputEditText>()




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 6e848a144af2c35c587c4347558d324745536f14..214295e7e85b1689cee3619b321d67ae940e4642 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
@@ -42,6 +42,7 @@ import xyz.apiote.bimba.czwek.dashboard.ui.journey.JourneyFragment
 import xyz.apiote.bimba.czwek.dashboard.ui.map.MapFragment
 import xyz.apiote.bimba.czwek.databinding.ActivityMainBinding
 import xyz.apiote.bimba.czwek.onboarding.FirstRunActivity
+import xyz.apiote.bimba.czwek.search.Query
 import xyz.apiote.bimba.czwek.search.ResultsActivity
 import xyz.apiote.bimba.czwek.settings.DownloadCitiesWorker
 import xyz.apiote.bimba.czwek.settings.ServerChooserActivity
@@ -143,7 +144,7 @@ 				permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false ||
 					permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false -> {
 					when (permissionAsker) {
 						is HomeFragment -> {
-							showResults(ResultsActivity.Mode.MODE_LOCATION)
+							showResults(Query(Query.Mode.LOCATION))
 						}
 
 						is MapFragment -> {
@@ -205,8 +206,7 @@ 				Manifest.permission.ACCESS_COARSE_LOCATION
 			) -> {
 				when (fragment) {
 					is HomeFragment -> {
-						// TODO make Query class and use also in journey
-						showResults(ResultsActivity.Mode.MODE_LOCATION)
+						showResults(Query(Query.Mode.LOCATION))
 					}
 
 					is MapFragment -> {
@@ -233,20 +233,8 @@ 			}
 		}
 	}
 
-	fun onSearchClicked(text: CharSequence?) {
-		// TODO make Query class and use also in journey
-		if (OpenLocationCode.isValidCode(text.toString())) {
-			val olc = OpenLocationCode(text.toString())
-			if (!olc.isFull) {
-				showResults(ResultsActivity.Mode.MODE_SHORT_CODE_LOCATION, text.toString())
-			} else {
-				val area = olc.decode()
-				showResults(olc.code, area.centerLatitude, area.centerLongitude)
-			}
-		} else if (OpenLocationCode.isValidCode(
-				text.toString().trim().split(" ").first().trim(',').trim()
-			)
-		) {
+	fun onSearchClicked(text: CharSequence) {
+		if (OpenLocationCode.isValidCode(text.toString().trim().split(" ").first().trim(',').trim())) {
 			if (PreferenceManager.getDefaultSharedPreferences(applicationContext)
 					.getLong(DownloadCitiesWorker.LAST_UPDATE_KEY, -1) < 0
 			) {
@@ -258,46 +246,29 @@ 						.setIcon(R.drawable.geocoding)
 						.setTitle(R.string.no_geocoding_data)
 						.setMessage(R.string.no_geocoding_data_description)
 						.setPositiveButton(R.string.ok) { _, _ ->
-							showResults(
-								ResultsActivity.Mode.MODE_SEARCH,
-								text.toString()
-							)
+							showResults(Query(text.toString(), Query.Mode.NAME))
 						}
 						.show()
 					PreferenceManager.getDefaultSharedPreferences(applicationContext).edit {
 						putBoolean(NO_GEOCODING_DATA_SHOWN, true)
 					}
+				} else {
+					showResults(Query(text.toString(), Query.Mode.NAME))
 				}
 			} else {
-				showResults(ResultsActivity.Mode.MODE_SHORT_CODE, text.toString())
+				showResults(Query(text.toString()))
 			}
 		} else {
-			showResults(ResultsActivity.Mode.MODE_SEARCH, text.toString())
+			showResults(Query(text.toString()))
 		}
 	}
 
-	private fun showResults(query: String, centerLatitude: Double, centerLongitude: Double) {
-		/* todo [3.2] (ux,low) animation
-			https://developer.android.com/guide/fragments/animate
-			https://github.com/raheemadamboev/fab-explosion-animation-app
-		*/
-		startActivity(
-			ResultsActivity.getIntent(
-				this,
-				ResultsActivity.Mode.MODE_POSITION,
-				query,
-				centerLatitude,
-				centerLongitude
-			)
-		)
-	}
-
-	private fun showResults(mode: ResultsActivity.Mode, query: String = "") {
+	private fun showResults(query: Query) {
 		/* todo [3.2] (ux,low) animation
 			https://developer.android.com/guide/fragments/animate
 			https://github.com/raheemadamboev/fab-explosion-animation-app
 		*/
-		startActivity(ResultsActivity.getIntent(this, mode, query))
+		startActivity(ResultsActivity.getIntent(this, query))
 	}
 
 	fun showBadge(complete: Boolean = false) {




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/journey/JourneyFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/journey/JourneyFragment.kt
index 1f7d529564f043535e3c99c29992875981811f89..3b7fe0587642546d30be3ebd49b23b9aec4bc173 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/journey/JourneyFragment.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/journey/JourneyFragment.kt
@@ -30,8 +30,11 @@ import androidx.core.view.updateLayoutParams
 import androidx.core.view.updatePadding
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.ViewModelProvider
+import androidx.swiperefreshlayout.widget.CircularProgressDrawable
+import com.google.android.material.chip.Chip
 import com.google.android.material.chip.ChipDrawable
-import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.chip.ChipGroup
+import com.google.openlocationcode.OpenLocationCode
 import xyz.apiote.bimba.czwek.R
 import xyz.apiote.bimba.czwek.dashboard.DashboardViewModel
 import xyz.apiote.bimba.czwek.dashboard.MainActivity
@@ -39,6 +42,9 @@ import xyz.apiote.bimba.czwek.databinding.FragmentJourneyBinding
 import xyz.apiote.bimba.czwek.dpToPixelI
 import xyz.apiote.bimba.czwek.journeys.JourneysActivity
 import xyz.apiote.bimba.czwek.repo.Place
+import xyz.apiote.bimba.czwek.repo.Position
+import xyz.apiote.bimba.czwek.search.Query
+import xyz.apiote.bimba.czwek.search.Query.Mode
 import xyz.apiote.bimba.czwek.search.ResultsActivity
 
 class JourneyFragment : Fragment(), LocationListener {
@@ -50,8 +56,7 @@ 	private var _binding: FragmentJourneyBinding? = null
 	private val binding get() = _binding!!
 
 	private lateinit var dashboard: MainActivity
-	private var hereChipRequester: String? = null
-	private var searchRequester: String? = null
+	private lateinit var viewModel: JourneyViewModel
 
 	private val activityLauncher =
 		registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
@@ -62,7 +67,7 @@ 				@Suppress("DEPRECATION")
 				it.data?.extras?.getParcelable(PLACE_KEY)
 			}
 			place?.let {
-				dashboard.viewModel.set(searchRequester!!, it)
+				dashboard.viewModel.set(viewModel.searchRequester!!, it)
 			}
 		}
 
@@ -71,13 +76,14 @@ 		inflater: LayoutInflater,
 		container: ViewGroup?,
 		savedInstanceState: Bundle?
 	): View {
-		val journeyViewModel =
-			ViewModelProvider(this)[JourneyViewModel::class.java]
+		viewModel = ViewModelProvider(this)[JourneyViewModel::class.java]
 
 		// TODO separate layout: two columns for horizontal
 
 		_binding = FragmentJourneyBinding.inflate(inflater, container, false)
 		val root: View = binding.root
+
+		dashboard = activity as MainActivity
 
 		dashboard.viewModel.textInputs[DashboardViewModel.ORIGIN_KEY] = binding.origin
 		dashboard.viewModel.textInputs[DashboardViewModel.DEST_KEY] = binding.destination
@@ -124,17 +130,17 @@ 			}
 			windowInsets
 		}
 
-		dashboard = activity as MainActivity
-
 		dashboard.hideBadge()
 		chipifyOrigin(dashboard.viewModel.data[DashboardViewModel.ORIGIN_KEY]!!.value)
 		chipifyDestination(dashboard.viewModel.data[DashboardViewModel.DEST_KEY]!!.value)
 
 		binding.originChipHere.setOnClickListener {
+			setChipProgress(binding.originChipHere)
 			setHere(DashboardViewModel.ORIGIN_KEY)
 		}
 
 		binding.destinationChipHere.setOnClickListener {
+			setChipProgress(binding.destinationChipHere)
 			setHere(DashboardViewModel.DEST_KEY)
 		}
 
@@ -194,15 +200,7 @@
 			override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
 
 			override fun afterTextChanged(s: Editable?) {
-				if (s.isNullOrBlank()) {
-					dashboard.viewModel.spans[DashboardViewModel.ORIGIN_KEY] = ""
-					binding.goButton.isEnabled = false
-					return
-				}
-				binding.goButton.isEnabled =
-					s.toString().replace(dashboard.viewModel.spans[DashboardViewModel.ORIGIN_KEY]!!, "") == "" && isDestinationClean()
-
-				// todo if searchText looks like coordinates or a plus code (short, long, compound) -> suggest it
+				afterTextChanged(DashboardViewModel.ORIGIN_KEY, binding.originSuggestions, s, inflater)
 			}
 		})
 
@@ -212,30 +210,72 @@
 			override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
 
 			override fun afterTextChanged(s: Editable?) {
-				if (s.isNullOrBlank()) {
-					dashboard.viewModel.spans[DashboardViewModel.DEST_KEY] = ""
-					binding.goButton.isEnabled = false
-					return
-				}
-				binding.goButton.isEnabled =
-					s.toString().replace(dashboard.viewModel.spans[DashboardViewModel.DEST_KEY]!!, "") == "" && isOriginClean()
-
-				// todo if searchText looks like coordinates or a plus code (short, long, compound) -> suggest it
+				afterTextChanged(DashboardViewModel.DEST_KEY, binding.destinationSuggestions, s, inflater)
 			}
 		})
 
 		return root
 	}
 
-	fun searchText(source: String) {
-		// TODO or coordinates intent
-		searchRequester = source
+	private fun afterTextChanged(
+		source: String,
+		suggestions: ChipGroup,
+		s: Editable?,
+		inflater: LayoutInflater
+	) {
+		if (s.isNullOrBlank()) {
+			dashboard.viewModel.spans[source] = ""
+			binding.goButton.isEnabled = false
+			return
+		}
+		binding.goButton.isEnabled =
+			s.toString().replace(
+				dashboard.viewModel.spans[source]!!,
+				""
+			) == "" && isOtherClean(source)
+
+		val q = Query(
+			dashboard.viewModel.textInputs[source]!!.text.toString()
+				.replace(dashboard.viewModel.spans[source]!!, "")
+		)
+		q.parse(requireContext())
+		if (q.mode in arrayOf(Mode.POSITION, Mode.LOCATION_PLUS_CODE)) {
+			val chip = inflater.inflate(R.layout.chip_suggestion, suggestions, false) as Chip
+			chip.text = q.toString()
+			chip.isCheckable = false
+			chip.isChipIconVisible = true
+			chip.setChipIconResource(R.drawable.position)
+			chip.setOnClickListener {
+				if (q.mode == Mode.POSITION) {
+					dashboard.viewModel.set(
+						source,
+						Place(q.position!!.latitude, q.position!!.longitude)
+					)
+				} else {
+					setChipProgress(chip)
+					dashboard.viewModel.positionQueries[source] = q
+					viewModel.hereChipRequester = source
+					setHere(source)
+				}
+			}
+			suggestions.removeView(dashboard.viewModel.positionChips[source])
+			dashboard.viewModel.positionChips[source] = chip
+			suggestions.addView(chip, 1)
+		} else {
+			suggestions.removeView(dashboard.viewModel.positionChips[source])
+		}
+	}
+
+	private fun getSearchText(source: String): String =
+		dashboard.viewModel.textInputs[source]!!.text.toString()
+			.replace(dashboard.viewModel.spans[source]!!, "")
+
+	private fun searchText(source: String) {
+		viewModel.searchRequester = source
 		activityLauncher.launch(
 			ResultsActivity.getIntent(
 				requireContext(),
-				ResultsActivity.Mode.MODE_SEARCH,
-				dashboard.viewModel.textInputs[source]!!.text.toString().replace(dashboard.viewModel.spans[source]!!, ""),
-				true
+				Query(getSearchText(source))
 			)
 		)
 	}
@@ -247,16 +287,28 @@
 	private fun isDestinationClean(): Boolean = binding.destination.text.toString().let {
 		it.replace(dashboard.viewModel.spans[DashboardViewModel.DEST_KEY]!!, "") == "" && it != ""
 	}
+
+	private fun isOtherClean(source: String): Boolean =
+		if (source == DashboardViewModel.ORIGIN_KEY) {
+			isDestinationClean()
+		} else if (source == DashboardViewModel.DEST_KEY) {
+			isOriginClean()
+		} else {
+			TODO("throw exception")
+		}
 
 	private fun chipifyOrigin(place: Place?) {
 		val source = DashboardViewModel.ORIGIN_KEY
 		val otherSource = DashboardViewModel.otherSource(source)
 		if (place != null) {
-			chipify(place, dashboard.viewModel.textInputs[source]!!)
+			chipify(place, source)
 			dashboard.viewModel.spans[source] = place.shortString()
 			if (dashboard.viewModel.data[otherSource]!!.value != null && isDestinationClean()) {
 				binding.goButton.isEnabled = true
 			}
+		} else {
+			binding.origin.setText("")
+			binding.goButton.isEnabled = false
 		}
 	}
 
@@ -264,15 +316,19 @@ 	private fun chipifyDestination(place: Place?) {
 		val source = DashboardViewModel.DEST_KEY
 		val otherSource = DashboardViewModel.otherSource(source)
 		if (place != null) {
-			chipify(place, dashboard.viewModel.textInputs[source]!!)
+			chipify(place, source)
 			dashboard.viewModel.spans[source] = place.shortString()
 			if (dashboard.viewModel.data[otherSource]!!.value != null && isOriginClean()) {
 				binding.goButton.isEnabled = true
 			}
+		} else {
+			binding.destination.setText("")
+			binding.goButton.isEnabled = false
 		}
 	}
 
-	private fun chipify(place: Place, textView: TextInputEditText) {
+	private fun chipify(place: Place, source: String) {
+		val textView = dashboard.viewModel.textInputs[source]!!
 		val text = place.shortString()
 		textView.setText(text)
 		var chip: ChipDrawable? = ChipDrawable.createFromResource(requireContext(), R.xml.journey_chip)
@@ -303,7 +359,7 @@ 				} else if (e.x > textView.totalPaddingLeft + chipContentRect.right && e.x < textView.totalPaddingLeft + chipCloseRect.right
 					&& e.y > textView.totalPaddingTop && e.y < textView.totalPaddingTop + chipCloseRect.bottom
 				) {
 					if (e.action == ACTION_UP) {
-						textView.setText("")
+						dashboard.viewModel.unset(source)
 						chip = null
 					}
 					true
@@ -317,7 +373,7 @@ 		}
 	}
 
 	private fun setHere(source: String) {
-		hereChipRequester = source
+		viewModel.hereChipRequester = source
 		if (dashboard.onGpsClicked(this)) {
 			try {
 				val locationManager =
@@ -341,8 +397,53 @@ 		super.onDestroyView()
 		_binding = null
 	}
 
+	override fun onSaveInstanceState(outState: Bundle) {
+		super.onSaveInstanceState(outState)
+		outState.putString(DashboardViewModel.ORIGIN_KEY, getSearchText(DashboardViewModel.ORIGIN_KEY))
+		outState.putString(DashboardViewModel.DEST_KEY, getSearchText(DashboardViewModel.DEST_KEY))
+		// TODO and rest of params
+	}
+
+	override fun onViewStateRestored(savedInstanceState: Bundle?) {
+		super.onViewStateRestored(savedInstanceState)
+
+		if (savedInstanceState != null) {
+			arrayOf(DashboardViewModel.ORIGIN_KEY, DashboardViewModel.DEST_KEY).forEach { source ->
+				val searchString = savedInstanceState.getString(source) ?: ""
+				if (searchString != "") {
+					binding.origin.setText(searchString)
+				}
+			}
+			// TODO and rest of params
+		}
+	}
+
 	override fun onLocationChanged(location: Location) {
-		hereChipRequester?.let { dashboard.viewModel.set(it, Place(location.latitude, location.longitude)) }
-		hereChipRequester = null
+		viewModel.hereChipRequester?.let {
+			val query = dashboard.viewModel.positionQueries[it]
+			val position = if (query != null && query.mode == Mode.LOCATION_PLUS_CODE) {
+				OpenLocationCode(query.raw).recover(location.latitude, location.longitude).decode().let {
+					viewModel.loadingChip?.setChipIconResource(R.drawable.position)
+					Position(it.centerLatitude, it.centerLatitude)
+				}
+			} else {
+				viewModel.loadingChip?.setChipIconResource(R.drawable.gps_black)
+				Position(location.latitude, location.longitude)
+			}
+			dashboard.viewModel.set(
+				it,
+				Place(position.latitude, position.longitude)
+			)
+			dashboard.viewModel.positionQueries[it] = null
+		}
+		viewModel.hereChipRequester = null
+	}
+
+	private fun setChipProgress(chip: Chip) {
+		viewModel.loadingChip = chip
+		chip.chipIcon = CircularProgressDrawable(requireContext()).apply {
+			setStyle(CircularProgressDrawable.DEFAULT)
+			start()
+		}
 	}
 }
\ No newline at end of file




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/journey/JourneyViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/journey/JourneyViewModel.kt
index 87c80d7c047df700c968d48c73e0600f1e474384..d26d70d8a50adccd3e5487d7a304369bcdd1ae7f 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/journey/JourneyViewModel.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/journey/JourneyViewModel.kt
@@ -4,15 +4,16 @@ // SPDX-License-Identifier: GPL-3.0-or-later
 
 package xyz.apiote.bimba.czwek.dashboard.ui.journey
 
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
+import android.annotation.SuppressLint
 import androidx.lifecycle.ViewModel
+import com.google.android.material.chip.Chip
 
 class JourneyViewModel : ViewModel() {
 
-	private val _text = MutableLiveData<String>().apply {
-		value = "This is voyage Fragment"
-	}
-	val text: LiveData<String> = _text
+	var hereChipRequester: String? = null
+	var searchRequester: String? = null
+
+	@SuppressLint("StaticFieldLeak")
+	var loadingChip: Chip? = null
 
 }




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/Query.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/Query.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bd2f303f39d626769cd2a9fda0a4e47d67aab570
--- /dev/null
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/Query.kt
@@ -0,0 +1,150 @@
+// SPDX-FileCopyrightText: Adam Evyčędo
+//
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package xyz.apiote.bimba.czwek.search
+
+import android.content.Context
+import android.os.Build
+import android.os.Parcel
+import android.os.Parcelable
+import com.google.openlocationcode.OpenLocationCode
+import kotlinx.parcelize.Parcelize
+import xyz.apiote.bimba.czwek.repo.Position
+
+class Query : Parcelable {
+	companion object {
+		@Suppress("unused")
+		@JvmField
+		val CREATOR = object : Parcelable.Creator<Query> {
+			@Suppress("DEPRECATION")
+			override fun createFromParcel(source: Parcel?): Query? {
+				if (source == null) {
+					return null
+				} else {
+					val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+						source.readParcelable(Mode::class.java.classLoader, Mode::class.java)!!
+					} else {
+						source.readParcelable(Mode::class.java.classLoader)!!
+					}
+					val raw = source.readString()!!
+					val position = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+						source.readParcelable(Position::class.java.classLoader, Position::class.java)!!
+					} else {
+						source.readParcelable(Position::class.java.classLoader)!!
+					}
+					return Query(mode, raw, position)
+				}
+			}
+
+			override fun newArray(size: Int): Array<out Query?>? {
+				return arrayOfNulls(size)
+			}
+
+		}
+	}
+
+	@Parcelize
+	enum class Mode : Parcelable {
+		LOCATION, POSITION, NAME, LOCATION_PLUS_CODE, UNKNOWN
+	}
+
+	constructor(raw: String) {
+		mode = Mode.UNKNOWN
+		this.raw = raw
+		position = null
+	}
+
+	constructor(raw: String, mode: Mode) {
+		this.mode = mode
+		this.raw = raw
+		position = null
+	}
+
+	constructor(mode: Mode) {
+		if (mode != Mode.LOCATION) {
+			throw Exception("Cannot initialise Query from bare Mode other than LOCATION")
+		}
+		this.mode = mode
+		raw = ""
+		position = null
+	}
+
+	constructor(position: Position) {
+		mode = Mode.POSITION
+		raw = position.toString()
+		this.position = position
+	}
+
+	private constructor(mode: Mode, raw: String, position: Position?) {
+		this.mode = mode
+		this.raw = raw
+		this.position = position
+	}
+
+	var mode: Mode
+		private set
+	val raw: String
+	var position: Position?
+		private set
+
+	fun parse(context: Context) {
+		if (mode != Mode.UNKNOWN) {
+			return
+		}
+		/* TODO parse coordinates https://developer.android.com/reference/android/location/Location#convert(java.lang.String)
+		  replace `"?` -> ``
+		  replace `' ?` -> `:`
+		  replace `° ?` -> `:`
+			split by space/locale-separator
+			if number has locale-digit-separator, replace with dot
+			if has E/S at end, set sign
+		 */
+		if (OpenLocationCode.isValidCode(raw)) {
+			val olc = OpenLocationCode(raw)
+			if (!olc.isFull) {
+				mode = Mode.LOCATION_PLUS_CODE
+			} else {
+				val area = olc.decode()
+				mode = Mode.POSITION
+				position = Position(area.centerLatitude, area.centerLongitude)
+			}
+		} else if (OpenLocationCode.isValidCode(raw.trim().split(" ").first().trim(',').trim())) {
+			mode = Mode.POSITION
+			geocode(context)
+		} else {
+			mode = Mode.NAME
+		}
+	}
+
+	private fun geocode(context: Context) {
+		val split = raw.trim().split(" ")
+		val code = split.first().trim(',').trim()
+		val freePart = split.drop(1).joinToString(" ")
+		val location = findPlace(context, freePart)
+		if (location == null) {
+			throw GeocodingException()
+		} else {
+			val area = OpenLocationCode(code).recover(location.latitude, location.longitude).decode()
+			position = Position(area.centerLatitude, area.centerLongitude)
+		}
+	}
+
+	override fun describeContents(): Int = 0
+
+	override fun writeToParcel(dest: Parcel, flags: Int) {
+		dest.writeParcelable(mode, flags)
+		dest.writeString(raw)
+		dest.writeParcelable(position, flags)
+	}
+
+	override fun toString(): String {
+		return when (mode) {
+			Mode.UNKNOWN -> raw
+			Mode.LOCATION -> "here"
+			Mode.POSITION -> "%.2f, %.2f".format(position!!.latitude, position!!.longitude)  // TODO settings for position format
+			Mode.NAME -> raw
+			Mode.LOCATION_PLUS_CODE -> raw
+		}
+	}
+}
\ No newline at end of file




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 e9200b17821fa4122cee1174b732f5d900ae75bb..5332c8d63e9a152ef1b83a4c2b5fd61d35407ea3 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
@@ -44,46 +44,20 @@ import xyz.apiote.bimba.czwek.repo.Position
 import xyz.apiote.bimba.czwek.repo.Queryable
 import xyz.apiote.bimba.czwek.repo.Stop
 import xyz.apiote.bimba.czwek.repo.TrafficResponseException
+import xyz.apiote.bimba.czwek.search.Query.Mode
 import xyz.apiote.bimba.czwek.settings.feeds.FeedsSettings
 
 class ResultsActivity : AppCompatActivity(), LocationListener, SensorEventListener {
-	enum class Mode {
-		MODE_LOCATION, MODE_SEARCH, MODE_POSITION, MODE_SHORT_CODE_LOCATION, MODE_SHORT_CODE
-	}
-
 	companion object {
-		const val MODE_KEY = "mode"
 		const val QUERY_KEY = "query"
-		const val LATITUDE_KEY = "lat"
-		const val LONGITUDE_KEY = "lon"
 		const val RETURN_KEY = "ret"
 		fun getIntent(
 			context: Context,
-			mode: Mode,
-			query: String,
-			latitude: Double,
-			longitude: Double
+			query: Query
 		) =
 			Intent(context, ResultsActivity::class.java).apply {
-				putExtra(MODE_KEY, mode)
 				putExtra(QUERY_KEY, query)
-				putExtra(LATITUDE_KEY, latitude)
-				putExtra(LONGITUDE_KEY, longitude)
 			}
-
-		fun getIntent(context: Context, mode: Mode, query: String) =
-			Intent(context, ResultsActivity::class.java).apply {
-				putExtra(MODE_KEY, mode)
-				putExtra(QUERY_KEY, query)
-			}
-
-		fun getIntent(context: Context, mode: Mode, query: String, returnResult: Boolean) =
-			Intent(context, ResultsActivity::class.java).apply {
-				putExtra(MODE_KEY, mode)
-				putExtra(QUERY_KEY, query)
-				putExtra(RETURN_KEY, returnResult)
-			}
-
 	}
 
 	private var _binding: ActivityResultsBinding? = null
@@ -96,6 +70,7 @@ 	private var runnable = Runnable {}
 	private var gravity: FloatArray? = null
 	private var geomagnetic: FloatArray? = null
 	private var shortOLC: OpenLocationCode? = null
+	private lateinit var query: Query
 
 	override fun onCreate(savedInstanceState: Bundle?) {
 		enableEdgeToEdge()
@@ -125,62 +100,54 @@ 		adapter =
 			BimbaResultsAdapter(layoutInflater, this, listOf(), null, null, false, getReturnResults())
 		binding.resultsRecycler.adapter = adapter
 
-		when (getMode()) {
-			Mode.MODE_LOCATION -> {
+		query = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+			intent.getParcelableExtra(QUERY_KEY, Query::class.java)!!
+		} else {
+			@Suppress("DEPRECATION")
+			intent.getParcelableExtra(QUERY_KEY)!!
+		}
+
+		useQuery()
+	}
+
+	private fun useQuery() {
+		when (query.mode) {
+			Mode.LOCATION -> {
 				binding.topAppBar.title = getString(R.string.stops_nearby)
 				locate()
 			}
 
-			Mode.MODE_SHORT_CODE_LOCATION -> {
-				val query = intent.extras?.getString(QUERY_KEY)
-				binding.topAppBar.title = getString(R.string.stops_near_code, query)
-				shortOLC = OpenLocationCode(query)
+			Mode.LOCATION_PLUS_CODE -> {
+				binding.topAppBar.title = getString(R.string.stops_near_code, query.raw)
+				shortOLC = OpenLocationCode(query.raw)
 				locate()
 			}
 
-			Mode.MODE_SHORT_CODE -> {
-				val query = intent.extras?.getString(QUERY_KEY)
-				binding.topAppBar.title = getString(R.string.stops_near_code, query)
-				val split = query!!.trim().split(" ")
-				val code = split.first().trim(',').trim()
-				val freePart = split.drop(1).joinToString(" ")
-				val location = findPlace(this, freePart)
-				if (location == null) {
+			Mode.UNKNOWN -> {
+				try {
+					query.parse(this)
+					if (query.mode != Mode.UNKNOWN) {
+						useQuery()
+					} else {
+						showError(Error(0, R.string.error_unknown, R.drawable.error_other))
+					}
+				} catch (_: GeocodingException) {
 					showError(Error(0, R.string.error_geocoding, R.drawable.geocoding))
-				} else {
-					val area = OpenLocationCode(code).recover(location.latitude, location.longitude).decode()
-					getQueryablesByLocation(Location(null).apply {
-						latitude = area.centerLatitude
-						longitude = area.centerLongitude
-					}, this)
 				}
 			}
 
-			Mode.MODE_POSITION -> {
-				val query = intent.extras?.getString(QUERY_KEY)
-				val lat = intent.extras?.getDouble(LATITUDE_KEY)
-				val lon = intent.extras?.getDouble(LONGITUDE_KEY)
-				binding.topAppBar.title = getString(R.string.stops_near_code, query)
+			Mode.POSITION -> {
+				binding.topAppBar.title = getString(R.string.stops_near_code, query.raw)
 				getQueryablesByLocation(Location(null).apply {
-					latitude = lat!!
-					longitude = lon!!
+					latitude = query.position!!.latitude
+					longitude = query.position!!.longitude
 				}, this)
 			}
 
-			Mode.MODE_SEARCH -> {
-				val query = intent.extras?.getString(QUERY_KEY)!!
-				binding.topAppBar.title = getString(R.string.results_for, query)
-				getQueryablesByQuery(query, this)
+			Mode.NAME -> {
+				binding.topAppBar.title = getString(R.string.results_for, query.raw)
+				getQueryablesByQuery(query.raw, this)
 			}
-		}
-	}
-
-	private fun getMode(): Mode {
-		return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-			intent.extras!!.getSerializable(MODE_KEY, Mode::class.java)!!
-		} else {
-			@Suppress("DEPRECATION")
-			intent.extras!!.get(MODE_KEY) as Mode
 		}
 	}
 
@@ -243,7 +210,7 @@ 	}
 
 	override fun onResume() {
 		super.onResume()
-		if (getMode() == Mode.MODE_LOCATION) {
+		if (query.mode == Mode.LOCATION) {
 			locate()
 		}
 	}
@@ -340,7 +307,7 @@ 					R.drawable.error_search
 				)
 			)
 		} else {
-			if (queryables.size == 1 && getMode() == Mode.MODE_SEARCH && !getReturnResults()) {
+			if (queryables.size == 1 && query.mode == Query.Mode.NAME && !getReturnResults()) {
 				adapter.click(0)
 			}
 			binding.resultsOverlay.visibility = View.GONE




diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/geocoder.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/geocoder.kt
index 98ed6ec4d75edc50fdf0e7105d5bcf06ae28a047..98c152f66af7260e13c063ea7a2f1035595d216e 100644
--- a/app/src/main/java/xyz/apiote/bimba/czwek/search/geocoder.kt
+++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/geocoder.kt
@@ -28,4 +28,6 @@ 	}
 	cursor.close()
 	db.close()
 	return location
-}
\ No newline at end of file
+}
+
+class GeocodingException : Exception()
\ No newline at end of file




diff --git a/app/src/main/res/drawable/position.xml b/app/src/main/res/drawable/position.xml
new file mode 100644
index 0000000000000000000000000000000000000000..2256cca5d494cdf165812525602e2011399b9fe5
--- /dev/null
+++ b/app/src/main/res/drawable/position.xml
@@ -0,0 +1,15 @@
+<!--
+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,2L12,2C8.13,2 5,5.13 5,9c0,1.74 0.5,3.37 1.41,4.84c0.95,1.54 2.2,2.86 3.16,4.4c0.47,0.75 0.81,1.45 1.17,2.26C11,21.05 11.21,22 12,22h0c0.79,0 1,-0.95 1.25,-1.5c0.37,-0.81 0.7,-1.51 1.17,-2.26c0.96,-1.53 2.21,-2.85 3.16,-4.4C18.5,12.37 19,10.74 19,9C19,5.13 15.87,2 12,2zM12,11.75c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5S13.38,11.75 12,11.75z" />
+</vector>




diff --git a/app/src/main/res/layout/chip_suggestion.xml b/app/src/main/res/layout/chip_suggestion.xml
new file mode 100644
index 0000000000000000000000000000000000000000..211f8a5261418b9a19dc6140094e16a5b16ca1db
--- /dev/null
+++ b/app/src/main/res/layout/chip_suggestion.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+SPDX-FileCopyrightText: Adam Evyčędo
+
+SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<com.google.android.material.chip.Chip xmlns:android="http://schemas.android.com/apk/res/android"
+	style="@style/Widget.Material3.Chip.Suggestion"
+	android:layout_width="wrap_content"
+	android:layout_height="wrap_content">
+
+</com.google.android.material.chip.Chip>
\ No newline at end of file