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