Merge pull request #3643 from vitorpamplona/claude/nip29-location-geohash-ux-890wjz

Map-first geohash location UX: picker, teleport, follow, retarget
This commit is contained in:
Vitor Pamplona
2026-07-18 22:39:58 -04:00
committed by GitHub
29 changed files with 1748 additions and 316 deletions
@@ -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 ")
@@ -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)
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.os.Build
import androidx.annotation.RequiresApi
import com.vitorpamplona.quartz.utils.Log
import java.io.IOException
/**
* Forward geocoding: turn a free-text place query (a city, address, or landmark)
* into a list of candidate [Address]es so the user can jump the map there.
*
* The mirror image of [ReverseGeolocation]: on TIRAMISU+ it uses the async
* listener overload of [Geocoder.getFromLocationName] (the blocking one is
* deprecated there), and falls back to the synchronous call on older devices.
* Both branches funnel through [onReady]; a null result means "no backend, an
* error, or nothing matched" and the caller should degrade gracefully.
*/
@Suppress("DEPRECATION")
class ForwardGeolocation {
companion object {
const val MAX_RESULTS = 5
fun execute(
query: String,
context: Context,
onReady: (List<Address>?) -> Unit,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
executeAsync(query, context, onReady)
} else {
onReady(executeSync(query, context))
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun executeAsync(
query: String,
context: Context,
onReady: (List<Address>?) -> Unit,
) {
val listener =
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: List<Address>) {
Log.d("ForwardGeoLocation") { "Found ${addresses.size} addresses for $query" }
onReady(addresses)
}
override fun onError(errorMessage: String?) {
super.onError(errorMessage)
Log.w("ForwardGeoLocation") { "Failure $errorMessage" }
onReady(null)
}
}
Log.d("ForwardGeoLocation") { "Execute Async $query" }
Geocoder(context).getFromLocationName(query, MAX_RESULTS, listener)
}
fun executeSync(
query: String,
context: Context,
): List<Address>? {
Log.d("ForwardGeoLocation") { "Execute Sync $query" }
return try {
Geocoder(context).getFromLocationName(query, MAX_RESULTS)
} catch (e: IOException) {
Log.w("ForwardGeolocation", "IO Error", e)
null
}
}
}
}
@@ -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
}
@@ -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
},
)
}
}
@@ -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<GeoPoint?>(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<Double?>(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<List<Address>>(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<Address>,
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"
}
@@ -27,4 +27,11 @@ interface ILocationGrabber {
fun locationManager(): LocationState
fun locationFlow(): StateFlow<LocationState.LocationResult>
/**
* 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?
}
@@ -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<GeoPoint>(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<GeoPoint>(1) }
// Same change-guards for the highlighted cell rectangle and the level-driven zoom.
val lastHighlight = remember { arrayOfNulls<BoundingBox>(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()
},
)
}
@@ -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 =
@@ -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<ExternalId?>(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<String?>(null)
var location: StateFlow<LocationState.LocationResult>? = 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
@@ -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()
@@ -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<AddressableNote>,
@@ -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}" }
@@ -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<Double?>(null) }
var pickedLon by remember { mutableStateOf<Double?>(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()),
)
}
}
@@ -246,6 +246,7 @@ class ChatNewMessageViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
override var pickedGeoHash by mutableStateOf<String?>(null)
var location: StateFlow<LocationState.LocationResult>? = 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()
@@ -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) {
@@ -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,
@@ -193,6 +193,7 @@ open class ChannelNewMessageViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
override var pickedGeoHash by mutableStateOf<String?>(null)
var location: StateFlow<LocationState.LocationResult>? = 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()
@@ -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,
@@ -205,6 +205,7 @@ class LongFormPostViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
override var pickedGeoHash by mutableStateOf<String?>(null)
var location: StateFlow<LocationState.LocationResult>? = 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
@@ -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)
}
}
@@ -185,6 +185,7 @@ open class NewProductViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
override var pickedGeoHash by mutableStateOf<String?>(null)
var location: StateFlow<LocationState.LocationResult>? = 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()
@@ -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 })
}
}
}
@@ -329,6 +329,8 @@ open class ShortNotePostViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
var location: StateFlow<LocationState.LocationResult>? = null
override var pickedGeoHash by mutableStateOf<String?>(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
@@ -178,6 +178,7 @@ open class NestNewMessageViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
override var pickedGeoHash by mutableStateOf<String?>(null)
var location: StateFlow<LocationState.LocationResult>? = 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()
@@ -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) {
@@ -197,6 +197,7 @@ class NewPublicMessageViewModel :
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
override var pickedGeoHash by mutableStateOf<String?>(null)
var location: StateFlow<LocationState.LocationResult>? = 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()
+23 -1
View File
@@ -1630,6 +1630,11 @@
<string name="follow_list_kind3follows_users_only">All User Follows</string>
<string name="follow_list_kind3_follows_users_only">Default Follow List</string>
<string name="follow_list_aroundme">Around Me</string>
<string name="follow_list_teleport">Teleport to a place…</string>
<string name="follow_geohash">Follow this location</string>
<string name="unfollow_geohash">Unfollow this location</string>
<string name="geo_post_posting_to">Posting to</string>
<string name="geo_post_change_place">Change</string>
<string name="follow_list_global">Global</string>
<string name="follow_list_curated">Curated</string>
<string name="follow_list_mine">Mine</string>
@@ -2147,6 +2152,10 @@
<string name="geohash_title">Expose Location as </string>
<string name="geohash_explainer">Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location</string>
<string name="geohash_teleport_title">Teleport</string>
<string name="geohash_teleport_action">✈ Teleport here</string>
<string name="location_pick_on_map">Pick a place on the map</string>
<string name="location_change_place">Change place on the map</string>
<string name="geohash_exclusive">Location-exclusive Post</string>
<string name="geohash_exclusive_explainer">Only followers of the location will see it. Your general followers won\'t see it.</string>
@@ -2356,7 +2365,20 @@
<string name="relay_group_field_topics">Topics</string>
<string name="relay_group_field_topics_hint">bitcoin, nostr, art</string>
<string name="relay_group_field_geohash">Location (geohash)</string>
<string name="relay_group_field_geohash_hint">u0nd</string>
<string name="relay_group_field_geohash_hint">Tap the map icon to choose a place</string>
<string name="relay_group_location_add">Add a location</string>
<string name="relay_group_location_add_desc">Pin your group on a map so people nearby can discover it.</string>
<string name="relay_group_location_edit">Change location</string>
<string name="relay_group_location_clear">Remove location</string>
<string name="relay_group_location_manual">Enter a geohash manually</string>
<!-- Reusable geohash location picker (shared, not group-specific) -->
<string name="location_picker_title">Choose location</string>
<string name="location_picker_hint">Move the map, search for a place, or use your current location.</string>
<string name="location_picker_search_hint">Search for a city or address</string>
<string name="location_picker_search_empty">No matching place found.</string>
<string name="location_picker_use_mine">Use my current location</string>
<string name="location_picker_area">Area size</string>
<string name="location_picker_confirm">Use this location</string>
<string name="relay_group_section_structure">Structure</string>
<string name="relay_group_parent_desc">Nest this group under a parent to build a hierarchy.</string>
<string name="relay_group_parent_label">Parent group</string>
@@ -161,6 +161,7 @@ object MaterialSymbols {
val MoreVert = MaterialSymbol("\uE5D4")
val MoveToInbox = MaterialSymbol("\uE168")
val MusicNote = MaterialSymbol("\uE405")
val MyLocation = MaterialSymbol("\uE55C")
val News = MaterialSymbol("\uE032")
val NoAccounts = MaterialSymbol("\uF03E")
val NoEncryption = MaterialSymbol("\uF03F")