feat(nip29): map-based location picker for group creation

The NIP-29 group create/edit form asked for the group's location as a raw
geohash text field (placeholder "u0nd") — unusable, since nobody knows their
geohash, so the discovery geo-filter it feeds stayed empty.

Replace it with a first-class, map-first location experience, reusing the
pieces that already power the Geohash-chat Teleport screen:

- New GeohashLocationPickerDialog: a full-screen picker where the user pans a
  map under a fixed center pin, searches for a place by name, or taps "use my
  location" (device GPS). Precision chips (GeohashChannelLevel) control the
  area size; the resolved place name is shown via LoadCityName. Never surfaces
  a raw geohash.
- New ForwardGeolocation service (Geocoder.getFromLocationName) powering search,
  mirroring the existing ReverseGeolocation.
- Extend LocationPickerMap with backward-compatible recenter/recenterZoom and
  onCenterChanged hooks for the center-pin interaction; extend LocationPreviewMap
  with a configurable aspectRatio for the form's map thumbnail.
- Rework the form's location field: an inviting empty-state card, a filled card
  with a themed map thumbnail + place name + geohash + clear, and a collapsed
  "enter manually" field so power users can still paste a known geohash.

The ViewModel's geohash state stays the single source of truth, so the publish
path (parseGeohashes -> kind-9002 EditMetadataEvent) is unchanged. Adds the
MyLocation glyph and regenerates the Material Symbols subset font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
This commit is contained in:
Claude
2026-07-18 21:07:44 +00:00
parent 0d534a3149
commit c28c8af13f
8 changed files with 851 additions and 13 deletions
@@ -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<Address>?) -> 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<Address>?) -> Unit,
) {
val listener =
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: List<Address>) {
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<Address>? {
Log.d("ForwardGeoLocation") { "Execute Sync $query" }
return try {
Geocoder(context).getFromLocationName(query, MAX_RESULTS)
} catch (e: IOException) {
Log.w("ForwardGeolocation", "IO Error", e)
null
}
}
}
}
@@ -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<GeoPoint?>(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)
}
}
}
}
@@ -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<GeoPoint>(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)
@@ -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)
@@ -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,
+13 -1
View File
@@ -2356,7 +2356,19 @@
<string name="relay_group_field_topics">Topics</string>
<string name="relay_group_field_topics_hint">bitcoin, nostr, art</string>
<string name="relay_group_field_geohash">Location (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_field_geohash_hint">Tap the map icon to choose a place</string>
<string name="relay_group_location_add">Add a location</string>
<string name="relay_group_location_add_desc">Pin your group on a map so people nearby can discover it.</string>
<string name="relay_group_location_edit">Change location</string>
<string name="relay_group_location_clear">Remove location</string>
<string name="relay_group_location_manual">Enter a geohash manually</string>
<string name="relay_group_location_picker_title">Choose location</string>
<string name="relay_group_location_picker_hint">Move the map, search for a place, or use your current location.</string>
<string name="relay_group_location_search_hint">Search for a city or address</string>
<string name="relay_group_location_search_empty">No matching place found.</string>
<string name="relay_group_location_use_mine">Use my current location</string>
<string name="relay_group_location_precision">Area size</string>
<string name="relay_group_location_confirm">Use this location</string>
<string name="relay_group_section_structure">Structure</string>
<string name="relay_group_parent_desc">Nest this group under a parent to build a hierarchy.</string>
<string name="relay_group_parent_label">Parent group</string>
@@ -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")