mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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 ")
|
||||
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+95
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
-114
@@ -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
|
||||
}
|
||||
|
||||
+127
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+757
@@ -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"
|
||||
}
|
||||
+7
@@ -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?
|
||||
}
|
||||
|
||||
+130
-11
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+44
-4
@@ -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 =
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
+71
-4
@@ -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}" }
|
||||
|
||||
+21
-129
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+191
-11
@@ -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,
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
+8
-12
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
Binary file not shown.
+1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user