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/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/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt
index 39f5c8dd63..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,8 @@ 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
import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName
@@ -129,85 +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),
- ) {
- val filter = selected?.code
- 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,
@@ -215,58 +241,46 @@ 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) }
+
if (optionsShowing && options.isNotEmpty()) {
GroupedFeedFilterDialog(
title = explainer,
@@ -274,12 +288,60 @@ 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)))
+ },
+ )
+ }
+}
+
+/** 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
@@ -366,6 +428,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 +579,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/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/GeohashLocationPickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt
new file mode 100644
index 0000000000..3a103691d8
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt
@@ -0,0 +1,757 @@
+/*
+ * 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 android.location.Address
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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
+import androidx.compose.foundation.layout.heightIn
+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.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.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
+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
+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
+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
+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.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
+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 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
+
+/**
+ * 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.
+ *
+ * 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).
+ */
+@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
+ val scope = rememberCoroutineScope()
+ val locationManager = Amethyst.instance.locationManager
+
+ 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, hasSelection) {
+ val lat = pickedLat
+ val lon = pickedLon
+ 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
+ // 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
+ }
+
+ // 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) {
+ pinLifted = true
+ delay(220)
+ pinLifted = false
+ }
+
+ // "Use my location": tapping either fires the fetch (permission already granted) or
+ // 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)
+ }
+ LaunchedEffect(wantsMyLocation) {
+ if (wantsMyLocation) {
+ // 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
+ }
+ }
+ val onUseMyLocation = {
+ if (permission.status.isGranted) {
+ wantsMyLocation = true
+ } else {
+ awaitingPermission = true
+ permission.launchPermissionRequest()
+ }
+ }
+
+ // 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) }
+ 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()
+ // 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()
+ }
+ }
+ }
+ }
+ val selectResult: (Address) -> Unit = { hit ->
+ recenter = GeoPoint(hit.latitude, hit.longitude)
+ pickedLat = hit.latitude
+ pickedLon = hit.longitude
+ hasSelection = true
+ results = emptyList()
+ query = ""
+ keyboard?.hide()
+ }
+
+ 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) zoomForGeohashLength(seedLen) else WORLD_ZOOM,
+ recenter = recenter,
+ recenterZoom = RECENTER_ZOOM,
+ zoomTo = zoomTo,
+ highlight = highlight,
+ highlightColor = highlightColor,
+ 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(),
+ )
+
+ 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),
+ )
+ }
+
+ PickerBottomBar(
+ cell = cell,
+ settledCell = settledCell,
+ level = level,
+ confirmLabel = confirmLabel,
+ 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.location_picker_title),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(start = 4.dp),
+ )
+ }
+ }
+}
+
+/**
+ * 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(
+ 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,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier =
+ Modifier
+ .size(46.dp)
+ .offset(y = -20.dp - lift),
+ )
+ // A shadow/anchor dot marking the exact center point; it spreads as the pin lifts.
+ Box(
+ Modifier
+ .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,
+ 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.location_picker_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.location_picker_search_empty)) }
+ } 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(),
+ )
+ }
+}
+
+@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.location_picker_use_mine),
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun PickerBottomBar(
+ cell: String?,
+ settledCell: String?,
+ level: GeohashChannelLevel,
+ confirmLabel: String,
+ 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.location_picker_area),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ // 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()}") },
+ )
+ }
+ }
+ }
+
+ if (cell == null) {
+ Text(
+ text = stringRes(R.string.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(confirmLabel, 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(", ")
+}
+
+/**
+ * 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) {
+ 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/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/creators/location/LocationPickerMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt
index 22838cda71..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
@@ -20,25 +20,35 @@
*/
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
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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
+import com.vitorpamplona.amethyst.ui.theme.isLight
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.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
@@ -47,6 +57,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 +74,31 @@ fun LocationPickerMap(
pickedLongitude: Double?,
modifier: Modifier = Modifier,
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,
) {
val context = LocalContext.current
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.
+ 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) }
+
+ // 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) {
@@ -73,6 +111,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 {
@@ -86,6 +136,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
+ }
+ },
+ )
}
}
@@ -105,22 +169,77 @@ 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 MUTED_TILE_FILTER)
+ mapView.invalidate()
+ }
+
AndroidView(
modifier = modifier,
factory = { mapView },
update = { map ->
- 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)
+ if (recenter != null && recenter != lastRecenter[0]) {
+ lastRecenter[0] = recenter
+ if (recenterZoom != null) {
+ map.controller.animateTo(recenter, recenterZoom, 800L)
+ } else {
+ map.controller.animateTo(recenter)
+ }
+ }
+
+ // 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.
+ 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()
},
)
}
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..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
@@ -57,7 +58,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,
@@ -83,6 +84,44 @@ private 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.
@@ -107,6 +146,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,15 +206,15 @@ fun LocationPreviewMap(
}
AndroidView(
- modifier = modifier.fillMaxWidth().aspectRatio(1f),
+ modifier = modifier.fillMaxWidth().aspectRatio(aspectRatio),
factory = { mapView },
update = { map ->
val point = GeoPoint(latitude, longitude)
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 =
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..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
@@ -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
@@ -111,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
@@ -165,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
@@ -242,6 +253,7 @@ open class CommentPostViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
+ override var pickedGeoHash by mutableStateOf(null)
var location: StateFlow? = null
// ZapRaiser
@@ -510,6 +522,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 +654,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 +880,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..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
@@ -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
@@ -72,7 +79,9 @@ 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.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
@@ -97,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
@@ -235,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)
+ }
}
}
@@ -330,7 +347,7 @@ private fun GenericCommentPostBody(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
- LocationAsHash(postViewModel)
+ GeoHashPostSection(postViewModel)
}
}
@@ -439,6 +456,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/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/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/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/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt
index 76222e2b00..0618c5188a 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.RelayGroupCardWarmupSubscription
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/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 7ca1594deb..1332417908 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
@@ -306,6 +307,7 @@ open class ChannelNewMessageViewModel :
val geohash = draftEvent.getGeoHash()
wantsToAddGeoHash = geohash != null
+ pickedGeoHash = geohash
val zapraiser = draftEvent.zapraiserAmount()
wantsZapraiser = zapraiser != null
@@ -514,7 +516,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
@@ -738,6 +740,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 342e495a1d..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
@@ -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,7 +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.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
@@ -527,17 +528,12 @@ 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) {
+ SettingsRow(
+ R.string.geohash_exclusive,
+ R.string.geohash_exclusive_explainer,
+ ) {
+ Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it })
}
}
}
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..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
@@ -329,6 +329,8 @@ open class ShortNotePostViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
var location: StateFlow? = null
+
+ override var pickedGeoHash by mutableStateOf(null)
var wantsExclusiveGeoPost by mutableStateOf(false)
// ZapRaiser
@@ -753,6 +755,7 @@ open class ShortNotePostViewModel :
val geohash = draftEvent.getGeoHash()
wantsToAddGeoHash = geohash != null
+ pickedGeoHash = geohash
if (geohash != null) {
wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND
}
@@ -858,6 +861,7 @@ open class ShortNotePostViewModel :
val geohash = draftEvent.getGeoHash()
wantsToAddGeoHash = geohash != null
+ pickedGeoHash = geohash
if (geohash != null) {
wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND
}
@@ -931,6 +935,7 @@ open class ShortNotePostViewModel :
val geohash = draftEvent.getGeoHash()
wantsToAddGeoHash = geohash != null
+ pickedGeoHash = geohash
if (geohash != null) {
wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND
}
@@ -1236,7 +1241,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 +1574,7 @@ open class ShortNotePostViewModel :
wantsToMarkAsSensitive = false
contentWarningDescription = ""
wantsToAddGeoHash = false
+ pickedGeoHash = null
wantsExclusiveGeoPost = false
wantsSecretEmoji = false
wantsAnonymousPost = false
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()
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 439e4029b8..bc61271482 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1630,6 +1630,11 @@
All User Follows
Default Follow List
Around Me
+ Teleport to a place…
+ Follow this location
+ Unfollow this location
+ Posting to
+ Change
Global
Curated
Mine
@@ -2147,6 +2152,10 @@
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
+ 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.
@@ -2356,7 +2365,20 @@
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")