From c28c8af13f61cdeb8be1a47cbb1ce1260ea68412 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 21:07:44 +0000 Subject: [PATCH 01/14] 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 zcmbVP4Oo;__CI&-ecu^|VLlLM1{h`lO^i%TU_kJTj}VZI6qQIR{4POESs$(N4aam> z$ZehSZ`%5_n@_psKYhfqE!%d}tWB3y^Xt+6ri=0?Nnilyi}~2|&N!~7yYcLOp7WkF z_k7&*o_p@^zVmvwVe9(%kw5@IgAWGk$m4+}1tq?kx_Q;lubwayARGfqe6`i9mJus`0QIZTK4EEf z?SjfFjb8$sT8FQqSXQ^9{?|>P2mohZ1JJTS86-CkdyQ(yjWK-YU4K>`V31AuNrp8k1(t?WC6rW01VNF zI>ThcgN6dbBq45@K?M+}kJIBbCJwg~xipT8?TM?2og5n*8yEX=%s^~z?A+J~V-+#) z$J7DnvtwfM`Gx*D-42~Y^w2l!cIe0HHltJ_{??t*wd%gt{Zcnw|Av0OK1{z&_mwUV zwM3V%pRO;|UeJE4tJZDQ2?e@i+AF$gx>PLmd0m8#)AnokpuAM4(0-;U9OdCQ=>Yfu1BXvH%E_;YSO6Gf72||e6HzJ&l8u`$N^MsYLCjLj#M30ZNjEn zi$qn=s4S?tr0Q15R5O)_lrO22O0%j^HENi0FLp(5_!4Q2?A5?Ni*arZvIe(qEm?0p zDIX~zv#q})QASciN<;d8aWV-lky=vZF+>#g?Pll2Q$Y?SOHWCV3VH=!>Z{akY zg$&4qDtHV2pD5r2T!K^JhI4QRj=?w33X|Y3a2R4>Bq*RCZo+j)A@kq>6u~Zd7doMX zEFg;@4eVqNse?yg9I1unWIF5y1GJG$xGEttT8@OjLxjY%sx)hwJ$-Ka%JdECThsrL z{z>|=^zYJdWXLiqGk%rvPR7xU|I6q%E6hX9dFC4P7W3!k&dkKj>6s0gA7yrCjmml@ z>+e~Pu@lFx8vFLx{bP@eZO_(cKaf2pdvbPl_U7!v**9~-b4KK>%K1Z1_qfsHW{i7o zymtJ`@q5Ofo-k}ei7;W+guhPcnHV)Ob7JYl4<~vi&6)IW?#SGl-0iuia@~1T^48|H z<*V~+^S9@pFVGZBDOghQX+hVcFFks+P+6E$xV-Sq!UKifljA4XP5!V*RWz+=P0@+s zA;r1HmBq`78;aj7-c$Tl@r4quB&H<2B)4Q%$-yc7l+{x%mL`@?EnQnG94YO6>^F~H znOZyb;^X;`uYbI!EWd19*%wbd{KSGMzIdW%+Kg#U)5E7fHNCSuzx>>cNi#m0aed~f znX_k}os}|c#jLky-K;QI?0=Gbvf{~Ym8!~TE6+X^_f*AG*4et*&(HpJPQ;w@Id4>- z#U|k3YlIaif&v8|!f@a4NTq37E-S4I1WrT(x`l259he{)QgQR)fsEn9lZR%cna!CQ zY0+A(E-iW}&r74t=Jer7e3aHaJl#A-8^sR~{m$aa#|{D-MO=4=gu)z zRqLv%5>r$2Qd1LC?lM42L0Ps#wCdQoN;F$nRVLcyrIul?cPBRJq<$uoyHgkv$oB3O zT9()M&2CwAaucCu7;SZ)dMChleA`Fy#9579y)Em{FbR`1NY=D$JXa)-53gPQw20#M z&(3AaNTA#a~Lz=DcUT?`i4n3%1geNj0 zClN$VB1sg{kr)z3;)#hQ299eod6104Lz_l2NG75=9%M3+IXtW(004T@&=UKjUBbwVK^jCX;ZL^=vze(-d>ZIu-vBKu1Ul>Y zK&uQupOzunfj)B?Xmu;lxd(vG+W~a`%Rm<_1iJ7$pi52wU3v;=T?f$T-9T5223o%u z=r7&|y7D~GRT%3JYd!UaE=33P8Z(0#js9#8{4xDx0m81#@9=w}mv z9@z-=EA%^#37<>?din&=Gw6Sf2imy?Xg9{_od(p6b6&Fm)PD(>i~*DH0H$~Wn92-H zodPTdc|7VKN&=R;5Lnt(U|Cy$Hz)E)kn|U2rWhAiqXuD_(u$lwF zmhr&qkya{!y|f>g@D{L*t-yXg7uf5=fNik@dsD~;wtWvUd~^1D1+bk*fxW*T*xuv7 zK6C^7bSbbuWdZvV=l+Td{cRnv)~&!!)Brmv1NQAmV5jxK<gDi(i-x>>~2-nvtBq zE@4t_yMcAA1lIjAu$w+${tDp8KY(MWfD^*2fKw`gi!TQ*X)SOgwZNrK18zJ*M{X=| zQ<8uyTL@f5C2*BTftz0j+@cG>)pY^4`~%=##Q8Ni|EpHuHuVFyZ4+>BW6bxz25!Fu zxX%v(cX&N;U+)C&SP5`vcLR6vW8m8IfV1W9r*%0cH82zrQ%_q$Rm*=kQA_2`u)Cr-zS!pC0h1MgTnXwEU7#nrWPXg`bc#c&wD(sp019Lt{#`i z<99m!oJ__sg@Sqd`#pZ2&rc*0(v*6ytr#RyFXMtfL3^f6mL*RL@*papHJ}FuU)e%;^P&BQiAY9Lc@}I?pnL8zh9};7)=j9Y%*$; z%Km;^`?Yg^KZnEbZ+Ao(jS-G^kVqJlNRS1GC517WUeEJ-y^JxB$Jx>0c6W3*Jsx*Y zkDFJkd67gJ{DQwxP7u^?4a*303KN|iK_`dD!&n#w3p#`xc{ORoWE*86M`B@exo=>g zF>GMKCzspp5{_#$aGb<$NAEC<=Ad#p_1f)TPA=yL?Dm1Cm(f+D?1sK85vI6+A`<6OVY6Mi z9NKgfYTvtUipP~3={(uZPDh%i}Fyxswqqrczb8t{6tC8S{rMVJ&DgD^(C z9*@`OcKakeFTvdwH?MgFnhHO$4aV?zgFZofpWEMvg=qwT((^~PPL~TmQj|)(Yeg3jNCb1+ zY;G0-!B<8W9G299YG@g6D1dqn&fUw-QzDZnqOa=bI}%0iMMaY*4XDS--*97OPYk^QH)fF=1}oBN}` zCHZGi|2F7bgyg}Zyd`)8JQ)|Nqlv?T=6!W|g? zQT0`I$`ryF`Bu-h>doS&|2fXLsS(El7&GFMMIo+yPs$_k*pX0M7YbmZ%~3!>b{8 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") From 11590b7081d52ff3ffb513f45cf22d0dd2d9f1a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 21:21:57 +0000 Subject: [PATCH 02/14] feat(location): polish the geohash picker and generalize it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refinements after the initial map picker: - Dark mode: apply the night tile filter to the interactive LocationPickerMap too, so the picker matches the app theme instead of showing a bright map. - Search results: the place search now shows a tappable list of candidates (with human place names) instead of silently jumping to the first hit. - Precision chips now show approximate area sizes (e.g. "City · ~5 km"). - The center pin lifts with an animated shadow while the target moves. Generalization: the picker's own strings are renamed from relay_group_location_* to neutral location_picker_* keys so any caller can reuse GeohashLocationPickerDialog, not just the NIP-29 group form. The group-form card strings stay namespaced. LocationPreviewMap gains a configurable aspectRatio. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../location/GeohashLocationPickerDialog.kt | 184 ++++++++++++++---- .../creators/location/LocationPickerMap.kt | 7 + .../creators/location/LocationPreviewMap.kt | 2 +- amethyst/src/main/res/values/strings.xml | 15 +- 4 files changed, 159 insertions(+), 49 deletions(-) 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 index bac5713d43..97ae488412 100644 --- 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 @@ -21,7 +21,10 @@ package com.vitorpamplona.amethyst.ui.note.creators.location import android.Manifest +import android.location.Address +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,6 +32,7 @@ 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.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset @@ -40,10 +44,12 @@ 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.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme @@ -142,6 +148,14 @@ fun GeohashLocationPickerDialog( settledCell = cell } + // Lift the center pin briefly whenever the target point moves, for tactile feedback. + var pinLifted by remember { mutableStateOf(false) } + LaunchedEffect(pickedLat, pickedLon) { + pinLifted = true + delay(220) + pinLifted = false + } + // "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). @@ -176,29 +190,34 @@ fun GeohashLocationPickerDialog( } } - // Forward-geocode search. + // Forward-geocode search. Results are shown as a pick list; choosing one flies there. var query by remember { mutableStateOf("") } var searching by remember { mutableStateOf(false) } var searchMissed by remember { mutableStateOf(false) } + var results by remember { mutableStateOf>(emptyList()) } val runSearch = { val q = query.trim() if (q.isNotEmpty()) { keyboard?.hide() searching = true searchMissed = false + results = emptyList() 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 - } + val hits = addresses.orEmpty().filter { it.hasLatitude() && it.hasLongitude() } + results = hits + searchMissed = hits.isEmpty() } } } + val selectResult: (Address) -> Unit = { hit -> + recenter = GeoPoint(hit.latitude, hit.longitude) + pickedLat = hit.latitude + pickedLon = hit.longitude + results = emptyList() + query = "" + keyboard?.hide() + } Dialog( onDismissRequest = onDismiss, @@ -229,23 +248,33 @@ fun GeohashLocationPickerDialog( modifier = Modifier.fillMaxSize(), ) - CenterPin(Modifier.align(Alignment.Center)) + CenterPin(lifted = pinLifted, modifier = 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), - ) + Column( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(12.dp), + ) { + SearchField( + query = query, + onQueryChange = { + query = it + searchMissed = false + }, + onSearch = runSearch, + searching = searching, + missed = searchMissed, + modifier = Modifier.fillMaxWidth(), + ) + if (results.isNotEmpty()) { + SearchResults( + results = results, + onSelect = selectResult, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) + } + } MyLocationButton( loading = wantsMyLocation || awaitingPermission, @@ -292,7 +321,7 @@ private fun PickerHeader(onClose: () -> Unit) { ) } Text( - text = stringRes(R.string.relay_group_location_picker_title), + text = stringRes(R.string.location_picker_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 4.dp), @@ -301,11 +330,22 @@ private fun PickerHeader(onClose: () -> Unit) { } } -/** The fixed pin that hovers over the map center; the point under its tip is the selection. */ +/** + * The fixed pin hovering over the map center; the point under its tip is the selection. + * While [lifted] (the target is moving) the pin rises off the map and its shadow dot + * widens, for the tactile "dropping a pin" feel of a modern place picker. + */ @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. +private fun CenterPin( + lifted: Boolean, + modifier: Modifier = Modifier, +) { + val lift by animateDpAsState(targetValue = if (lifted) 8.dp else 0.dp, label = "pinLift") + val dot by animateDpAsState(targetValue = if (lifted) 10.dp else 7.dp, label = "pinDot") + + Box(modifier = modifier.size(56.dp), contentAlignment = Alignment.Center) { + // The pin's tip sits at the map center; lift the whole glyph up by half its height + // plus the animated hover offset. Icon( symbol = MaterialSymbols.LocationOn, contentDescription = null, @@ -313,18 +353,59 @@ private fun CenterPin(modifier: Modifier = Modifier) { modifier = Modifier .size(46.dp) - .offset(y = (-20).dp), + .offset(y = -20.dp - lift), ) - // A small anchor dot marking the exact center point. + // A shadow/anchor dot marking the exact center point; it spreads as the pin lifts. Box( Modifier - .size(7.dp) - .shadow(2.dp, CircleShape) + .size(dot) + .shadow(if (lifted) 4.dp else 2.dp, CircleShape) .background(MaterialTheme.colorScheme.primary, CircleShape), ) } } +/** A tappable list of forward-geocode candidates shown under the search field. */ +@Composable +private fun SearchResults( + results: List
, + onSelect: (Address) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.shadow(6.dp, RoundedCornerShape(16.dp)), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Column(Modifier.fillMaxWidth().heightIn(max = 240.dp).verticalScroll(rememberScrollState())) { + results.forEachIndexed { index, address -> + if (index > 0) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Row( + Modifier + .fillMaxWidth() + .clickable { onSelect(address) } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Text( + text = address.displayLine(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + ) + } + } + } + } +} + @Composable private fun SearchField( query: String, @@ -343,7 +424,7 @@ private fun SearchField( value = query, onValueChange = onQueryChange, singleLine = true, - placeholder = { Text(stringRes(R.string.relay_group_location_search_hint)) }, + placeholder = { Text(stringRes(R.string.location_picker_search_hint)) }, leadingIcon = { Icon( symbol = MaterialSymbols.Search, @@ -369,7 +450,7 @@ private fun SearchField( isError = missed, supportingText = if (missed) { - { Text(stringRes(R.string.relay_group_location_search_empty)) } + { Text(stringRes(R.string.location_picker_search_empty)) } } else { null }, @@ -401,7 +482,7 @@ private fun MyLocationButton( } else { Icon( symbol = MaterialSymbols.MyLocation, - contentDescription = stringRes(R.string.relay_group_location_use_mine), + contentDescription = stringRes(R.string.location_picker_use_mine), modifier = Modifier.size(24.dp), ) } @@ -431,7 +512,7 @@ private fun PickerBottomBar( .padding(16.dp), ) { Text( - text = stringRes(R.string.relay_group_location_precision), + text = stringRes(R.string.location_picker_area), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -446,14 +527,14 @@ private fun PickerBottomBar( FilterChip( selected = lvl == level, onClick = { onLevel(lvl) }, - label = { Text(lvl.label()) }, + label = { Text("${lvl.label()} · ${lvl.areaSize()}") }, ) } } if (cell == null) { Text( - text = stringRes(R.string.relay_group_location_picker_hint), + text = stringRes(R.string.location_picker_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 8.dp), @@ -500,8 +581,29 @@ private fun PickerBottomBar( enabled = cell != null, modifier = Modifier.fillMaxWidth(), ) { - Text(stringRes(R.string.relay_group_location_confirm), fontWeight = FontWeight.SemiBold) + Text(stringRes(R.string.location_picker_confirm), fontWeight = FontWeight.SemiBold) } } } } + +/** + * A human, one-line label for a geocoder result: its most specific name, then the + * enclosing locality/region/country, de-duplicated. Falls back to the full address line. + */ +private fun Address.displayLine(): String { + val primary = featureName ?: locality ?: subAdminArea ?: adminArea ?: getAddressLine(0) + val context = listOfNotNull(locality, adminArea, countryName).distinct().filter { it != primary } + return listOfNotNull(primary, context.joinToString(", ").ifBlank { null }).joinToString(", ") +} + +/** A rough physical size for a geohash cell at this precision, for the chip subtitle. */ +private fun GeohashChannelLevel.areaSize(): String = + when (this) { + GeohashChannelLevel.REGION -> "~1250 km" + GeohashChannelLevel.PROVINCE -> "~39 km" + GeohashChannelLevel.CITY -> "~5 km" + GeohashChannelLevel.NEIGHBORHOOD -> "~1.2 km" + GeohashChannelLevel.BLOCK -> "~150 m" + GeohashChannelLevel.BUILDING -> "~38 m" + } 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 82f0003f7f..f5b0db80c1 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.location +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -31,6 +32,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.ui.theme.isLight import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver import org.osmdroid.events.MapListener @@ -76,6 +78,7 @@ fun LocationPickerMap( val lifecycleOwner = LocalLifecycleOwner.current val currentOnPick by rememberUpdatedState(onPick) val currentOnCenterChanged by rememberUpdatedState(onCenterChanged) + val darkTheme = !MaterialTheme.colorScheme.isLight // 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. @@ -151,6 +154,10 @@ fun LocationPickerMap( } } + // Follow the app theme: dim the bright MAPNIK tiles in dark mode, matching + // the display-only LocationPreviewMap. + map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + 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 307c7e6856..63f3940390 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 @@ -57,7 +57,7 @@ private const val DEFAULT_ZOOM = 16.0 * unlike a plain colour invert (forests → magenta) or a desaturating invert * (everything → black). */ -private val NIGHT_TILE_FILTER: ColorFilter = +internal val NIGHT_TILE_FILTER: ColorFilter = ColorMatrixColorFilter( floatArrayOf( 0.574f, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 60298cd751..62c07a8c42 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2362,13 +2362,14 @@ 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 + + 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 From 8658dcd5126819164709fbd056c0be9e65d27a12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 22:36:47 +0000 Subject: [PATCH 03/14] refactor(geohash): reuse the shared picker in the Teleport screen Extract the picker body into GeohashLocationPickerContent (chrome-less: map + search + use-my-location + precision + readout + confirm), leaving GeohashLocationPickerDialog as a thin dialog wrapper around it. Rewrite GeohashTeleportScreen to host that shared content inside its own scaffold, passing a teleport-specific confirm label and action (follow the cell + open the chat with the teleport flag). This dedupes the map/precision/city-name code that Teleport previously copied, and gives Teleport place search, "use my location", dark-mode tiles, area-size chips, and the animated pin for free. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../location/GeohashLocationPickerDialog.kt | 193 ++++++++++-------- .../geohashChat/GeohashTeleportScreen.kt | 150 ++------------ amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 133 insertions(+), 212 deletions(-) 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 index 97ae488412..8a9ddf00d1 100644 --- 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 @@ -99,25 +99,59 @@ private const val SEEDED_ZOOM = 13.0 private const val WORLD_ZOOM = 2.5 /** - * A full-screen, map-first location picker that produces a geohash string. + * A full-screen, map-first location picker dialog 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. + * Thin chrome around [GeohashLocationPickerContent]: a close button + title header + * over the shared picker body. Use this when you need the picker as a modal (e.g. + * the NIP-29 group form); use [GeohashLocationPickerContent] directly when hosting + * it inside your own screen scaffold (e.g. the Geohash-chat Teleport screen). */ -@OptIn(ExperimentalPermissionsApi::class) @Composable fun GeohashLocationPickerDialog( initialGeohash: String?, onDismiss: () -> Unit, onConfirm: (String) -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column(Modifier.fillMaxSize()) { + PickerHeader(onClose = onDismiss) + GeohashLocationPickerContent( + initialGeohash = initialGeohash, + confirmLabel = stringRes(R.string.location_picker_confirm), + onConfirm = onConfirm, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } + } + } +} + +/** + * The reusable, chrome-less body of the location picker: a map with a fixed center + * pin, a place-search bar, a "use my location" button, precision chips, a live + * place-name readout, and a confirm button labeled [confirmLabel]. Fill it into a + * dialog ([GeohashLocationPickerDialog]) or a screen scaffold. + * + * The user has three ways to land on a place — pan the map under the 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. + * 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. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun GeohashLocationPickerContent( + initialGeohash: String?, + confirmLabel: String, + onConfirm: (String) -> Unit, + modifier: Modifier = Modifier, ) { val context = LocalContext.current val keyboard = LocalSoftwareKeyboardController.current @@ -219,82 +253,74 @@ fun GeohashLocationPickerDialog( keyboard?.hide() } - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { - Column(Modifier.fillMaxSize()) { - PickerHeader(onClose = onDismiss) + Column(modifier.fillMaxWidth()) { + 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(), + ) - 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(lifted = pinLifted, modifier = Modifier.align(Alignment.Center)) - CenterPin(lifted = pinLifted, modifier = Modifier.align(Alignment.Center)) - - Column( - Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .padding(12.dp), - ) { - SearchField( - query = query, - onQueryChange = { - query = it - searchMissed = false - }, - onSearch = runSearch, - searching = searching, - missed = searchMissed, - modifier = Modifier.fillMaxWidth(), - ) - if (results.isNotEmpty()) { - SearchResults( - results = results, - onSelect = selectResult, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - ) - } - } - - MyLocationButton( - loading = wantsMyLocation || awaitingPermission, - onClick = onUseMyLocation, - modifier = - Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), + Column( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(12.dp), + ) { + SearchField( + query = query, + onQueryChange = { + query = it + searchMissed = false + }, + onSearch = runSearch, + searching = searching, + missed = searchMissed, + modifier = Modifier.fillMaxWidth(), + ) + if (results.isNotEmpty()) { + SearchResults( + results = results, + onSelect = selectResult, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), ) } - - PickerBottomBar( - cell = cell, - settledCell = settledCell, - level = level, - onLevel = { level = it }, - onConfirm = { cell?.let(onConfirm) }, - ) } + + MyLocationButton( + loading = wantsMyLocation || awaitingPermission, + onClick = onUseMyLocation, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) } + + PickerBottomBar( + cell = cell, + settledCell = settledCell, + level = level, + confirmLabel = confirmLabel, + onLevel = { level = it }, + onConfirm = { cell?.let(onConfirm) }, + ) } } @@ -494,6 +520,7 @@ private fun PickerBottomBar( cell: String?, settledCell: String?, level: GeohashChannelLevel, + confirmLabel: String, onLevel: (GeohashChannelLevel) -> Unit, onConfirm: () -> Unit, ) { @@ -581,7 +608,7 @@ private fun PickerBottomBar( enabled = cell != null, modifier = Modifier.fillMaxWidth(), ) { - Text(stringRes(R.string.location_picker_confirm), fontWeight = FontWeight.SemiBold) + Text(confirmLabel, fontWeight = FontWeight.SemiBold) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt index d7f2611d31..2f3f4d8db2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt @@ -20,163 +20,55 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat -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.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPickerMap +import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon +import com.vitorpamplona.amethyst.ui.stringRes /** - * Teleport: tap a point on the map to join a remote geohash cell (at a chosen + * Teleport: pick a point on the map to join a remote geohash cell (at a chosen * precision level) you are not physically in. Joining follows the cell (kind * 10081) and opens the chat with the teleport flag set, so outgoing messages * carry the ["t","teleport"] marker. + * + * The map/search/precision UI is the shared [GeohashLocationPickerContent]; this + * screen only supplies the top bar and the teleport-specific confirm action. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun GeohashTeleportScreen( accountViewModel: AccountViewModel, nav: INav, ) { - var pickedLat by remember { mutableStateOf(null) } - var pickedLon by remember { mutableStateOf(null) } - var level by remember { mutableStateOf(GeohashChannelLevel.CITY) } - - 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 - } - Scaffold( topBar = { TopBarExtensibleWithBackButton( - title = { Text("Teleport", fontWeight = FontWeight.Bold) }, + title = { Text(stringRes(R.string.geohash_teleport_title), fontWeight = FontWeight.Bold) }, popBack = nav::popBack, ) }, ) { pad -> - Column( - Modifier - .fillMaxSize() - .padding(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding()), - ) { - LocationPickerMap( - latitude = 20.0, - longitude = 0.0, - pickedLatitude = pickedLat, - pickedLongitude = pickedLon, - modifier = Modifier.weight(1f).fillMaxWidth(), - onPick = { lat, lon -> - pickedLat = lat - pickedLon = lon - }, - ) - - 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().padding(16.dp)) { - Text( - if (cell == null) "Tap the map to pick a spot" else "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 = { level = lvl }, - label = { Text(lvl.label()) }, - ) - } - } - - if (cell != null) { - 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) { - SymbolIcon( - symbol = MaterialSymbols.LocationOn, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(22.dp), - ) - } - } - Column(Modifier.weight(1f).padding(start = 12.dp)) { - LoadCityName(geohashStr = 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 = { - accountViewModel.followGeohash(cell) - nav.popBack() - nav.nav(Route.GeohashChat(cell, teleported = true)) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("✈ Teleport here", fontWeight = FontWeight.SemiBold) - } - } - } - } - } + GeohashLocationPickerContent( + initialGeohash = null, + confirmLabel = stringRes(R.string.geohash_teleport_action), + onConfirm = { cell -> + accountViewModel.followGeohash(cell) + nav.popBack() + nav.nav(Route.GeohashChat(cell, teleported = true)) + }, + modifier = + Modifier + .fillMaxSize() + .padding(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding()), + ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 62c07a8c42..b488c55f4d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2147,6 +2147,8 @@ Expose Location as Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location + Teleport + ✈ Teleport here Location-exclusive Post Only followers of the location will see it. Your general followers won\'t see it. From 7b32ca1da339afd3abb54cc3f098ea0f8eeb6abd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 22:57:55 +0000 Subject: [PATCH 04/14] feat(composer): add "pick on map" location to the short-note composer The composer could only attach the current GPS location (fixed ~5km), which fails when GPS is denied/unavailable and can't tag a post with a different place. Add a "pick a place on the map" action that opens the shared GeohashLocationPickerDialog and stores the result in ShortNotePostViewModel.pickedGeoHash, which overrides the GPS fix at build time and round-trips through drafts. Picking a place also skips the GPS permission prompt. The existing "use my location" GPS flow is unchanged; this is additive. Scoped to the short-note composer for now; the other ILocationGrabber composers (long-form, classifieds, DMs, comments, channel/nest messages) can adopt the same pickedGeoHash override + section in a follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../loggedIn/home/ShortNotePostScreen.kt | 96 ++++++++++++++++--- .../loggedIn/home/ShortNotePostViewModel.kt | 18 +++- amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 342e495a1d..9e4ebe9d63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll @@ -106,6 +107,8 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton +import com.vitorpamplona.amethyst.ui.note.creators.location.DisplayLocationInTitle +import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying @@ -527,19 +530,7 @@ private fun NewPostScreenBody( } if (postViewModel.wantsToAddGeoHash) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), - ) { - LocationAsHash(postViewModel) { - SettingsRow( - R.string.geohash_exclusive, - R.string.geohash_exclusive_explainer, - ) { - Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) - } - } - } + GeoHashPostSection(postViewModel) } if (postViewModel.wantsForwardZapTo) { @@ -881,6 +872,85 @@ private fun BottomRowActions( } } +/** + * The composer's location section. Defaults to the device GPS flow ([LocationAsHash]), + * but a "pick on map" action opens the shared [GeohashLocationPickerDialog] and stores + * the chosen geohash in [ShortNotePostViewModel.pickedGeoHash], which then overrides GPS + * at build time. Picking a place also skips the GPS permission prompt. + */ +@Composable +private fun GeoHashPostSection(postViewModel: ShortNotePostViewModel) { + var showPicker by remember { mutableStateOf(false) } + val picked = postViewModel.pickedGeoHash + + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = Size10dp, horizontal = Size10dp), + ) { + if (picked != null) { + // A map-picked place: show it, and let the user clear back to GPS. + Row(verticalAlignment = CenterVertically, modifier = Modifier.fillMaxWidth()) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.geohash_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + DisplayLocationInTitle(geohash = picked) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { postViewModel.pickedGeoHash = null }) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.remove_location), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + SettingsRow(R.string.geohash_exclusive, R.string.geohash_exclusive_explainer) { + Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) + } + } else { + // GPS mode (unchanged): current device location + the exclusive-post switch. + LocationAsHash(postViewModel) { + SettingsRow(R.string.geohash_exclusive, R.string.geohash_exclusive_explainer) { + Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) + } + } + } + + TextButton(onClick = { showPicker = true }, modifier = Modifier.padding(top = 4.dp)) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(if (picked != null) R.string.location_change_place else R.string.location_pick_on_map), + modifier = Modifier.padding(start = 6.dp), + ) + } + } + + if (showPicker) { + GeohashLocationPickerDialog( + initialGeohash = picked, + onDismiss = { showPicker = false }, + onConfirm = { cell -> + postViewModel.pickedGeoHash = cell + showPicker = false + }, + ) + } +} + @SuppressLint("ViewModelConstructorInComposable") @Preview @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index ea22e857a2..43e64bbae2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -329,6 +329,12 @@ open class ShortNotePostViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) var location: StateFlow? = null + + /** + * A geohash the user picked on the map, which overrides the live GPS location at + * build time. Null means "use my current GPS location" (the default behavior). + */ + var pickedGeoHash by mutableStateOf(null) var wantsExclusiveGeoPost by mutableStateOf(false) // ZapRaiser @@ -753,6 +759,7 @@ open class ShortNotePostViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash if (geohash != null) { wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } @@ -858,6 +865,7 @@ open class ShortNotePostViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash if (geohash != null) { wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } @@ -931,6 +939,7 @@ open class ShortNotePostViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash if (geohash != null) { wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } @@ -1236,7 +1245,13 @@ open class ShortNotePostViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = + if (wantsToAddGeoHash) { + // A map-picked geohash wins over the live GPS fix. + pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + } else { + null + } val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null val emojis = account.emoji.findEmojiTags(tagger.message) @@ -1563,6 +1578,7 @@ open class ShortNotePostViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsExclusiveGeoPost = false wantsSecretEmoji = false wantsAnonymousPost = false diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b488c55f4d..d6a11e6af2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2149,6 +2149,8 @@ Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location Teleport ✈ Teleport here + Pick a place on the map + Change place on the map Location-exclusive Post Only followers of the location will see it. Your general followers won\'t see it. From fea9bcb8ea5239f5441171146eff5b78fc237441 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 23:29:46 +0000 Subject: [PATCH 05/14] feat(composer): extend map location picker to all composers Lift pickedGeoHash into the shared ILocationGrabber interface and add a shared GeoHashPostSection composable (GPS default + "pick on map"), then wire it into every remaining composer: long-form, classifieds, private DMs, public messages, NIP-22 comments, channel messages, and nest messages. Each ViewModel now honors the map-picked geohash over GPS at build time and round-trips it through drafts; each screen renders the shared section in place of the GPS-only LocationAsHash. The short-note composer is refactored onto the same shared section (its local copy removed). The geohash-chat "New location channel" screen already reaches the picker via its Teleport card, which now uses the shared, polished picker. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../creators/location/GeoHashPostSection.kt | 127 ++++++++++++++++++ .../creators/location/ILocationGrabber.kt | 7 + .../nip22Comments/CommentPostViewModel.kt | 6 +- .../nip22Comments/GenericCommentPostScreen.kt | 4 +- .../privateDM/send/ChatNewMessageViewModel.kt | 5 +- .../chats/privateDM/send/NewGroupDMScreen.kt | 4 +- .../send/ChannelNewMessageViewModel.kt | 5 +- .../nip23LongForm/LongFormPostScreen.kt | 4 +- .../nip23LongForm/LongFormPostViewModel.kt | 5 +- .../nip99Classifieds/NewProductScreen.kt | 4 +- .../nip99Classifieds/NewProductViewModel.kt | 5 +- .../loggedIn/home/ShortNotePostScreen.kt | 92 ++----------- .../loggedIn/home/ShortNotePostViewModel.kt | 6 +- .../room/chat/NestNewMessageViewModel.kt | 5 +- .../publicMessages/NewPublicMessageScreen.kt | 4 +- .../NewPublicMessageViewModel.kt | 5 +- 16 files changed, 183 insertions(+), 105 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt new file mode 100644 index 0000000000..5196bf7284 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt @@ -0,0 +1,127 @@ +/* + * 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 androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +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.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp + +/** + * The shared location section for post composers. Defaults to the device-GPS flow + * ([LocationAsHash]), and adds a "pick a place on the map" action that opens + * [GeohashLocationPickerDialog] and stores the chosen geohash in + * [ILocationGrabber.pickedGeoHash] — which each composer's build step then prefers + * over the live GPS fix. Picking a place also skips the GPS permission prompt. + * + * [innerContent] is a slot for composer-specific extras rendered beneath the + * location readout (e.g. the "location-exclusive post" switch). + */ +@Composable +fun GeoHashPostSection( + model: ILocationGrabber, + innerContent: @Composable () -> Unit = {}, +) { + var showPicker by remember { mutableStateOf(false) } + val picked = model.pickedGeoHash + + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = Size10dp, horizontal = Size10dp), + ) { + if (picked != null) { + // A map-picked place: show it, and let the user clear back to GPS. + Row(verticalAlignment = CenterVertically, modifier = Modifier.fillMaxWidth()) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.geohash_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + DisplayLocationInTitle(geohash = picked) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { model.pickedGeoHash = null }) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.remove_location), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + innerContent() + } else { + // GPS mode (unchanged): current device location + the composer's extras. + LocationAsHash(model, innerContent) + } + + TextButton(onClick = { showPicker = true }, modifier = Modifier.padding(top = 4.dp)) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(if (picked != null) R.string.location_change_place else R.string.location_pick_on_map), + modifier = Modifier.padding(start = 6.dp), + ) + } + } + + if (showPicker) { + GeohashLocationPickerDialog( + initialGeohash = picked, + onDismiss = { showPicker = false }, + onConfirm = { cell -> + model.pickedGeoHash = cell + showPicker = false + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt index 105ca1c223..6940f786ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt @@ -27,4 +27,11 @@ interface ILocationGrabber { fun locationManager(): LocationState fun locationFlow(): StateFlow + + /** + * A geohash the user picked on the map (via [GeohashLocationPickerDialog]), which + * overrides the live GPS location at build time. Null means "use my current GPS + * location" — the default behavior. Implementers back this with a Compose state. + */ + var pickedGeoHash: String? } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 79bc61906c..d52c964d8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -242,6 +243,7 @@ open class CommentPostViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -510,6 +512,7 @@ open class CommentPostViewModel : replyingTo?.let { observeCommunityRules(it) } wantsToAddGeoHash = draftEvent.hasGeohashes() + pickedGeoHash = draftEvent.getGeoHash() notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } @@ -641,7 +644,7 @@ open class CommentPostViewModel : ) tagger.run() - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val geoHash = (if (wantsToAddGeoHash) pickedGeoHash else null) ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) @@ -867,6 +870,7 @@ open class CommentPostViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsSecretEmoji = false wantsAnonymousPost = false anonymousSignerCache = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index e67b48fc9e..12d9ea78d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -72,7 +72,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton @@ -330,7 +330,7 @@ private fun GenericCommentPostBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - LocationAsHash(postViewModel) + GeoHashPostSection(postViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 2d92030b7b..b100b957b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -246,6 +246,7 @@ class ChatNewMessageViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -370,6 +371,7 @@ class ChatNewMessageViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -579,7 +581,7 @@ class ChatNewMessageViewModel : val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val message = messageText val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null @@ -649,6 +651,7 @@ class ChatNewMessageViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 8941be093f..d87f54987d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -100,7 +100,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl @@ -250,7 +250,7 @@ fun GroupDMScreenContent( } if (postViewModel.wantsToAddGeoHash) { - LocationAsHash(postViewModel) + GeoHashPostSection(postViewModel) } if (postViewModel.wantsForwardZapTo) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 17a13aee09..4fd7a5b155 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -193,6 +193,7 @@ open class ChannelNewMessageViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // Geohash location chat (Bitchat interop): messages are signed with an anonymous per-cell @@ -299,6 +300,7 @@ open class ChannelNewMessageViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -507,7 +509,7 @@ open class ChannelNewMessageViewModel : val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val channelRelays = channel.relays() - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null @@ -731,6 +733,7 @@ open class ChannelNewMessageViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null forwardZapTo = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 93c8a6b0ae..2465929d40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -106,7 +106,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription @@ -442,7 +442,7 @@ private fun MarkdownPostScreenBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - LocationAsHash(postViewModel) { + GeoHashPostSection(postViewModel) { SettingsRow( R.string.geohash_exclusive, R.string.geohash_exclusive_explainer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 47686de456..7dd5c3c036 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -205,6 +205,7 @@ class LongFormPostViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null var wantsExclusiveGeoPost by mutableStateOf(false) @@ -328,6 +329,7 @@ class LongFormPostViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash if (geohash != null) { wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } @@ -411,7 +413,7 @@ class LongFormPostViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null val emojis = account.emoji.findEmojiTags(tagger.message) @@ -631,6 +633,7 @@ class LongFormPostViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsExclusiveGeoPost = false wantsSecretEmoji = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 9f32382d97..2e95142599 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -65,7 +65,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton @@ -250,7 +250,7 @@ private fun NewProductBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - LocationAsHash(postViewModel) + GeoHashPostSection(postViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 6e7554d7dd..fce9567e51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -185,6 +185,7 @@ open class NewProductViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -284,6 +285,7 @@ open class NewProductViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -353,7 +355,7 @@ open class NewProductViewModel : val urls = findURLs(tagger.message) val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null @@ -484,6 +486,7 @@ open class NewProductViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 9e4ebe9d63..db7e95f3be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -107,9 +107,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.DisplayLocationInTitle -import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField @@ -530,7 +528,14 @@ private fun NewPostScreenBody( } if (postViewModel.wantsToAddGeoHash) { - GeoHashPostSection(postViewModel) + GeoHashPostSection(postViewModel) { + SettingsRow( + R.string.geohash_exclusive, + R.string.geohash_exclusive_explainer, + ) { + Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) + } + } } if (postViewModel.wantsForwardZapTo) { @@ -872,85 +877,6 @@ private fun BottomRowActions( } } -/** - * The composer's location section. Defaults to the device GPS flow ([LocationAsHash]), - * but a "pick on map" action opens the shared [GeohashLocationPickerDialog] and stores - * the chosen geohash in [ShortNotePostViewModel.pickedGeoHash], which then overrides GPS - * at build time. Picking a place also skips the GPS permission prompt. - */ -@Composable -private fun GeoHashPostSection(postViewModel: ShortNotePostViewModel) { - var showPicker by remember { mutableStateOf(false) } - val picked = postViewModel.pickedGeoHash - - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = Size10dp, horizontal = Size10dp), - ) { - if (picked != null) { - // A map-picked place: show it, and let the user clear back to GPS. - Row(verticalAlignment = CenterVertically, modifier = Modifier.fillMaxWidth()) { - Icon( - symbol = MaterialSymbols.LocationOn, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringRes(R.string.geohash_title), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.W500, - modifier = Modifier.padding(start = 10.dp), - ) - DisplayLocationInTitle(geohash = picked) - Spacer(modifier = Modifier.weight(1f)) - IconButton(onClick = { postViewModel.pickedGeoHash = null }) { - Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.remove_location), - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - HorizontalDivider() - SettingsRow(R.string.geohash_exclusive, R.string.geohash_exclusive_explainer) { - Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) - } - } else { - // GPS mode (unchanged): current device location + the exclusive-post switch. - LocationAsHash(postViewModel) { - SettingsRow(R.string.geohash_exclusive, R.string.geohash_exclusive_explainer) { - Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) - } - } - } - - TextButton(onClick = { showPicker = true }, modifier = Modifier.padding(top = 4.dp)) { - Icon( - symbol = MaterialSymbols.LocationOn, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Text( - text = stringRes(if (picked != null) R.string.location_change_place else R.string.location_pick_on_map), - modifier = Modifier.padding(start = 6.dp), - ) - } - } - - if (showPicker) { - GeohashLocationPickerDialog( - initialGeohash = picked, - onDismiss = { showPicker = false }, - onConfirm = { cell -> - postViewModel.pickedGeoHash = cell - showPicker = false - }, - ) - } -} - @SuppressLint("ViewModelConstructorInComposable") @Preview @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 43e64bbae2..e2d1fd6afb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -330,11 +330,7 @@ open class ShortNotePostViewModel : var wantsToAddGeoHash by mutableStateOf(false) var location: StateFlow? = null - /** - * A geohash the user picked on the map, which overrides the live GPS location at - * build time. Null means "use my current GPS location" (the default behavior). - */ - var pickedGeoHash by mutableStateOf(null) + override var pickedGeoHash by mutableStateOf(null) var wantsExclusiveGeoPost by mutableStateOf(false) // ZapRaiser diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 63a55b94c4..e7e86b5f3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -178,6 +178,7 @@ open class NestNewMessageViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -272,6 +273,7 @@ open class NestNewMessageViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -427,7 +429,7 @@ open class NestNewMessageViewModel : val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null @@ -482,6 +484,7 @@ open class NestNewMessageViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null forwardZapTo = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index f10bd8db4e..56e1382b6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest @@ -233,7 +233,7 @@ fun PublicMessageScreenContent( } if (postViewModel.wantsToAddGeoHash) { - LocationAsHash(postViewModel) + GeoHashPostSection(postViewModel) } if (postViewModel.wantsForwardZapTo) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 68ffbf6006..4007134010 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -197,6 +197,7 @@ class NewPublicMessageViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -300,6 +301,7 @@ class NewPublicMessageViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -388,7 +390,7 @@ class NewPublicMessageViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val geoHash = (if (wantsToAddGeoHash) pickedGeoHash else null) ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null val emojis = account.emoji.findEmojiTags(tagger.message) @@ -528,6 +530,7 @@ class NewPublicMessageViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() From 78e641b3b283f6af0466cd39643e233024434ef3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 23:43:02 +0000 Subject: [PATCH 06/14] fix(location): audit fixes for the geohash picker Bugs: - "Use my location" could spin forever when a fix never arrives (location off, indoors, permission granted but unavailable). Wait with a 20s timeout and stop on any terminal outcome so the button always resets. - With no seed, an initial osmdroid layout-scroll at the opening center could be mistaken for a pick, auto-selecting the mid-Atlantic and enabling Confirm before the user moved. Gate selection behind a real pan/search/GPS/tap (hasSelection). Performance: - The map's AndroidView update ran setColorFilter + overlays.removeAll + invalidate() on every scroll-driven recomposition. Move the dark-mode tile filter to a theme-keyed effect, and rebuild the marker + invalidate only when the marker point actually changes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../location/GeohashLocationPickerDialog.kt | 49 ++++++++++++++++--- .../creators/location/LocationPickerMap.kt | 44 +++++++++++------ 2 files changed, 71 insertions(+), 22 deletions(-) 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 index 8a9ddf00d1..cba3c257ef 100644 --- 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 @@ -87,7 +87,9 @@ 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 kotlinx.coroutines.withTimeoutOrNull import org.osmdroid.util.GeoPoint +import kotlin.math.abs /** Zoom the map animates to after a search hit or a "use my location" tap. */ private const val RECENTER_ZOOM = 14.0 @@ -98,6 +100,19 @@ 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 +/** Neutral starting center (mid-Atlantic) when the picker opens with no seed. */ +private const val WORLD_CENTER_LAT = 20.0 +private const val WORLD_CENTER_LON = 0.0 + +/** + * Minimum center shift (degrees) from the opening center that counts as a real pan, + * so an initial osmdroid layout-scroll at the opening center is not mistaken for a pick. + */ +private const val SELECT_MOVE_EPS = 0.0005 + +/** Give up waiting for a GPS fix after this long so the button never spins forever. */ +private const val GPS_FIX_TIMEOUT_MS = 20_000L + /** * A full-screen, map-first location picker dialog that produces a geohash string. * @@ -160,18 +175,25 @@ fun GeohashLocationPickerContent( val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } } val seedLen = initialGeohash?.trim()?.length ?: 0 + // The map opens centered here. Without a seed there is no real selection yet — and + // osmdroid can emit an initial scroll at this exact center, which must NOT be treated + // as a pick (else the picker would auto-select the mid-Atlantic and enable Confirm). + val initialLat = seed?.centerLat ?: WORLD_CENTER_LAT + val initialLon = seed?.centerLon ?: WORLD_CENTER_LON + var pickedLat by remember { mutableStateOf(seed?.centerLat) } var pickedLon by remember { mutableStateOf(seed?.centerLon) } + var hasSelection by remember { mutableStateOf(seed != null) } var level by remember { mutableStateOf(GeohashChannelLevel.forChars(seedLen) ?: GeohashChannelLevel.CITY) } var recenter by remember { mutableStateOf(null) } val cell = - remember(pickedLat, pickedLon, level) { + remember(pickedLat, pickedLon, level, hasSelection) { val lat = pickedLat val lon = pickedLon - if (lat != null && lon != null) GeoHash.encode(lat, lon, level.chars).toString() else null + if (hasSelection && 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 @@ -207,11 +229,18 @@ fun GeohashLocationPickerContent( } 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 + // Wait for the first real fix, but bail on a timeout so the button never spins + // forever (permission granted yet location off, indoors, emulator with no fix…). + val fix = + withTimeoutOrNull(GPS_FIX_TIMEOUT_MS) { + locationManager.preciseGeohashStateFlow.first { it is LocationState.LocationResult.Success } + } as? LocationState.LocationResult.Success + if (fix != null) { + recenter = GeoPoint(fix.geoHash.centerLat, fix.geoHash.centerLon) + pickedLat = fix.geoHash.centerLat + pickedLon = fix.geoHash.centerLon + hasSelection = true + } wantsMyLocation = false } } @@ -248,6 +277,7 @@ fun GeohashLocationPickerContent( recenter = GeoPoint(hit.latitude, hit.longitude) pickedLat = hit.latitude pickedLon = hit.longitude + hasSelection = true results = emptyList() query = "" keyboard?.hide() @@ -266,11 +296,16 @@ fun GeohashLocationPickerContent( onCenterChanged = { lat, lon -> pickedLat = lat pickedLon = lon + // A pan/zoom away from the opening center is the user's first real pick. + if (!hasSelection && (abs(lat - initialLat) > SELECT_MOVE_EPS || abs(lon - initialLon) > SELECT_MOVE_EPS)) { + hasSelection = true + } }, onPick = { lat, lon -> recenter = GeoPoint(lat, lon) pickedLat = lat pickedLon = lon + hasSelection = true }, modifier = Modifier.fillMaxSize(), ) 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 f5b0db80c1..1ee0bb3a89 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 @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.location import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -84,6 +85,10 @@ fun LocationPickerMap( // same [recenter] doesn't yank the map back while the user is panning. val lastRecenter = remember { arrayOfNulls(1) } + // Tracks the last marker point so we only rebuild the marker overlay (and invalidate) + // when it actually changes — not on every scroll-driven recomposition. + val lastMarker = remember { arrayOfNulls(1) } + val mapView = remember(context) { Configuration.getInstance().userAgentValue = context.packageName @@ -141,6 +146,13 @@ fun LocationPickerMap( } } + // Follow the app theme: dim the bright MAPNIK tiles in dark mode (matching the + // display-only LocationPreviewMap). Applied only when the theme flips, not per frame. + LaunchedEffect(mapView, darkTheme) { + mapView.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + mapView.invalidate() + } + AndroidView( modifier = modifier, factory = { mapView }, @@ -154,22 +166,24 @@ fun LocationPickerMap( } } - // Follow the app theme: dim the bright MAPNIK tiles in dark mode, matching - // the display-only LocationPreviewMap. - map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) - - map.overlays.removeAll { it is Marker } - if (pickedLatitude != null && pickedLongitude != null) { - val point = GeoPoint(pickedLatitude, pickedLongitude) - val marker = - Marker(map).apply { - position = point - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - setInfoWindow(null) - } - map.overlays.add(marker) + // Only rebuild the marker overlay when the picked point actually changes. The + // update block runs on every scroll-driven recomposition, so unconditionally + // clearing/re-adding the marker and invalidating would be per-frame waste. + val marker = if (pickedLatitude != null && pickedLongitude != null) GeoPoint(pickedLatitude, pickedLongitude) else null + if (marker != lastMarker[0]) { + lastMarker[0] = marker + map.overlays.removeAll { it is Marker } + if (marker != null) { + map.overlays.add( + Marker(map).apply { + position = marker + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setInfoWindow(null) + }, + ) + } + map.invalidate() } - map.invalidate() }, ) } From b20efb3147a3adb0101e8deac881222b5fd7cb35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 23:45:44 +0000 Subject: [PATCH 07/14] fix(location): map drag interception + wrap precision chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The interactive picker map didn't tell ancestor views to stop intercepting touches, so a horizontal drag on the Teleport map got stolen (opening the nav drawer) instead of panning. Add the same requestDisallowInterceptTouchEvent touch listener LocationPreviewMap already uses. - The precision chips were a single horizontally-scrolling Row; make them a FlowRow so "Region · ~1250 km … Building · ~38 m" wraps onto multiple lines. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../location/GeohashLocationPickerDialog.kt | 12 ++++++------ .../ui/note/creators/location/LocationPickerMap.kt | 13 +++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) 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 index cba3c257ef..8a462c1d6c 100644 --- 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 @@ -25,10 +25,11 @@ import android.location.Address import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -550,6 +551,7 @@ private fun MyLocationButton( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun PickerBottomBar( cell: String?, @@ -578,12 +580,10 @@ private fun PickerBottomBar( style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Row( - Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(vertical = 10.dp), + FlowRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { GeohashChannelLevel.ordered.forEach { lvl -> FilterChip( 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 1ee0bb3a89..030883b693 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.location +import android.view.MotionEvent import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -100,6 +101,18 @@ fun LocationPickerMap( controller.setZoom(zoom) controller.setCenter(GeoPoint(latitude, longitude)) + // Ask ancestors (nav drawer, horizontal pager, feed) to stop intercepting + // touches while a finger is on the map, so a horizontal drag pans the map + // instead of opening the drawer or being stolen as a swipe. Mirrors + // LocationPreviewMap. Returning false lets the MapView still pan/zoom/tap. + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> view.parent?.requestDisallowInterceptTouchEvent(false) + } + false + } + val receiver = object : MapEventsReceiver { override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { From 787fd99d1e8e99616fdcfef61559624f5f9ef569 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 23:57:06 +0000 Subject: [PATCH 08/14] =?UTF-8?q?fix(location):=20picker=20polish=20?= =?UTF-8?q?=E2=80=94=20open=20zoom,=20search=20pill,=20chip=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Open the seeded picker at a zoom that fits the geohash cell's precision (region opens wide, building opens tight) instead of a fixed zoom. - Hide the search field's rectangular outline so its square corners no longer poke through the rounded pill (border colors → transparent). - Drop the 48dp minimum touch-target on the area-size chips so wrapped rows sit 8dp apart (matching the horizontal gap) instead of ~24dp. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../location/GeohashLocationPickerDialog.kt | 62 ++++++++++++++----- 1 file changed, 47 insertions(+), 15 deletions(-) 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 index 8a462c1d6c..f9ed07fb9f 100644 --- 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 @@ -53,11 +53,14 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -66,10 +69,12 @@ 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.graphics.Color 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.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -95,12 +100,26 @@ import kotlin.math.abs /** 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 starting zoom so a seeded geohash cell roughly fills the view: a coarse cell + * (few chars → large area) opens zoomed out, a fine cell zoomed in. Without this a + * region-level seed would open uselessly tight and a building-level seed too loose. + */ +private fun zoomForGeohashLength(length: Int): Double = + when { + length <= 1 -> 2.0 + length == 2 -> 4.0 + length == 3 -> 6.5 + length == 4 -> 9.0 + length == 5 -> 12.0 + length == 6 -> 14.0 + length == 7 -> 16.0 + else -> 17.5 + } + /** Neutral starting center (mid-Atlantic) when the picker opens with no seed. */ private const val WORLD_CENTER_LAT = 20.0 private const val WORLD_CENTER_LON = 0.0 @@ -291,7 +310,7 @@ fun GeohashLocationPickerContent( longitude = seed?.centerLon ?: 0.0, pickedLatitude = null, pickedLongitude = null, - zoom = if (seed != null) SEEDED_ZOOM else WORLD_ZOOM, + zoom = if (seed != null) zoomForGeohashLength(seedLen) else WORLD_ZOOM, recenter = recenter, recenterZoom = RECENTER_ZOOM, onCenterChanged = { lat, lon -> @@ -516,6 +535,15 @@ private fun SearchField( } else { null }, + // The pill comes from the wrapping Surface; hide the text field's own + // rectangular outline so its square corners don't poke through the pill. + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + errorBorderColor = Color.Transparent, + ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSearch() }), modifier = Modifier.fillMaxWidth(), @@ -580,17 +608,21 @@ private fun PickerBottomBar( style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - FlowRow( - modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - GeohashChannelLevel.ordered.forEach { lvl -> - FilterChip( - selected = lvl == level, - onClick = { onLevel(lvl) }, - label = { Text("${lvl.label()} · ${lvl.areaSize()}") }, - ) + // Drop the 48dp minimum touch-target so wrapped chip rows sit 8dp apart + // (matching the horizontal gap) instead of ~24dp apart. + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + GeohashChannelLevel.ordered.forEach { lvl -> + FilterChip( + selected = lvl == level, + onClick = { onLevel(lvl) }, + label = { Text("${lvl.label()} · ${lvl.areaSize()}") }, + ) + } } } From c6b2bad0a95ac620a11c933d13725f4492eb06c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 00:21:02 +0000 Subject: [PATCH 09/14] feat(location): calmer map, cell highlight, zoom on area size - Mute the busy MAPNIK tiles in light mode (desaturate + gentle lighten), the light-mode counterpart to the existing dark tile filter. Applied to both the interactive picker and the preview thumbnail. - Outline the selected geohash cell on the map so the user sees exactly which region a post/filter covers; the rectangle follows the pin and resizes with the area-size level. - When the area-size level changes, animate the zoom so the new cell is framed. LocationPickerMap gains zoomTo/highlight/highlightColor params (all guarded to rebuild only on change, preserving the per-frame-cheap update path). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../location/GeohashLocationPickerDialog.kt | 36 +++++++++++++++ .../creators/location/LocationPickerMap.kt | 45 ++++++++++++++++++- .../creators/location/LocationPreviewMap.kt | 43 +++++++++++++++++- 3 files changed, 121 insertions(+), 3 deletions(-) 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 index f9ed07fb9f..1422814b3c 100644 --- 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 @@ -70,6 +70,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight @@ -94,6 +95,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull +import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint import kotlin.math.abs @@ -224,6 +226,17 @@ fun GeohashLocationPickerContent( settledCell = cell } + // The selected geohash cell outlined on the map, and its stroke colour. + val highlightColor = MaterialTheme.colorScheme.primary.toArgb() + val highlight = remember(cell) { cell?.let(::geohashBounds) } + + // When the area-size level changes (only after a real selection), zoom the map so the + // new cell is comfortably framed. + var zoomTo by remember { mutableStateOf(null) } + LaunchedEffect(level) { + if (hasSelection) zoomTo = zoomForGeohashLength(level.chars) + } + // Lift the center pin briefly whenever the target point moves, for tactile feedback. var pinLifted by remember { mutableStateOf(false) } LaunchedEffect(pickedLat, pickedLon) { @@ -313,6 +326,9 @@ fun GeohashLocationPickerContent( zoom = if (seed != null) zoomForGeohashLength(seedLen) else WORLD_ZOOM, recenter = recenter, recenterZoom = RECENTER_ZOOM, + zoomTo = zoomTo, + highlight = highlight, + highlightColor = highlightColor, onCenterChanged = { lat, lon -> pickedLat = lat pickedLon = lon @@ -691,6 +707,26 @@ private fun Address.displayLine(): String { return listOfNotNull(primary, context.joinToString(", ").ifBlank { null }).joinToString(", ") } +/** + * The lat/lon bounding box of a geohash cell. A geohash of length L uses 5L bits, + * split lon = ceil(5L/2), lat = floor(5L/2); each halves its axis per bit, so the cell + * spans 180/2^latBits by 360/2^lonBits around the decoded center. + */ +private fun geohashBounds(geohash: String): BoundingBox? { + val gh = GeoHash.decode(geohash) ?: return null + val bits = 5 * geohash.length + val latBits = bits / 2 + val lonBits = bits - latBits + val latHeight = 180.0 / (1L shl latBits) + val lonWidth = 360.0 / (1L shl lonBits) + return BoundingBox( + gh.centerLat + latHeight / 2, + gh.centerLon + lonWidth / 2, + gh.centerLat - latHeight / 2, + gh.centerLon - lonWidth / 2, + ) +} + /** A rough physical size for a geohash cell at this precision, for the chip subtitle. */ private fun GeohashChannelLevel.areaSize(): String = when (this) { 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 030883b693..f5e3375398 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 @@ -31,6 +31,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.ColorUtils import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -41,11 +42,13 @@ 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.BoundingBox import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polygon /** * An interactive OpenStreetMap (osmdroid) picker: tapping the map reports the @@ -73,6 +76,9 @@ fun LocationPickerMap( zoom: Double = 4.0, recenter: GeoPoint? = null, recenterZoom: Double? = null, + zoomTo: Double? = null, + highlight: BoundingBox? = null, + highlightColor: Int = 0, onCenterChanged: ((Double, Double) -> Unit)? = null, onPick: (Double, Double) -> Unit, ) { @@ -90,6 +96,10 @@ fun LocationPickerMap( // when it actually changes — not on every scroll-driven recomposition. val lastMarker = remember { arrayOfNulls(1) } + // Same change-guards for the highlighted cell rectangle and the level-driven zoom. + val lastHighlight = remember { arrayOfNulls(1) } + val lastZoomTo = remember { doubleArrayOf(Double.NaN) } + val mapView = remember(context) { Configuration.getInstance().userAgentValue = context.packageName @@ -162,7 +172,7 @@ fun LocationPickerMap( // Follow the app theme: dim the bright MAPNIK tiles in dark mode (matching the // display-only LocationPreviewMap). Applied only when the theme flips, not per frame. LaunchedEffect(mapView, darkTheme) { - mapView.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + mapView.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else MUTED_TILE_FILTER) mapView.invalidate() } @@ -179,6 +189,39 @@ fun LocationPickerMap( } } + // Animate the zoom when the caller asks (e.g. the area-size level changed), + // keeping the current center. Guarded so it fires once per distinct value. + if (zoomTo != null && zoomTo != lastZoomTo[0]) { + lastZoomTo[0] = zoomTo + map.controller.zoomTo(zoomTo, 400L) + } + + // Outline the selected geohash cell so the user sees exactly which region a + // post/filter will cover. Rebuilt only when the cell bounds change. + if (highlight != lastHighlight[0]) { + lastHighlight[0] = highlight + map.overlays.removeAll { it is Polygon } + if (highlight != null) { + map.overlays.add( + 0, + Polygon(map).apply { + points = + listOf( + GeoPoint(highlight.latNorth, highlight.lonWest), + GeoPoint(highlight.latNorth, highlight.lonEast), + GeoPoint(highlight.latSouth, highlight.lonEast), + GeoPoint(highlight.latSouth, highlight.lonWest), + ) + fillPaint.color = ColorUtils.setAlphaComponent(highlightColor, 38) + outlinePaint.color = highlightColor + outlinePaint.strokeWidth = 4f + setOnClickListener { _, _, _ -> false } + }, + ) + } + map.invalidate() + } + // Only rebuild the marker overlay when the picked point actually changes. The // update block runs on every scroll-driven recomposition, so unconditionally // clearing/re-adding the marker and invalidating would be per-frame waste. 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 63f3940390..5d9161d1e6 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 @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.location import android.graphics.ColorFilter +import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent @@ -83,6 +84,44 @@ internal val NIGHT_TILE_FILTER: ColorFilter = ), ) +/** + * Light-mode "calm" filter for the busy MAPNIK tiles: desaturate (mute the loud + * road/POI colours) and lift brightness a touch so labels read softer. Keeps the + * map legible for picking a spot without the full-colour visual noise. + */ +internal val MUTED_TILE_FILTER: ColorFilter = + ColorMatrixColorFilter( + ColorMatrix().apply { + setSaturation(0.55f) + postConcat( + ColorMatrix( + floatArrayOf( + 0.92f, + 0f, + 0f, + 0f, + 16f, + 0f, + 0.92f, + 0f, + 0f, + 16f, + 0f, + 0f, + 0.92f, + 0f, + 16f, + 0f, + 0f, + 0f, + 1f, + 0f, + ), + ), + ) + }, + ) + /** * A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude] * with a single pin at that point. @@ -174,8 +213,8 @@ fun LocationPreviewMap( map.controller.setZoom(zoom) map.controller.setCenter(point) - // Follow the app theme: dim the bright MAPNIK tiles in dark mode. - map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + // Follow the app theme: dim in dark mode, mute the busy colours in light mode. + map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else MUTED_TILE_FILTER) map.overlays.removeAll { it is Marker } val marker = From 1445aea84994d4457ed4a3e6b07fe69e3b31a8f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 00:48:24 +0000 Subject: [PATCH 10/14] feat(location): open the picker at the user's location when permitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the picker opens with no seed and location permission is already granted, fly to the user's current position instead of the neutral world view. Runs once and never prompts — it only consumes already-granted permission; on denial or no fix it stays at the world view as before. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../creators/location/GeohashLocationPickerDialog.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 index 1422814b3c..b700f3e822 100644 --- 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 @@ -286,6 +286,17 @@ fun GeohashLocationPickerContent( } } + // On open with no seed, if we already have location permission, start the map at the + // user's current position instead of the neutral world view. Runs once, and never + // prompts — it only uses permission that's already granted. + var autoLocated by remember { mutableStateOf(false) } + LaunchedEffect(permission.status.isGranted) { + if (!autoLocated && seed == null && permission.status.isGranted) { + autoLocated = true + wantsMyLocation = true + } + } + // Forward-geocode search. Results are shown as a pick list; choosing one flies there. var query by remember { mutableStateOf("") } var searching by remember { mutableStateOf(false) } From 7c37a8691c639be0af6e115a409b476c43540a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 00:55:43 +0000 Subject: [PATCH 11/14] feat(topnav): teleport to a place from the feed filter dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Teleport to a place…" entry to every top-nav feed filter that offers "Around Me" (home, video, discover, notifications, products, music, git, podcasts, relay-group discovery, …). Selecting it opens the shared map picker; on confirm the chosen place becomes this screen's TopFilter.Geohash feed. Implementation is centralized in FeedFilterSpinner (no per-top-bar changes): a UI-only TopFilter.TeleportPicker sentinel is intercepted there to open the picker, then forwarded through the normal onSelect path as a TopFilter.Geohash — which each screen's existing changeDefault*FollowList already handles. The spinner header falls back to the raw selection so a teleported (unfollowed) geohash still shows its place name. Follow-up (not in this commit): a "follow this location" action so a teleported place can be saved to the kind-10081 geohash list from the feed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../amethyst/model/AccountSettings.kt | 8 +++++ .../topNavFeeds/FeedTopNavFilterState.kt | 4 ++- .../navigation/topbars/FeedFilterSpinner.kt | 32 +++++++++++++++-- .../amethyst/ui/screen/TopNavFilterState.kt | 34 ++++++++++++------- amethyst/src/main/res/values/strings.xml | 1 + 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d73223ca4b..9ffc3e7d31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -127,6 +127,14 @@ sealed class TopFilter( @Serializable object AroundMe : TopFilter(" Around Me ") + /** + * Not a real selection: a sentinel for the "Teleport" chip in the top-nav filter. + * The spinner intercepts it to open the map picker and then applies the chosen + * [Geohash] instead — it is never persisted or dispatched to a feed flow. + */ + @Serializable + object TeleportPicker : TopFilter(" Teleport ") + @Serializable object Mine : TopFilter(" Mine ") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 1758c14a8a..4105a35552 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -74,7 +74,9 @@ class FeedTopNavFilterState( ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { - TopFilter.Global, TopFilter.Selected -> { + // TeleportPicker is a UI-only sentinel (intercepted by the spinner to open the + // map picker); it never reaches here, but fall back to Global for exhaustiveness. + TopFilter.Global, TopFilter.Selected, TopFilter.TeleportPicker -> { GlobalFeedFlow(followsRelays, proxyRelays, relayFeeds) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 39f5c8dd63..69ded95d7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName @@ -142,7 +143,10 @@ fun FeedFilterSpinner( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f, fill = false), ) { - val filter = selected?.code + // Fall back to the raw selection: a teleported geohash that isn't in the + // catalog (not followed) has no matching option, but we still want to show + // its place name rather than "Select an option". + val filter = selected?.code ?: placeholderCode if (filter is TopFilter.Geohash) { LoadCityName( geohashStr = filter.tag, @@ -267,6 +271,8 @@ fun FeedFilterSpinner( ) } + var teleporting by remember { mutableStateOf(false) } + if (optionsShowing && options.isNotEmpty()) { GroupedFeedFilterDialog( title = explainer, @@ -274,12 +280,29 @@ fun FeedFilterSpinner( onDismiss = { optionsShowing = false }, onSelect = { definition -> optionsShowing = false - onSelect(definition) + // "Teleport" isn't a real filter — open the map picker and apply the chosen + // place as this screen's Geohash filter via the normal onSelect path. + if (definition.code is TopFilter.TeleportPicker) { + teleporting = true + } else { + onSelect(definition) + } }, ) { RenderOption(it.name, accountViewModel) } } + + if (teleporting) { + GeohashLocationPickerDialog( + initialGeohash = (selected?.code as? TopFilter.Geohash)?.tag, + onDismiss = { teleporting = false }, + onConfirm = { cell -> + teleporting = false + onSelect(FeedDefinition(code = TopFilter.Geohash(cell), name = GeoHashName(cell))) + }, + ) + } } @Composable @@ -366,6 +389,7 @@ private fun FeedDefinition.group(): FeedGroup = is ResourceName -> { when (code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS + is TopFilter.TeleportPicker -> FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS is TopFilter.Selected -> FeedGroup.RELAYS is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS @@ -516,6 +540,10 @@ private fun FeedIcon( MaterialSymbols.LocationOn } + is TopFilter.TeleportPicker -> { + MaterialSymbols.TravelExplore + } + is TopFilter.AllFollows -> { MaterialSymbols.Groups } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 80a1ce25b2..38570df353 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -97,6 +97,14 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_aroundme), ) + // A UI-only entry: selecting it opens the map picker (handled in FeedFilterSpinner) + // and applies the chosen place as a TopFilter.Geohash for this screen's feed. + val teleport = + FeedDefinition( + code = TopFilter.TeleportPicker, + name = ResourceName(R.string.follow_list_teleport), + ) + val muteListFollow = FeedDefinition( code = TopFilter.MuteList(account.muteList.getMuteListAddress()), @@ -115,9 +123,9 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_all_favorite_dvms), ) - val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, muteListFollow) - val defaultNotificationLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, selectedFollow, globalFollow, muteListFollow) + val defaultNotificationLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, selectedFollow, globalFollow, muteListFollow) fun mergePeopleLists( peopleLists: List, @@ -261,7 +269,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow), peopleLists, interests, listOf(muteListFollow), @@ -303,7 +311,7 @@ class TopNavFilterState( listOf( // Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the // music + playlists screens can show only the user's own published items. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), peopleLists, interests, listOf(muteListFollow), @@ -321,7 +329,7 @@ class TopNavFilterState( listOf( // Git repository announcements can be narrowed by author, hashtag and geohash, // so this mirrors the kind3 catalog plus "Mine" — the user's own repositories. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), peopleLists, interests, listOf(muteListFollow), @@ -355,7 +363,7 @@ class TopNavFilterState( // Relay-group discovery routes to relays by author, hashtag and geohash, plus // favorited + joined-group relay chips; "Mine" (the joined-groups view) sits last // in the base group to match the ordering every other feed uses. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), peopleLists, interests, joinedRelayChips, @@ -374,7 +382,7 @@ class TopNavFilterState( listOf( // Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the // podcasts + episodes screens can show only the user's own published shows/episodes. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), peopleLists, interests, listOf(muteListFollow), @@ -387,7 +395,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow), peopleLists, listOf(muteListFollow), ).flatten().toImmutableList(), @@ -415,7 +423,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, selectedFollow, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, selectedFollow, globalFollow), peopleLists, listOf(muteListFollow), ).flatten().toImmutableList(), @@ -455,22 +463,22 @@ class TopNavFilterState( val musicRoutes = _musicRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) val gitRepositoryRoutes = _gitRepositoryRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) val relayGroupsDiscoveryRoutes = _relayGroupsDiscoveryRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) val podcastRoutes = _podcastRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d6a11e6af2..d6a8854beb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1630,6 +1630,7 @@ All User Follows Default Follow List Around Me + Teleport to a place… Global Curated Mine From fb9ccbaad0024797c0ea25a3a85a276a99c35357 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 01:25:24 +0000 Subject: [PATCH 12/14] feat(location): follow a teleported place; retarget geo-posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Follow this location from the feed: when a top-nav Geohash filter is active (e.g. after teleporting), the filter header shows a bookmark toggle to follow/unfollow that place (kind 10081), so a teleported spot can be saved to your locations — the "add to my interests" step. Respects read-only accounts. 2) Retarget a geo-post: the geohash comment composer now shows "Posting to " with a Change action that opens the map picker and re-scopes the post's NIP-73 geohash channel (CommentPostViewModel.geohashScope/setGeohashScope), instead of being locked to the feed's channel. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../navigation/topbars/FeedFilterSpinner.kt | 271 ++++++++++-------- .../nip22Comments/CommentPostViewModel.kt | 10 + .../nip22Comments/GenericCommentPostScreen.kt | 61 ++++ amethyst/src/main/res/values/strings.xml | 4 + 4 files changed, 230 insertions(+), 116 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 69ded95d7f..8426fc9bec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName @@ -130,88 +132,108 @@ fun FeedFilterSpinner( val openDropdownLabel = stringRes(R.string.open_dropdown_menu) - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Size20Modifier) + val filter = selected?.code ?: placeholderCode - // Bound the Column so long filter names (e.g. DVM titles) get truncated - // instead of wrapping to multiple lines and shoving the expand icon out. - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f, fill = false), - ) { - // Fall back to the raw selection: a teleported geohash that isn't in the - // catalog (not followed) has no matching option, but we still want to show - // its place name rather than "Select an option". - val filter = selected?.code ?: placeholderCode - if (filter is TopFilter.Geohash) { - LoadCityName( - geohashStr = filter.tag, - onLoading = { - Row { - Text( - text = filter.tag, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) - } - }, - ) { cityName -> + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.weight(1f, fill = false), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Size20Modifier) + + // Bound the Column so long filter names (e.g. DVM titles) get truncated + // instead of wrapping to multiple lines and shoving the expand icon out. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f, fill = false), + ) { + if (filter is TopFilter.Geohash) { + LoadCityName( + geohashStr = filter.tag, + onLoading = { + Row { + Text( + text = filter.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) + } + }, + ) { cityName -> + Text( + text = cityName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { Text( - text = cityName, + text = currentText, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - } else { - Text( - text = currentText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (filter is TopFilter.AroundMe) { - val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) - if (!locationPermissionState.status.isGranted) { - LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } + if (filter is TopFilter.AroundMe) { + val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) + if (!locationPermissionState.status.isGranted) { + LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } - Text( - text = stringRes(R.string.lack_location_permissions), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - val location by Amethyst.instance.locationManager.geohashStateFlow - .collectAsStateWithLifecycle() + Text( + text = stringRes(R.string.lack_location_permissions), + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + val location by Amethyst.instance.locationManager.geohashStateFlow + .collectAsStateWithLifecycle() - when (val myLocation = location) { - is LocationState.LocationResult.Success -> { - LoadCityName( - geohashStr = myLocation.geoHash.toString(), - onLoading = { - Row { - Text( - text = "(${myLocation.geoHash})", - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) - } - }, - ) { cityName -> + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + LoadCityName( + geohashStr = myLocation.geoHash.toString(), + onLoading = { + Row { + Text( + text = "(${myLocation.geoHash})", + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) + } + }, + ) { cityName -> + Text( + text = "($cityName)", + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + LocationState.LocationResult.LackPermission -> { Text( - text = "($cityName)", + text = stringRes(R.string.lack_location_permissions), + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + LocationState.LocationResult.Loading -> { + Text( + text = stringRes(R.string.loading_location), fontSize = Font12SP, lineHeight = 12.sp, maxLines = 1, @@ -219,56 +241,42 @@ fun FeedFilterSpinner( ) } } - - LocationState.LocationResult.LackPermission -> { - Text( - text = stringRes(R.string.lack_location_permissions), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - LocationState.LocationResult.Loading -> { - Text( - text = stringRes(R.string.loading_location), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } } } - } - Icon( - symbol = MaterialSymbols.ExpandMore, - contentDescription = explainer, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, + Icon( + symbol = MaterialSymbols.ExpandMore, + contentDescription = explainer, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + Box( + modifier = + Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + optionsShowing = true + }.semantics { + role = Role.DropdownList + stateDescription = accessibilityDescription + onClick(label = openDropdownLabel) { + optionsShowing = true + return@onClick true + } + }, ) } - Box( - modifier = - Modifier - .matchParentSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - optionsShowing = true - }.semantics { - role = Role.DropdownList - stateDescription = accessibilityDescription - onClick(label = openDropdownLabel) { - optionsShowing = true - return@onClick true - } - }, - ) + + // Save/unsave the active place to the followed-locations list (kind 10081), so a + // teleported spot can be kept as a permanent chip — the "add to my interests" step. + if (filter is TopFilter.Geohash) { + FollowLocationToggle(filter.tag, accountViewModel) + } } var teleporting by remember { mutableStateOf(false) } @@ -305,6 +313,37 @@ fun FeedFilterSpinner( } } +/** A compact toggle in the filter header to follow/unfollow the active geohash location. */ +@Composable +private fun FollowLocationToggle( + tag: String, + accountViewModel: AccountViewModel, +) { + val isFollowing by observeUserIsFollowingGeohash(tag, accountViewModel) + IconButton(onClick = { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + if (isFollowing) R.string.login_with_a_private_key_to_be_able_to_unfollow else R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else if (isFollowing) { + accountViewModel.unfollowGeohash(tag) + } else { + accountViewModel.followGeohash(tag) + } + }) { + Icon( + symbol = if (isFollowing) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd, + contentDescription = + stringRes( + if (isFollowing) R.string.unfollow_geohash else R.string.follow_geohash, + ), + modifier = Size20Modifier, + tint = if (isFollowing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable fun RenderOption( option: Name, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index d52c964d8c..f6cf99ee14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -112,6 +112,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.scope import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -166,6 +167,15 @@ open class CommentPostViewModel : var externalIdentity by mutableStateOf(null) var replyingTo: Note? by mutableStateOf(null) + /** The geohash channel this comment is scoped to, or null when it isn't a geo-post. */ + val geohashScope: String? + get() = (externalIdentity as? GeohashId)?.geohash + + /** Retarget a geo-post to a different location channel (from the map picker). */ + fun setGeohashScope(geohash: String) { + externalIdentity = GeohashId(geohash) + } + // The signature pre-filled by applySignature(), so an untouched signature-only // message is treated as blank instead of auto-saved as a junk draft. private var appliedSignature: String? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 12d9ea78d8..d466f17f9b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll @@ -41,8 +42,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -73,6 +80,8 @@ import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection +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.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton @@ -302,6 +311,8 @@ private fun GenericCommentPostBody( ) } + GeoPostLocationChannel(postViewModel) + DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { @@ -439,6 +450,56 @@ private fun GenericCommentPostBody( } } +/** + * For a geo-post (a comment scoped to a geohash channel), shows which place it will post + * to and lets the user retarget it via the map picker. Hidden for non-geo comments. + */ +@Composable +private fun GeoPostLocationChannel(postViewModel: CommentPostViewModel) { + val scope = postViewModel.geohashScope ?: return + var showPicker by remember { mutableStateOf(false) } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { showPicker = true } + .padding(horizontal = Size10dp, vertical = 6.dp), + verticalAlignment = CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) { + Text( + text = stringRes(R.string.geo_post_posting_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LoadCityName(geohashStr = scope) { cityName -> + Text(text = cityName, style = MaterialTheme.typography.bodyLarge, maxLines = 1) + } + } + TextButton(onClick = { showPicker = true }) { + Text(stringRes(R.string.geo_post_change_place)) + } + } + + if (showPicker) { + GeohashLocationPickerDialog( + initialGeohash = scope, + onDismiss = { showPicker = false }, + onConfirm = { cell -> + postViewModel.setGeohashScope(cell) + showPicker = false + }, + ) + } +} + @Composable private fun BottomRowActions(postViewModel: CommentPostViewModel) { val scrollState = rememberScrollState() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d6a8854beb..deb3b3fb88 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1631,6 +1631,10 @@ Default Follow List Around Me Teleport to a place… + Follow this location + Unfollow this location + Posting to + Change Global Curated Mine From a87d4688a8dfbabbd544a8444b40be343eb803a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 01:50:02 +0000 Subject: [PATCH 13/14] fix(location): single location control on the geo-post screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The geo-post composer showed two locations: the static external-id marker at the top and the new "Posting to · Change" row below the message. Drop the duplicate and, for the geohash case, render the interactive channel control (with retarget) in the marker's place. Non-geohash external ids still use DisplayExternalId. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../note/nip22Comments/GenericCommentPostScreen.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index d466f17f9b..6baef9f1a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -106,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet @@ -244,9 +245,16 @@ private fun GenericCommentPostBody( ) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState, reverseScrolling = true)) { postViewModel.externalIdentity?.let { - Row { - DisplayExternalId(it, accountViewModel, nav) + if (it is GeohashId) { + // Geo-post: the interactive location channel (with retarget) replaces + // the static external-id marker so there's a single location control. + GeoPostLocationChannel(postViewModel) Spacer(modifier = StdVertSpacer) + } else { + Row { + DisplayExternalId(it, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } } } @@ -311,8 +319,6 @@ private fun GenericCommentPostBody( ) } - GeoPostLocationChannel(postViewModel) - DisplayPreviews(postViewModel.urlPreviews, accountViewModel, nav) if (postViewModel.wantsToMarkAsSensitive) { From b94303a77a5ad5d3d064e91b3a4f706e4667f507 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 02:11:59 +0000 Subject: [PATCH 14/14] =?UTF-8?q?fix(location):=20audit=20fixes=20?= =?UTF-8?q?=E2=80=94=20stuck=20GPS=20spinner,=20main-thread=20geocode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Use my location" could spin forever after a permission denial: the reset was in an isGranted-keyed effect that never re-ran on false→false. Use rememberPermissionState's result callback, which fires on grant AND denial, so the spinner always clears. - Forward-geocode search ran the blocking Geocoder on the UI thread on Android < 13 (ANR risk). Run the search on Dispatchers.IO. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh --- .../location/GeohashLocationPickerDialog.kt | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) 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 index b700f3e822..3a103691d8 100644 --- 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 @@ -65,6 +65,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -92,8 +93,10 @@ 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.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint @@ -192,6 +195,7 @@ fun GeohashLocationPickerContent( ) { val context = LocalContext.current val keyboard = LocalSoftwareKeyboardController.current + val scope = rememberCoroutineScope() val locationManager = Amethyst.instance.locationManager val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } } @@ -246,19 +250,18 @@ fun GeohashLocationPickerContent( } // "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) + // asks for it; [awaitingPermission] tracks the in-flight prompt so the button shows a + // spinner. The result callback fires on BOTH grant and denial — so a denial always + // clears the spinner (an isGranted-keyed effect wouldn't re-run on the false→false case). var wantsMyLocation by remember { mutableStateOf(false) } var awaitingPermission by remember { mutableStateOf(false) } + val permission = + rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) { granted -> + awaitingPermission = false + if (granted) wantsMyLocation = true + } 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) { @@ -309,11 +312,15 @@ fun GeohashLocationPickerContent( searching = true searchMissed = false results = emptyList() - ForwardGeolocation.execute(q, context) { addresses -> - searching = false - val hits = addresses.orEmpty().filter { it.hasLatitude() && it.hasLongitude() } - results = hits - searchMissed = hits.isEmpty() + // Off the main thread: on Android < 13 ForwardGeolocation.execute() calls the + // blocking Geocoder synchronously, which would freeze/ANR the UI thread. + scope.launch(Dispatchers.IO) { + ForwardGeolocation.execute(q, context) { addresses -> + searching = false + val hits = addresses.orEmpty().filter { it.hasLatitude() && it.hasLongitude() } + results = hits + searchMissed = hits.isEmpty() + } } } }