From c28c8af13f61cdeb8be1a47cbb1ce1260ea68412 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 21:07:44 +0000 Subject: [PATCH] feat(nip29): map-based location picker for group creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../service/location/ForwardGeolocation.kt | 95 ++++ .../location/GeohashLocationPickerDialog.kt | 507 ++++++++++++++++++ .../creators/location/LocationPickerMap.kt | 42 ++ .../creators/location/LocationPreviewMap.kt | 3 +- .../relayGroup/RelayGroupMetadataScreen.kt | 202 ++++++- amethyst/src/main/res/values/strings.xml | 14 +- .../font/material_symbols_outlined.ttf | Bin 472980 -> 475928 bytes .../commons/icons/symbols/MaterialSymbols.kt | 1 + 8 files changed, 851 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ForwardGeolocation.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt 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 590cbff2c3ec67c1c1ed81bd37c10a48c8ed06ff..e75dfdb2c22e137c8c4d9f4ef257b40537b24a3c 100644 GIT binary patch delta 5245 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 YVpRyLhTIRPHq_-XB<^5c4nNfMPaoxNTmS$7 delta 2244 zcmYLK2~bp57Co=u`warppwPc*nr?_1H33VOZfKevWRXR75iw&c2r3Xo%wR!mf;*@| zJEIg5XB?L(iI%Y)HDq)Y9pl0nM5NH6GAfE22oa3vxXgo^srgm+pa1T1?)~qb`oC_y z+gJT6PY?i({KnuAla>*&GiKL9;7JZZYGX6QQ@3v0d>DAz3($$%Je8(?2cCby_?fW; zc8`w&{q206ys*f)tn%W<9!@g^NIMqsc+FtZ5nx~KIo9UzUaD#$bD`u}t!fls00`MLt`u)1gE0Tajw!Nh{1(z2|F zvsdwiVLozlzQ?jGF+$&1j3WsMhpCkuLEEum+59ePSvx{hM```?eCIg02>g=IByl2~@o zRm8TLcc|vuXN79BQuxC1wXm63KDM$UCL52j9@g#=9%DSppm4eEL7EVq+x zg(EC#C%5gYK~=G8i>lG>LsgY30N}3{ML%3K9k zs(j|UL>Z%0DqWQ)T>h;LRnAvVR@%56aw!57fi65-aaK_v?iOE;R4A&&-HIS_JAcX? zS&KKtYvRAejbfU@tXQkCQq+k*iF|{^_hO_XMG+;xEx#cec|1xqU6$V!lSMB;-YMFO zRQ_E49e*wsZR9_ScJdhDUyp^~tPd>GprMuaMc<>tsC0T2}Ycs`slrRa&Ht!)$DiTX({x9eMNJ4mah(%}P zLQEg=(>EZOc#sKX8u3ImA*e$=I&d9rXonX5_yi4jKx}XgU1-HHZsG>6;tE<2hI2R% z7kI)B&oPKc_=p&B9I-fv19*%cvXJD#7eiz&DS|hG$WoM$6f{DGyF`n75|UouACl-Ot-E!Y|D)*U#j4T??&J8>~&&F4eBl)@y&zcI(FKbh-t)I^7lB zkiVDz0{{K~ZvvtNiUUpr^y@|aRJ}nzPyeO9L4QbpM&B7YCNMCtFt94{kAeLLcf%sX zH-;9&-JmT&eZdogAfY zuOhM|HbmTy92Yq&a${t3}LsJy7b8NoBw%{VsWakNA9$I*+Tzl!dO@r+p?b2;Wk zY)ou*?5Wu6u@7U1;vC{8#D&DA#8t%A#(f)iD(>3MX)`~cIS`*3Ul;%P_*V&rgvE(Q z>%^?Y`$?%uO-XNO<t5Vz27Nj+$C#9dv@X9b|?9aH7 znV7jJ^T}*+cH!*9SyQtrvVNZ9IA{HwJK2fZ+p~Y3YcqH2+($V+IcsyS&a*QnS~~0x z=UM)+@_CMBU0W@oS_GIC?R!V@O#2}J1RT3LEU_HvjJ2_h@2;|zbB&Sr?;#sO*kcO( zxN7E`8=o3Tjk)KkCGr{G5eZ>LO00=3u_q2>9C0QhaUrh6jc7(CHHl0no?KYo#FuD^ z|A^Q~FbO5mB#y+BS>~=@ck{bmZ?kvb#4+Wg3RRGD%O`!mNi10dwvsW7l#gg#+F~hu zuA`=C2hhYq(6k=V^fu6pv!I!cptI{hvsQ!7DFDr$2|9N=XwCspV?AhY18Ckc(EJOa z1+PJu_<$~50$TWI&|=QDtP^xa6zIw;pk;E<@=Varn?Nh=K-YwTuII5$WuRMjz(lw0 z05u1J?&J;YD?uAL@z?u6cXR)rNPNx#1XYOw}w zN(Vj7>zet%7kfdk)Pr8-^|xC=@5X{Y;2e)spaa}D2+$V?LEm08@qbzYm?R2J;6El| z2u#Lt{6R4HUa(2i!KNMon>HWJ+Zs&U1Qrkk7MckbQ39559Bft>*c_gl^9F3eYOuUk zu*KY#c7l~ufUR5vR#^wO&J%3YGqBBP!Mg9d4zT)Kusz9O`*(nSdjahG z0I=f{uphbaZ@kg@B(Mt$z%J^zwSisw4eatgu&W$fc=Of|xp8dEdi_*{XYyAL6(7ee%O2uTwm zB-cX7RzjG!4?^B42nA&jmUDk8_nYbr!Bqwu+>Aa{7FQXYZ;pEF`smB`$@E=KQ Bc6$H- 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")