diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ForwardGeolocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ForwardGeolocation.kt
new file mode 100644
index 0000000000..1068405373
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ForwardGeolocation.kt
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.service.location
+
+import android.content.Context
+import android.location.Address
+import android.location.Geocoder
+import android.os.Build
+import androidx.annotation.RequiresApi
+import com.vitorpamplona.quartz.utils.Log
+import java.io.IOException
+
+/**
+ * Forward geocoding: turn a free-text place query (a city, address, or landmark)
+ * into a list of candidate [Address]es so the user can jump the map there.
+ *
+ * The mirror image of [ReverseGeolocation]: on TIRAMISU+ it uses the async
+ * listener overload of [Geocoder.getFromLocationName] (the blocking one is
+ * deprecated there), and falls back to the synchronous call on older devices.
+ * Both branches funnel through [onReady]; a null result means "no backend, an
+ * error, or nothing matched" and the caller should degrade gracefully.
+ */
+@Suppress("DEPRECATION")
+class ForwardGeolocation {
+ companion object {
+ const val MAX_RESULTS = 5
+
+ fun execute(
+ query: String,
+ context: Context,
+ onReady: (List
?) -> Unit,
+ ) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ executeAsync(query, context, onReady)
+ } else {
+ onReady(executeSync(query, context))
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.TIRAMISU)
+ fun executeAsync(
+ query: String,
+ context: Context,
+ onReady: (List?) -> Unit,
+ ) {
+ val listener =
+ object : Geocoder.GeocodeListener {
+ override fun onGeocode(addresses: List) {
+ Log.d("ForwardGeoLocation") { "Found ${addresses.size} addresses for $query" }
+ onReady(addresses)
+ }
+
+ override fun onError(errorMessage: String?) {
+ super.onError(errorMessage)
+ Log.w("ForwardGeoLocation") { "Failure $errorMessage" }
+ onReady(null)
+ }
+ }
+
+ Log.d("ForwardGeoLocation") { "Execute Async $query" }
+ Geocoder(context).getFromLocationName(query, MAX_RESULTS, listener)
+ }
+
+ fun executeSync(
+ query: String,
+ context: Context,
+ ): List? {
+ Log.d("ForwardGeoLocation") { "Execute Sync $query" }
+ return try {
+ Geocoder(context).getFromLocationName(query, MAX_RESULTS)
+ } catch (e: IOException) {
+ Log.w("ForwardGeolocation", "IO Error", e)
+ null
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt
new file mode 100644
index 0000000000..bac5713d43
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt
@@ -0,0 +1,507 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
+ * Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+package com.vitorpamplona.amethyst.ui.note.creators.location
+
+import android.Manifest
+import androidx.compose.foundation.background
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.service.location.ForwardGeolocation
+import com.vitorpamplona.amethyst.service.location.LocationState
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.label
+import com.vitorpamplona.amethyst.ui.stringRes
+import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
+import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import org.osmdroid.util.GeoPoint
+
+/** Zoom the map animates to after a search hit or a "use my location" tap. */
+private const val RECENTER_ZOOM = 14.0
+
+/** How close to zoom in when the picker opens already holding a location. */
+private const val SEEDED_ZOOM = 13.0
+
+/** How far out to start when the picker opens with nothing selected yet. */
+private const val WORLD_ZOOM = 2.5
+
+/**
+ * A full-screen, map-first location picker that produces a geohash string.
+ *
+ * The user has three ways to land on a place — pan the map under the fixed center
+ * pin, search for a place by name (forward geocoding), or tap "use my location"
+ * (device GPS) — and never has to know what a geohash is. The chosen precision
+ * ([GeohashChannelLevel]) controls how many characters the resulting geohash has,
+ * i.e. how large an area the group claims. On confirm, [onConfirm] receives the
+ * encoded geohash (e.g. `u4pruy`).
+ *
+ * Reuses [LocationPickerMap] (in center-pin mode), [LoadCityName] for the
+ * human-readable place name, and the app-wide [LocationState] for GPS. Modeled on
+ * the Geohash-chat Teleport screen, promoted here into a reusable dialog.
+ */
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun GeohashLocationPickerDialog(
+ initialGeohash: String?,
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit,
+) {
+ val context = LocalContext.current
+ val keyboard = LocalSoftwareKeyboardController.current
+ val locationManager = Amethyst.instance.locationManager
+
+ val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } }
+ val seedLen = initialGeohash?.trim()?.length ?: 0
+
+ var pickedLat by remember { mutableStateOf(seed?.centerLat) }
+ var pickedLon by remember { mutableStateOf(seed?.centerLon) }
+ var level by remember {
+ mutableStateOf(GeohashChannelLevel.forChars(seedLen) ?: GeohashChannelLevel.CITY)
+ }
+ var recenter by remember { mutableStateOf(null) }
+
+ val cell =
+ remember(pickedLat, pickedLon, level) {
+ val lat = pickedLat
+ val lon = pickedLon
+ if (lat != null && lon != null) GeoHash.encode(lat, lon, level.chars).toString() else null
+ }
+
+ // Debounce the reverse-geocode: panning changes [cell] constantly, and we don't
+ // want to hammer the Geocoder — only resolve a place name once the map settles.
+ var settledCell by remember { mutableStateOf(cell) }
+ LaunchedEffect(cell) {
+ delay(450)
+ settledCell = cell
+ }
+
+ // "Use my location": tapping either fires the fetch (permission already granted) or
+ // asks for it; [awaitingPermission] carries the intent across the system dialog so a
+ // grant auto-starts the fetch, while a denial simply drops the request (no stuck spinner).
+ val permission = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
+ var wantsMyLocation by remember { mutableStateOf(false) }
+ var awaitingPermission by remember { mutableStateOf(false) }
+ LaunchedEffect(permission.status.isGranted) {
+ locationManager.setLocationPermission(permission.status.isGranted)
+ if (permission.status.isGranted && awaitingPermission) {
+ awaitingPermission = false
+ wantsMyLocation = true
+ } else if (!permission.status.isGranted) {
+ awaitingPermission = false
+ }
+ }
+ LaunchedEffect(wantsMyLocation) {
+ if (wantsMyLocation) {
+ val fix = locationManager.preciseGeohashStateFlow.first { it is LocationState.LocationResult.Success }
+ val hash = (fix as LocationState.LocationResult.Success).geoHash
+ recenter = GeoPoint(hash.centerLat, hash.centerLon)
+ pickedLat = hash.centerLat
+ pickedLon = hash.centerLon
+ wantsMyLocation = false
+ }
+ }
+ val onUseMyLocation = {
+ if (permission.status.isGranted) {
+ wantsMyLocation = true
+ } else {
+ awaitingPermission = true
+ permission.launchPermissionRequest()
+ }
+ }
+
+ // Forward-geocode search.
+ var query by remember { mutableStateOf("") }
+ var searching by remember { mutableStateOf(false) }
+ var searchMissed by remember { mutableStateOf(false) }
+ val runSearch = {
+ val q = query.trim()
+ if (q.isNotEmpty()) {
+ keyboard?.hide()
+ searching = true
+ searchMissed = false
+ ForwardGeolocation.execute(q, context) { addresses ->
+ searching = false
+ val hit = addresses?.firstOrNull()
+ if (hit != null) {
+ recenter = GeoPoint(hit.latitude, hit.longitude)
+ pickedLat = hit.latitude
+ pickedLon = hit.longitude
+ } else {
+ searchMissed = true
+ }
+ }
+ }
+ }
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
+ Column(Modifier.fillMaxSize()) {
+ PickerHeader(onClose = onDismiss)
+
+ Box(Modifier.fillMaxWidth().weight(1f)) {
+ LocationPickerMap(
+ latitude = seed?.centerLat ?: 20.0,
+ longitude = seed?.centerLon ?: 0.0,
+ pickedLatitude = null,
+ pickedLongitude = null,
+ zoom = if (seed != null) SEEDED_ZOOM else WORLD_ZOOM,
+ recenter = recenter,
+ recenterZoom = RECENTER_ZOOM,
+ onCenterChanged = { lat, lon ->
+ pickedLat = lat
+ pickedLon = lon
+ },
+ onPick = { lat, lon ->
+ recenter = GeoPoint(lat, lon)
+ pickedLat = lat
+ pickedLon = lon
+ },
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ CenterPin(Modifier.align(Alignment.Center))
+
+ SearchField(
+ query = query,
+ onQueryChange = {
+ query = it
+ searchMissed = false
+ },
+ onSearch = runSearch,
+ searching = searching,
+ missed = searchMissed,
+ modifier =
+ Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth()
+ .padding(12.dp),
+ )
+
+ MyLocationButton(
+ loading = wantsMyLocation || awaitingPermission,
+ onClick = onUseMyLocation,
+ modifier =
+ Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp),
+ )
+ }
+
+ PickerBottomBar(
+ cell = cell,
+ settledCell = settledCell,
+ level = level,
+ onLevel = { level = it },
+ onConfirm = { cell?.let(onConfirm) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PickerHeader(onClose: () -> Unit) {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .statusBarsPadding()
+ .padding(horizontal = 4.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onClose) {
+ Icon(
+ symbol = MaterialSymbols.Close,
+ contentDescription = stringRes(R.string.cancel),
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ Text(
+ text = stringRes(R.string.relay_group_location_picker_title),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(start = 4.dp),
+ )
+ }
+ }
+}
+
+/** The fixed pin that hovers over the map center; the point under its tip is the selection. */
+@Composable
+private fun CenterPin(modifier: Modifier = Modifier) {
+ Box(modifier = modifier.size(48.dp), contentAlignment = Alignment.Center) {
+ // The pin's tip sits at the map center; lift the whole glyph up by half its height.
+ Icon(
+ symbol = MaterialSymbols.LocationOn,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier =
+ Modifier
+ .size(46.dp)
+ .offset(y = (-20).dp),
+ )
+ // A small anchor dot marking the exact center point.
+ Box(
+ Modifier
+ .size(7.dp)
+ .shadow(2.dp, CircleShape)
+ .background(MaterialTheme.colorScheme.primary, CircleShape),
+ )
+ }
+}
+
+@Composable
+private fun SearchField(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ onSearch: () -> Unit,
+ searching: Boolean,
+ missed: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ modifier = modifier.shadow(6.dp, RoundedCornerShape(28.dp)),
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surface,
+ ) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ singleLine = true,
+ placeholder = { Text(stringRes(R.string.relay_group_location_search_hint)) },
+ leadingIcon = {
+ Icon(
+ symbol = MaterialSymbols.Search,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ trailingIcon = {
+ if (searching) {
+ CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp))
+ } else if (query.isNotEmpty()) {
+ IconButton(onClick = { onQueryChange("") }) {
+ Icon(
+ symbol = MaterialSymbols.Close,
+ contentDescription = stringRes(R.string.clear),
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ isError = missed,
+ supportingText =
+ if (missed) {
+ { Text(stringRes(R.string.relay_group_location_search_empty)) }
+ } else {
+ null
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { onSearch() }),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+}
+
+@Composable
+private fun MyLocationButton(
+ loading: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ FilledIconButton(
+ onClick = onClick,
+ enabled = !loading,
+ colors =
+ IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.primary,
+ ),
+ modifier = modifier.shadow(6.dp, CircleShape).size(52.dp),
+ ) {
+ if (loading) {
+ CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
+ } else {
+ Icon(
+ symbol = MaterialSymbols.MyLocation,
+ contentDescription = stringRes(R.string.relay_group_location_use_mine),
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+}
+
+@Composable
+private fun PickerBottomBar(
+ cell: String?,
+ settledCell: String?,
+ level: GeohashChannelLevel,
+ onLevel: (GeohashChannelLevel) -> Unit,
+ onConfirm: () -> Unit,
+) {
+ Surface(
+ shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp),
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ tonalElevation = 3.dp,
+ shadowElevation = 8.dp,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .imePadding()
+ .padding(16.dp),
+ ) {
+ Text(
+ text = stringRes(R.string.relay_group_location_precision),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState())
+ .padding(vertical = 10.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ GeohashChannelLevel.ordered.forEach { lvl ->
+ FilterChip(
+ selected = lvl == level,
+ onClick = { onLevel(lvl) },
+ label = { Text(lvl.label()) },
+ )
+ }
+ }
+
+ if (cell == null) {
+ Text(
+ text = stringRes(R.string.relay_group_location_picker_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 8.dp),
+ )
+ } else {
+ Row(
+ Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.primaryContainer,
+ modifier = Modifier.size(40.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ symbol = MaterialSymbols.LocationOn,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
+ Column(Modifier.weight(1f).padding(start = 12.dp)) {
+ LoadCityName(geohashStr = settledCell ?: cell) { cityName ->
+ Text(
+ cityName,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ )
+ }
+ Text(
+ "#$cell",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ Button(
+ onClick = onConfirm,
+ enabled = cell != null,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringRes(R.string.relay_group_location_confirm), fontWeight = FontWeight.SemiBold)
+ }
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt
index 22838cda71..82f0003f7f 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt
@@ -33,6 +33,9 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.osmdroid.config.Configuration
import org.osmdroid.events.MapEventsReceiver
+import org.osmdroid.events.MapListener
+import org.osmdroid.events.ScrollEvent
+import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
@@ -47,6 +50,14 @@ import org.osmdroid.views.overlay.Marker
*
* Shares the tile/User-Agent/lifecycle setup with [LocationPreviewMap]; unlike
* that display-only map, this one installs a [MapEventsOverlay] for tap picking.
+ *
+ * Two optional hooks power a "move the map under a fixed center pin" experience
+ * (the modern picker style) without breaking the tap-to-drop callers:
+ * - [onCenterChanged] fires whenever the map is scrolled or zoomed, reporting the
+ * new map center — pair it with a Compose crosshair drawn over the map's center.
+ * - [recenter] animates the map to a new point when its value changes (e.g. after
+ * a place search or a "use my location" tap). Passing the same value twice is a
+ * no-op, so it is safe to hoist in state.
*/
@Composable
fun LocationPickerMap(
@@ -56,11 +67,19 @@ fun LocationPickerMap(
pickedLongitude: Double?,
modifier: Modifier = Modifier,
zoom: Double = 4.0,
+ recenter: GeoPoint? = null,
+ recenterZoom: Double? = null,
+ onCenterChanged: ((Double, Double) -> Unit)? = null,
onPick: (Double, Double) -> Unit,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnPick by rememberUpdatedState(onPick)
+ val currentOnCenterChanged by rememberUpdatedState(onCenterChanged)
+
+ // Tracks the last point we animated to, so a recomposition that re-supplies the
+ // same [recenter] doesn't yank the map back while the user is panning.
+ val lastRecenter = remember { arrayOfNulls(1) }
val mapView =
remember(context) {
@@ -86,6 +105,20 @@ fun LocationPickerMap(
}
}
overlays.add(0, MapEventsOverlay(receiver))
+
+ addMapListener(
+ object : MapListener {
+ override fun onScroll(event: ScrollEvent?): Boolean {
+ mapCenter.let { currentOnCenterChanged?.invoke(it.latitude, it.longitude) }
+ return false
+ }
+
+ override fun onZoom(event: ZoomEvent?): Boolean {
+ mapCenter.let { currentOnCenterChanged?.invoke(it.latitude, it.longitude) }
+ return false
+ }
+ },
+ )
}
}
@@ -109,6 +142,15 @@ fun LocationPickerMap(
modifier = modifier,
factory = { mapView },
update = { map ->
+ if (recenter != null && recenter != lastRecenter[0]) {
+ lastRecenter[0] = recenter
+ if (recenterZoom != null) {
+ map.controller.animateTo(recenter, recenterZoom, 800L)
+ } else {
+ map.controller.animateTo(recenter)
+ }
+ }
+
map.overlays.removeAll { it is Marker }
if (pickedLatitude != null && pickedLongitude != null) {
val point = GeoPoint(pickedLatitude, pickedLongitude)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt
index 984c3fc985..307c7e6856 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt
@@ -107,6 +107,7 @@ fun LocationPreviewMap(
longitude: Double,
modifier: Modifier = Modifier,
zoom: Double = DEFAULT_ZOOM,
+ aspectRatio: Float = 1f,
pinColor: Color? = null,
pinEmoji: String? = null,
pinAlpha: Float = 1f,
@@ -166,7 +167,7 @@ fun LocationPreviewMap(
}
AndroidView(
- modifier = modifier.fillMaxWidth().aspectRatio(1f),
+ modifier = modifier.fillMaxWidth().aspectRatio(aspectRatio),
factory = { mapView },
update = { map ->
val point = GeoPoint(latitude, longitude)
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt
index 5f2bbe0c45..e06bc97066 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt
@@ -39,12 +39,17 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -57,6 +62,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -71,10 +77,14 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
+import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog
+import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
+import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
+import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
/**
@@ -370,17 +380,8 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
- OutlinedTextField(
- value = viewModel.geohash.value,
- onValueChange = {
- viewModel.geohash.value = it
- viewModel.markTouched()
- },
- singleLine = true,
- label = { Text(stringRes(R.string.relay_group_field_geohash)) },
- placeholder = { Text(stringRes(R.string.relay_group_field_geohash_hint)) },
- modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
- )
+ Spacer(Modifier.height(8.dp))
+ GroupLocationField(viewModel)
Spacer(Modifier.height(12.dp))
Text(
@@ -423,6 +424,185 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) {
}
}
+/**
+ * The group's discovery location. Map-first: an inviting card opens a full-screen
+ * map picker ([GeohashLocationPickerDialog]) that turns a tapped/searched place
+ * into the geohash the ViewModel already stores. A collapsed "enter manually"
+ * field keeps the paste-a-known-geohash path for power users. The geohash text in
+ * [RelayGroupMetadataViewModel.geohash] stays the single source of truth.
+ */
+@Composable
+private fun GroupLocationField(viewModel: RelayGroupMetadataViewModel) {
+ var showPicker by remember { mutableStateOf(false) }
+ var showManual by remember { mutableStateOf(false) }
+
+ val geohash =
+ viewModel.geohash.value.text
+ .trim()
+
+ if (geohash.isNotBlank()) {
+ SelectedLocationCard(
+ geohash = geohash,
+ onEdit = { showPicker = true },
+ onClear = {
+ viewModel.geohash.value = TextFieldValue("")
+ viewModel.markTouched()
+ },
+ )
+ } else {
+ AddLocationCard(onClick = { showPicker = true })
+ }
+
+ TextButton(
+ onClick = { showManual = !showManual },
+ modifier = Modifier.padding(top = 2.dp),
+ ) {
+ Icon(
+ symbol = MaterialSymbols.Edit,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Text(
+ text = stringRes(R.string.relay_group_location_manual),
+ style = MaterialTheme.typography.labelLarge,
+ modifier = Modifier.padding(start = 6.dp),
+ )
+ }
+ if (showManual) {
+ OutlinedTextField(
+ value = viewModel.geohash.value,
+ onValueChange = {
+ viewModel.geohash.value = it
+ viewModel.markTouched()
+ },
+ singleLine = true,
+ label = { Text(stringRes(R.string.relay_group_field_geohash)) },
+ placeholder = { Text(stringRes(R.string.relay_group_field_geohash_hint)) },
+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
+ )
+ }
+
+ if (showPicker) {
+ GeohashLocationPickerDialog(
+ initialGeohash = geohash.ifBlank { null },
+ onDismiss = { showPicker = false },
+ onConfirm = { cell ->
+ viewModel.geohash.value = TextFieldValue(cell)
+ viewModel.markTouched()
+ showPicker = false
+ },
+ )
+ }
+}
+
+/** Empty-state call to action inviting the user to pin the group on a map. */
+@Composable
+private fun AddLocationCard(onClick: () -> Unit) {
+ OutlinedCard(
+ onClick = onClick,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ Box(
+ Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ symbol = MaterialSymbols.LocationOn,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ Column(Modifier.weight(1f)) {
+ Text(
+ text = stringRes(R.string.relay_group_location_add),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ Text(
+ text = stringRes(R.string.relay_group_location_add_desc),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Icon(
+ symbol = MaterialSymbols.AutoMirrored.ArrowForwardIos,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(16.dp),
+ )
+ }
+ }
+}
+
+/** Filled-state card: a themed map thumbnail + the resolved place name and geohash. */
+@Composable
+private fun SelectedLocationCard(
+ geohash: String,
+ onEdit: () -> Unit,
+ onClear: () -> Unit,
+) {
+ val decoded = remember(geohash) { GeoHash.decode(geohash) }
+
+ Card(
+ onClick = onEdit,
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
+ ) {
+ if (decoded != null) {
+ LocationPreviewMap(
+ latitude = decoded.centerLat,
+ longitude = decoded.centerLon,
+ zoom = 12.0,
+ aspectRatio = 2.4f,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ Row(
+ Modifier.fillMaxWidth().padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Icon(
+ symbol = MaterialSymbols.LocationOn,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(22.dp),
+ )
+ Column(Modifier.weight(1f)) {
+ LoadCityName(geohashStr = geohash) { cityName ->
+ Text(
+ text = cityName,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ )
+ }
+ Text(
+ text = "#$geohash",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ IconButton(onClick = onClear) {
+ Icon(
+ symbol = MaterialSymbols.Close,
+ contentDescription = stringRes(R.string.relay_group_location_clear),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun LabeledSwitchRow(
label: String,
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 2dfb9d6327..60298cd751 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -2356,7 +2356,19 @@
Topics
bitcoin, nostr, art
Location (geohash)
- u0nd
+ Tap the map icon to choose a place
+ Add a location
+ Pin your group on a map so people nearby can discover it.
+ Change location
+ Remove location
+ Enter a geohash manually
+ Choose location
+ Move the map, search for a place, or use your current location.
+ Search for a city or address
+ No matching place found.
+ Use my current location
+ Area size
+ Use this location
Structure
Nest this group under a parent to build a hierarchy.
Parent group
diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf
index 590cbff2c3..e75dfdb2c2 100644
Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt
index 80bac6a653..d8e8b48b29 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt
@@ -161,6 +161,7 @@ object MaterialSymbols {
val MoreVert = MaterialSymbol("\uE5D4")
val MoveToInbox = MaterialSymbol("\uE168")
val MusicNote = MaterialSymbol("\uE405")
+ val MyLocation = MaterialSymbol("\uE55C")
val News = MaterialSymbol("\uE032")
val NoAccounts = MaterialSymbol("\uF03E")
val NoEncryption = MaterialSymbol("\uF03F")