diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d73223ca4b..9ffc3e7d31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -127,6 +127,14 @@ sealed class TopFilter( @Serializable object AroundMe : TopFilter(" Around Me ") + /** + * Not a real selection: a sentinel for the "Teleport" chip in the top-nav filter. + * The spinner intercepts it to open the map picker and then applies the chosen + * [Geohash] instead — it is never persisted or dispatched to a feed flow. + */ + @Serializable + object TeleportPicker : TopFilter(" Teleport ") + @Serializable object Mine : TopFilter(" Mine ") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 1758c14a8a..4105a35552 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -74,7 +74,9 @@ class FeedTopNavFilterState( ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { - TopFilter.Global, TopFilter.Selected -> { + // TeleportPicker is a UI-only sentinel (intercepted by the spinner to open the + // map picker); it never reaches here, but fall back to Global for exhaustiveness. + TopFilter.Global, TopFilter.Selected, TopFilter.TeleportPicker -> { GlobalFeedFlow(followsRelays, proxyRelays, relayFeeds) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ForwardGeolocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ForwardGeolocation.kt new file mode 100644 index 0000000000..1068405373 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/ForwardGeolocation.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.location + +import android.content.Context +import android.location.Address +import android.location.Geocoder +import android.os.Build +import androidx.annotation.RequiresApi +import com.vitorpamplona.quartz.utils.Log +import java.io.IOException + +/** + * Forward geocoding: turn a free-text place query (a city, address, or landmark) + * into a list of candidate [Address]es so the user can jump the map there. + * + * The mirror image of [ReverseGeolocation]: on TIRAMISU+ it uses the async + * listener overload of [Geocoder.getFromLocationName] (the blocking one is + * deprecated there), and falls back to the synchronous call on older devices. + * Both branches funnel through [onReady]; a null result means "no backend, an + * error, or nothing matched" and the caller should degrade gracefully. + */ +@Suppress("DEPRECATION") +class ForwardGeolocation { + companion object { + const val MAX_RESULTS = 5 + + fun execute( + query: String, + context: Context, + onReady: (List
?) -> Unit, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + executeAsync(query, context, onReady) + } else { + onReady(executeSync(query, context)) + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun executeAsync( + query: String, + context: Context, + onReady: (List
?) -> Unit, + ) { + val listener = + object : Geocoder.GeocodeListener { + override fun onGeocode(addresses: List
) { + Log.d("ForwardGeoLocation") { "Found ${addresses.size} addresses for $query" } + onReady(addresses) + } + + override fun onError(errorMessage: String?) { + super.onError(errorMessage) + Log.w("ForwardGeoLocation") { "Failure $errorMessage" } + onReady(null) + } + } + + Log.d("ForwardGeoLocation") { "Execute Async $query" } + Geocoder(context).getFromLocationName(query, MAX_RESULTS, listener) + } + + fun executeSync( + query: String, + context: Context, + ): List
? { + Log.d("ForwardGeoLocation") { "Execute Sync $query" } + return try { + Geocoder(context).getFromLocationName(query, MAX_RESULTS) + } catch (e: IOException) { + Log.w("ForwardGeolocation", "IO Error", e) + null + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 39f5c8dd63..8426fc9bec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -72,6 +73,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName @@ -129,85 +132,108 @@ fun FeedFilterSpinner( val openDropdownLabel = stringRes(R.string.open_dropdown_menu) - Box( - modifier = modifier, - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer(modifier = Size20Modifier) + val filter = selected?.code ?: placeholderCode - // Bound the Column so long filter names (e.g. DVM titles) get truncated - // instead of wrapping to multiple lines and shoving the expand icon out. - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.weight(1f, fill = false), - ) { - val filter = selected?.code - if (filter is TopFilter.Geohash) { - LoadCityName( - geohashStr = filter.tag, - onLoading = { - Row { - Text( - text = filter.tag, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) - } - }, - ) { cityName -> + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier.weight(1f, fill = false), + contentAlignment = Alignment.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Size20Modifier) + + // Bound the Column so long filter names (e.g. DVM titles) get truncated + // instead of wrapping to multiple lines and shoving the expand icon out. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f, fill = false), + ) { + if (filter is TopFilter.Geohash) { + LoadCityName( + geohashStr = filter.tag, + onLoading = { + Row { + Text( + text = filter.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) + } + }, + ) { cityName -> + Text( + text = cityName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } else { Text( - text = cityName, + text = currentText, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - } else { - Text( - text = currentText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (filter is TopFilter.AroundMe) { - val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) - if (!locationPermissionState.status.isGranted) { - LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } + if (filter is TopFilter.AroundMe) { + val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) + if (!locationPermissionState.status.isGranted) { + LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } - Text( - text = stringRes(R.string.lack_location_permissions), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - val location by Amethyst.instance.locationManager.geohashStateFlow - .collectAsStateWithLifecycle() + Text( + text = stringRes(R.string.lack_location_permissions), + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + val location by Amethyst.instance.locationManager.geohashStateFlow + .collectAsStateWithLifecycle() - when (val myLocation = location) { - is LocationState.LocationResult.Success -> { - LoadCityName( - geohashStr = myLocation.geoHash.toString(), - onLoading = { - Row { - Text( - text = "(${myLocation.geoHash})", - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(modifier = StdHorzSpacer) - LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) - } - }, - ) { cityName -> + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + LoadCityName( + geohashStr = myLocation.geoHash.toString(), + onLoading = { + Row { + Text( + text = "(${myLocation.geoHash})", + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdHorzSpacer) + LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) + } + }, + ) { cityName -> + Text( + text = "($cityName)", + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + LocationState.LocationResult.LackPermission -> { Text( - text = "($cityName)", + text = stringRes(R.string.lack_location_permissions), + fontSize = Font12SP, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + LocationState.LocationResult.Loading -> { + Text( + text = stringRes(R.string.loading_location), fontSize = Font12SP, lineHeight = 12.sp, maxLines = 1, @@ -215,58 +241,46 @@ fun FeedFilterSpinner( ) } } - - LocationState.LocationResult.LackPermission -> { - Text( - text = stringRes(R.string.lack_location_permissions), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - LocationState.LocationResult.Loading -> { - Text( - text = stringRes(R.string.loading_location), - fontSize = Font12SP, - lineHeight = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } } } - } - Icon( - symbol = MaterialSymbols.ExpandMore, - contentDescription = explainer, - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.placeholderText, + Icon( + symbol = MaterialSymbols.ExpandMore, + contentDescription = explainer, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + Box( + modifier = + Modifier + .matchParentSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + optionsShowing = true + }.semantics { + role = Role.DropdownList + stateDescription = accessibilityDescription + onClick(label = openDropdownLabel) { + optionsShowing = true + return@onClick true + } + }, ) } - Box( - modifier = - Modifier - .matchParentSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { - optionsShowing = true - }.semantics { - role = Role.DropdownList - stateDescription = accessibilityDescription - onClick(label = openDropdownLabel) { - optionsShowing = true - return@onClick true - } - }, - ) + + // Save/unsave the active place to the followed-locations list (kind 10081), so a + // teleported spot can be kept as a permanent chip — the "add to my interests" step. + if (filter is TopFilter.Geohash) { + FollowLocationToggle(filter.tag, accountViewModel) + } } + var teleporting by remember { mutableStateOf(false) } + if (optionsShowing && options.isNotEmpty()) { GroupedFeedFilterDialog( title = explainer, @@ -274,12 +288,60 @@ fun FeedFilterSpinner( onDismiss = { optionsShowing = false }, onSelect = { definition -> optionsShowing = false - onSelect(definition) + // "Teleport" isn't a real filter — open the map picker and apply the chosen + // place as this screen's Geohash filter via the normal onSelect path. + if (definition.code is TopFilter.TeleportPicker) { + teleporting = true + } else { + onSelect(definition) + } }, ) { RenderOption(it.name, accountViewModel) } } + + if (teleporting) { + GeohashLocationPickerDialog( + initialGeohash = (selected?.code as? TopFilter.Geohash)?.tag, + onDismiss = { teleporting = false }, + onConfirm = { cell -> + teleporting = false + onSelect(FeedDefinition(code = TopFilter.Geohash(cell), name = GeoHashName(cell))) + }, + ) + } +} + +/** A compact toggle in the filter header to follow/unfollow the active geohash location. */ +@Composable +private fun FollowLocationToggle( + tag: String, + accountViewModel: AccountViewModel, +) { + val isFollowing by observeUserIsFollowingGeohash(tag, accountViewModel) + IconButton(onClick = { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + if (isFollowing) R.string.login_with_a_private_key_to_be_able_to_unfollow else R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else if (isFollowing) { + accountViewModel.unfollowGeohash(tag) + } else { + accountViewModel.followGeohash(tag) + } + }) { + Icon( + symbol = if (isFollowing) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd, + contentDescription = + stringRes( + if (isFollowing) R.string.unfollow_geohash else R.string.follow_geohash, + ), + modifier = Size20Modifier, + tint = if (isFollowing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } @Composable @@ -366,6 +428,7 @@ private fun FeedDefinition.group(): FeedGroup = is ResourceName -> { when (code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS + is TopFilter.TeleportPicker -> FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS is TopFilter.Selected -> FeedGroup.RELAYS is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS @@ -516,6 +579,10 @@ private fun FeedIcon( MaterialSymbols.LocationOn } + is TopFilter.TeleportPicker -> { + MaterialSymbols.TravelExplore + } + is TopFilter.AllFollows -> { MaterialSymbols.Groups } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt new file mode 100644 index 0000000000..5196bf7284 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeoHashPostSection.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.location + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp + +/** + * The shared location section for post composers. Defaults to the device-GPS flow + * ([LocationAsHash]), and adds a "pick a place on the map" action that opens + * [GeohashLocationPickerDialog] and stores the chosen geohash in + * [ILocationGrabber.pickedGeoHash] — which each composer's build step then prefers + * over the live GPS fix. Picking a place also skips the GPS permission prompt. + * + * [innerContent] is a slot for composer-specific extras rendered beneath the + * location readout (e.g. the "location-exclusive post" switch). + */ +@Composable +fun GeoHashPostSection( + model: ILocationGrabber, + innerContent: @Composable () -> Unit = {}, +) { + var showPicker by remember { mutableStateOf(false) } + val picked = model.pickedGeoHash + + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = Size10dp, horizontal = Size10dp), + ) { + if (picked != null) { + // A map-picked place: show it, and let the user clear back to GPS. + Row(verticalAlignment = CenterVertically, modifier = Modifier.fillMaxWidth()) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.geohash_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp), + ) + DisplayLocationInTitle(geohash = picked) + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = { model.pickedGeoHash = null }) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.remove_location), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + innerContent() + } else { + // GPS mode (unchanged): current device location + the composer's extras. + LocationAsHash(model, innerContent) + } + + TextButton(onClick = { showPicker = true }, modifier = Modifier.padding(top = 4.dp)) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(if (picked != null) R.string.location_change_place else R.string.location_pick_on_map), + modifier = Modifier.padding(start = 6.dp), + ) + } + } + + if (showPicker) { + GeohashLocationPickerDialog( + initialGeohash = picked, + onDismiss = { showPicker = false }, + onConfirm = { cell -> + model.pickedGeoHash = cell + showPicker = false + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt new file mode 100644 index 0000000000..3a103691d8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/GeohashLocationPickerDialog.kt @@ -0,0 +1,757 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.note.creators.location + +import android.Manifest +import android.location.Address +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.location.ForwardGeolocation +import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.label +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.osmdroid.util.BoundingBox +import org.osmdroid.util.GeoPoint +import kotlin.math.abs + +/** Zoom the map animates to after a search hit or a "use my location" tap. */ +private const val RECENTER_ZOOM = 14.0 + +/** How far out to start when the picker opens with nothing selected yet. */ +private const val WORLD_ZOOM = 2.5 + +/** + * A starting zoom so a seeded geohash cell roughly fills the view: a coarse cell + * (few chars → large area) opens zoomed out, a fine cell zoomed in. Without this a + * region-level seed would open uselessly tight and a building-level seed too loose. + */ +private fun zoomForGeohashLength(length: Int): Double = + when { + length <= 1 -> 2.0 + length == 2 -> 4.0 + length == 3 -> 6.5 + length == 4 -> 9.0 + length == 5 -> 12.0 + length == 6 -> 14.0 + length == 7 -> 16.0 + else -> 17.5 + } + +/** Neutral starting center (mid-Atlantic) when the picker opens with no seed. */ +private const val WORLD_CENTER_LAT = 20.0 +private const val WORLD_CENTER_LON = 0.0 + +/** + * Minimum center shift (degrees) from the opening center that counts as a real pan, + * so an initial osmdroid layout-scroll at the opening center is not mistaken for a pick. + */ +private const val SELECT_MOVE_EPS = 0.0005 + +/** Give up waiting for a GPS fix after this long so the button never spins forever. */ +private const val GPS_FIX_TIMEOUT_MS = 20_000L + +/** + * A full-screen, map-first location picker dialog that produces a geohash string. + * + * Thin chrome around [GeohashLocationPickerContent]: a close button + title header + * over the shared picker body. Use this when you need the picker as a modal (e.g. + * the NIP-29 group form); use [GeohashLocationPickerContent] directly when hosting + * it inside your own screen scaffold (e.g. the Geohash-chat Teleport screen). + */ +@Composable +fun GeohashLocationPickerDialog( + initialGeohash: String?, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Column(Modifier.fillMaxSize()) { + PickerHeader(onClose = onDismiss) + GeohashLocationPickerContent( + initialGeohash = initialGeohash, + confirmLabel = stringRes(R.string.location_picker_confirm), + onConfirm = onConfirm, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } + } + } +} + +/** + * The reusable, chrome-less body of the location picker: a map with a fixed center + * pin, a place-search bar, a "use my location" button, precision chips, a live + * place-name readout, and a confirm button labeled [confirmLabel]. Fill it into a + * dialog ([GeohashLocationPickerDialog]) or a screen scaffold. + * + * The user has three ways to land on a place — pan the map under the center pin, + * search for a place by name (forward geocoding), or tap "use my location" (device + * GPS) — and never has to know what a geohash is. The chosen precision + * ([GeohashChannelLevel]) controls how many characters the resulting geohash has. + * On confirm, [onConfirm] receives the encoded geohash (e.g. `u4pruy`). + * + * Reuses [LocationPickerMap] (in center-pin mode), [LoadCityName] for the + * human-readable place name, and the app-wide [LocationState] for GPS. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun GeohashLocationPickerContent( + initialGeohash: String?, + confirmLabel: String, + onConfirm: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val keyboard = LocalSoftwareKeyboardController.current + val scope = rememberCoroutineScope() + val locationManager = Amethyst.instance.locationManager + + val seed = remember(initialGeohash) { initialGeohash?.takeIf { it.isNotBlank() }?.let { GeoHash.decode(it) } } + val seedLen = initialGeohash?.trim()?.length ?: 0 + + // The map opens centered here. Without a seed there is no real selection yet — and + // osmdroid can emit an initial scroll at this exact center, which must NOT be treated + // as a pick (else the picker would auto-select the mid-Atlantic and enable Confirm). + val initialLat = seed?.centerLat ?: WORLD_CENTER_LAT + val initialLon = seed?.centerLon ?: WORLD_CENTER_LON + + var pickedLat by remember { mutableStateOf(seed?.centerLat) } + var pickedLon by remember { mutableStateOf(seed?.centerLon) } + var hasSelection by remember { mutableStateOf(seed != null) } + var level by remember { + mutableStateOf(GeohashChannelLevel.forChars(seedLen) ?: GeohashChannelLevel.CITY) + } + var recenter by remember { mutableStateOf(null) } + + val cell = + remember(pickedLat, pickedLon, level, hasSelection) { + val lat = pickedLat + val lon = pickedLon + if (hasSelection && lat != null && lon != null) GeoHash.encode(lat, lon, level.chars).toString() else null + } + + // Debounce the reverse-geocode: panning changes [cell] constantly, and we don't + // want to hammer the Geocoder — only resolve a place name once the map settles. + var settledCell by remember { mutableStateOf(cell) } + LaunchedEffect(cell) { + delay(450) + settledCell = cell + } + + // The selected geohash cell outlined on the map, and its stroke colour. + val highlightColor = MaterialTheme.colorScheme.primary.toArgb() + val highlight = remember(cell) { cell?.let(::geohashBounds) } + + // When the area-size level changes (only after a real selection), zoom the map so the + // new cell is comfortably framed. + var zoomTo by remember { mutableStateOf(null) } + LaunchedEffect(level) { + if (hasSelection) zoomTo = zoomForGeohashLength(level.chars) + } + + // Lift the center pin briefly whenever the target point moves, for tactile feedback. + var pinLifted by remember { mutableStateOf(false) } + LaunchedEffect(pickedLat, pickedLon) { + pinLifted = true + delay(220) + pinLifted = false + } + + // "Use my location": tapping either fires the fetch (permission already granted) or + // asks for it; [awaitingPermission] tracks the in-flight prompt so the button shows a + // spinner. The result callback fires on BOTH grant and denial — so a denial always + // clears the spinner (an isGranted-keyed effect wouldn't re-run on the false→false case). + var wantsMyLocation by remember { mutableStateOf(false) } + var awaitingPermission by remember { mutableStateOf(false) } + val permission = + rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION) { granted -> + awaitingPermission = false + if (granted) wantsMyLocation = true + } + LaunchedEffect(permission.status.isGranted) { + locationManager.setLocationPermission(permission.status.isGranted) + } + LaunchedEffect(wantsMyLocation) { + if (wantsMyLocation) { + // Wait for the first real fix, but bail on a timeout so the button never spins + // forever (permission granted yet location off, indoors, emulator with no fix…). + val fix = + withTimeoutOrNull(GPS_FIX_TIMEOUT_MS) { + locationManager.preciseGeohashStateFlow.first { it is LocationState.LocationResult.Success } + } as? LocationState.LocationResult.Success + if (fix != null) { + recenter = GeoPoint(fix.geoHash.centerLat, fix.geoHash.centerLon) + pickedLat = fix.geoHash.centerLat + pickedLon = fix.geoHash.centerLon + hasSelection = true + } + wantsMyLocation = false + } + } + val onUseMyLocation = { + if (permission.status.isGranted) { + wantsMyLocation = true + } else { + awaitingPermission = true + permission.launchPermissionRequest() + } + } + + // On open with no seed, if we already have location permission, start the map at the + // user's current position instead of the neutral world view. Runs once, and never + // prompts — it only uses permission that's already granted. + var autoLocated by remember { mutableStateOf(false) } + LaunchedEffect(permission.status.isGranted) { + if (!autoLocated && seed == null && permission.status.isGranted) { + autoLocated = true + wantsMyLocation = true + } + } + + // Forward-geocode search. Results are shown as a pick list; choosing one flies there. + var query by remember { mutableStateOf("") } + var searching by remember { mutableStateOf(false) } + var searchMissed by remember { mutableStateOf(false) } + var results by remember { mutableStateOf>(emptyList()) } + val runSearch = { + val q = query.trim() + if (q.isNotEmpty()) { + keyboard?.hide() + searching = true + searchMissed = false + results = emptyList() + // Off the main thread: on Android < 13 ForwardGeolocation.execute() calls the + // blocking Geocoder synchronously, which would freeze/ANR the UI thread. + scope.launch(Dispatchers.IO) { + ForwardGeolocation.execute(q, context) { addresses -> + searching = false + val hits = addresses.orEmpty().filter { it.hasLatitude() && it.hasLongitude() } + results = hits + searchMissed = hits.isEmpty() + } + } + } + } + val selectResult: (Address) -> Unit = { hit -> + recenter = GeoPoint(hit.latitude, hit.longitude) + pickedLat = hit.latitude + pickedLon = hit.longitude + hasSelection = true + results = emptyList() + query = "" + keyboard?.hide() + } + + Column(modifier.fillMaxWidth()) { + Box(Modifier.fillMaxWidth().weight(1f)) { + LocationPickerMap( + latitude = seed?.centerLat ?: 20.0, + longitude = seed?.centerLon ?: 0.0, + pickedLatitude = null, + pickedLongitude = null, + zoom = if (seed != null) zoomForGeohashLength(seedLen) else WORLD_ZOOM, + recenter = recenter, + recenterZoom = RECENTER_ZOOM, + zoomTo = zoomTo, + highlight = highlight, + highlightColor = highlightColor, + onCenterChanged = { lat, lon -> + pickedLat = lat + pickedLon = lon + // A pan/zoom away from the opening center is the user's first real pick. + if (!hasSelection && (abs(lat - initialLat) > SELECT_MOVE_EPS || abs(lon - initialLon) > SELECT_MOVE_EPS)) { + hasSelection = true + } + }, + onPick = { lat, lon -> + recenter = GeoPoint(lat, lon) + pickedLat = lat + pickedLon = lon + hasSelection = true + }, + modifier = Modifier.fillMaxSize(), + ) + + CenterPin(lifted = pinLifted, modifier = Modifier.align(Alignment.Center)) + + Column( + Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(12.dp), + ) { + SearchField( + query = query, + onQueryChange = { + query = it + searchMissed = false + }, + onSearch = runSearch, + searching = searching, + missed = searchMissed, + modifier = Modifier.fillMaxWidth(), + ) + if (results.isNotEmpty()) { + SearchResults( + results = results, + onSelect = selectResult, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) + } + } + + MyLocationButton( + loading = wantsMyLocation || awaitingPermission, + onClick = onUseMyLocation, + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) + } + + PickerBottomBar( + cell = cell, + settledCell = settledCell, + level = level, + confirmLabel = confirmLabel, + onLevel = { level = it }, + onConfirm = { cell?.let(onConfirm) }, + ) + } +} + +@Composable +private fun PickerHeader(onClose: () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 4.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onClose) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.cancel), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = stringRes(R.string.location_picker_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 4.dp), + ) + } + } +} + +/** + * The fixed pin hovering over the map center; the point under its tip is the selection. + * While [lifted] (the target is moving) the pin rises off the map and its shadow dot + * widens, for the tactile "dropping a pin" feel of a modern place picker. + */ +@Composable +private fun CenterPin( + lifted: Boolean, + modifier: Modifier = Modifier, +) { + val lift by animateDpAsState(targetValue = if (lifted) 8.dp else 0.dp, label = "pinLift") + val dot by animateDpAsState(targetValue = if (lifted) 10.dp else 7.dp, label = "pinDot") + + Box(modifier = modifier.size(56.dp), contentAlignment = Alignment.Center) { + // The pin's tip sits at the map center; lift the whole glyph up by half its height + // plus the animated hover offset. + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .size(46.dp) + .offset(y = -20.dp - lift), + ) + // A shadow/anchor dot marking the exact center point; it spreads as the pin lifts. + Box( + Modifier + .size(dot) + .shadow(if (lifted) 4.dp else 2.dp, CircleShape) + .background(MaterialTheme.colorScheme.primary, CircleShape), + ) + } +} + +/** A tappable list of forward-geocode candidates shown under the search field. */ +@Composable +private fun SearchResults( + results: List
, + onSelect: (Address) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.shadow(6.dp, RoundedCornerShape(16.dp)), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Column(Modifier.fillMaxWidth().heightIn(max = 240.dp).verticalScroll(rememberScrollState())) { + results.forEachIndexed { index, address -> + if (index > 0) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Row( + Modifier + .fillMaxWidth() + .clickable { onSelect(address) } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + Text( + text = address.displayLine(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + ) + } + } + } + } +} + +@Composable +private fun SearchField( + query: String, + onQueryChange: (String) -> Unit, + onSearch: () -> Unit, + searching: Boolean, + missed: Boolean, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.shadow(6.dp, RoundedCornerShape(28.dp)), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + placeholder = { Text(stringRes(R.string.location_picker_search_hint)) }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (searching) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) + } else if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.clear), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + isError = missed, + supportingText = + if (missed) { + { Text(stringRes(R.string.location_picker_search_empty)) } + } else { + null + }, + // The pill comes from the wrapping Surface; hide the text field's own + // rectangular outline so its square corners don't poke through the pill. + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + errorBorderColor = Color.Transparent, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun MyLocationButton( + loading: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FilledIconButton( + onClick = onClick, + enabled = !loading, + colors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + ), + modifier = modifier.shadow(6.dp, CircleShape).size(52.dp), + ) { + if (loading) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp)) + } else { + Icon( + symbol = MaterialSymbols.MyLocation, + contentDescription = stringRes(R.string.location_picker_use_mine), + modifier = Modifier.size(24.dp), + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PickerBottomBar( + cell: String?, + settledCell: String?, + level: GeohashChannelLevel, + confirmLabel: String, + onLevel: (GeohashChannelLevel) -> Unit, + onConfirm: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 3.dp, + shadowElevation = 8.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(16.dp), + ) { + Text( + text = stringRes(R.string.location_picker_area), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // Drop the 48dp minimum touch-target so wrapped chip rows sit 8dp apart + // (matching the horizontal gap) instead of ~24dp apart. + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + FlowRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + GeohashChannelLevel.ordered.forEach { lvl -> + FilterChip( + selected = lvl == level, + onClick = { onLevel(lvl) }, + label = { Text("${lvl.label()} · ${lvl.areaSize()}") }, + ) + } + } + } + + if (cell == null) { + Text( + text = stringRes(R.string.location_picker_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 8.dp), + ) + } else { + Row( + Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(40.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(22.dp), + ) + } + } + Column(Modifier.weight(1f).padding(start = 12.dp)) { + LoadCityName(geohashStr = settledCell ?: cell) { cityName -> + Text( + cityName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } + Text( + "#$cell", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Button( + onClick = onConfirm, + enabled = cell != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text(confirmLabel, fontWeight = FontWeight.SemiBold) + } + } + } +} + +/** + * A human, one-line label for a geocoder result: its most specific name, then the + * enclosing locality/region/country, de-duplicated. Falls back to the full address line. + */ +private fun Address.displayLine(): String { + val primary = featureName ?: locality ?: subAdminArea ?: adminArea ?: getAddressLine(0) + val context = listOfNotNull(locality, adminArea, countryName).distinct().filter { it != primary } + return listOfNotNull(primary, context.joinToString(", ").ifBlank { null }).joinToString(", ") +} + +/** + * The lat/lon bounding box of a geohash cell. A geohash of length L uses 5L bits, + * split lon = ceil(5L/2), lat = floor(5L/2); each halves its axis per bit, so the cell + * spans 180/2^latBits by 360/2^lonBits around the decoded center. + */ +private fun geohashBounds(geohash: String): BoundingBox? { + val gh = GeoHash.decode(geohash) ?: return null + val bits = 5 * geohash.length + val latBits = bits / 2 + val lonBits = bits - latBits + val latHeight = 180.0 / (1L shl latBits) + val lonWidth = 360.0 / (1L shl lonBits) + return BoundingBox( + gh.centerLat + latHeight / 2, + gh.centerLon + lonWidth / 2, + gh.centerLat - latHeight / 2, + gh.centerLon - lonWidth / 2, + ) +} + +/** A rough physical size for a geohash cell at this precision, for the chip subtitle. */ +private fun GeohashChannelLevel.areaSize(): String = + when (this) { + GeohashChannelLevel.REGION -> "~1250 km" + GeohashChannelLevel.PROVINCE -> "~39 km" + GeohashChannelLevel.CITY -> "~5 km" + GeohashChannelLevel.NEIGHBORHOOD -> "~1.2 km" + GeohashChannelLevel.BLOCK -> "~150 m" + GeohashChannelLevel.BUILDING -> "~38 m" + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt index 105ca1c223..6940f786ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/ILocationGrabber.kt @@ -27,4 +27,11 @@ interface ILocationGrabber { fun locationManager(): LocationState fun locationFlow(): StateFlow + + /** + * A geohash the user picked on the map (via [GeohashLocationPickerDialog]), which + * overrides the live GPS location at build time. Null means "use my current GPS + * location" — the default behavior. Implementers back this with a Compose state. + */ + var pickedGeoHash: String? } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt index 22838cda71..f5e3375398 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPickerMap.kt @@ -20,25 +20,35 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.location +import android.view.MotionEvent +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.graphics.ColorUtils import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.ui.theme.isLight import org.osmdroid.config.Configuration import org.osmdroid.events.MapEventsReceiver +import org.osmdroid.events.MapListener +import org.osmdroid.events.ScrollEvent +import org.osmdroid.events.ZoomEvent import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.BoundingBox import org.osmdroid.util.GeoPoint import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.MapView import org.osmdroid.views.overlay.MapEventsOverlay import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.Polygon /** * An interactive OpenStreetMap (osmdroid) picker: tapping the map reports the @@ -47,6 +57,14 @@ import org.osmdroid.views.overlay.Marker * * Shares the tile/User-Agent/lifecycle setup with [LocationPreviewMap]; unlike * that display-only map, this one installs a [MapEventsOverlay] for tap picking. + * + * Two optional hooks power a "move the map under a fixed center pin" experience + * (the modern picker style) without breaking the tap-to-drop callers: + * - [onCenterChanged] fires whenever the map is scrolled or zoomed, reporting the + * new map center — pair it with a Compose crosshair drawn over the map's center. + * - [recenter] animates the map to a new point when its value changes (e.g. after + * a place search or a "use my location" tap). Passing the same value twice is a + * no-op, so it is safe to hoist in state. */ @Composable fun LocationPickerMap( @@ -56,11 +74,31 @@ fun LocationPickerMap( pickedLongitude: Double?, modifier: Modifier = Modifier, zoom: Double = 4.0, + recenter: GeoPoint? = null, + recenterZoom: Double? = null, + zoomTo: Double? = null, + highlight: BoundingBox? = null, + highlightColor: Int = 0, + onCenterChanged: ((Double, Double) -> Unit)? = null, onPick: (Double, Double) -> Unit, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val currentOnPick by rememberUpdatedState(onPick) + val currentOnCenterChanged by rememberUpdatedState(onCenterChanged) + val darkTheme = !MaterialTheme.colorScheme.isLight + + // Tracks the last point we animated to, so a recomposition that re-supplies the + // same [recenter] doesn't yank the map back while the user is panning. + val lastRecenter = remember { arrayOfNulls(1) } + + // Tracks the last marker point so we only rebuild the marker overlay (and invalidate) + // when it actually changes — not on every scroll-driven recomposition. + val lastMarker = remember { arrayOfNulls(1) } + + // Same change-guards for the highlighted cell rectangle and the level-driven zoom. + val lastHighlight = remember { arrayOfNulls(1) } + val lastZoomTo = remember { doubleArrayOf(Double.NaN) } val mapView = remember(context) { @@ -73,6 +111,18 @@ fun LocationPickerMap( controller.setZoom(zoom) controller.setCenter(GeoPoint(latitude, longitude)) + // Ask ancestors (nav drawer, horizontal pager, feed) to stop intercepting + // touches while a finger is on the map, so a horizontal drag pans the map + // instead of opening the drawer or being stolen as a swipe. Mirrors + // LocationPreviewMap. Returning false lets the MapView still pan/zoom/tap. + setOnTouchListener { view, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> view.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> view.parent?.requestDisallowInterceptTouchEvent(false) + } + false + } + val receiver = object : MapEventsReceiver { override fun singleTapConfirmedHelper(p: GeoPoint): Boolean { @@ -86,6 +136,20 @@ fun LocationPickerMap( } } overlays.add(0, MapEventsOverlay(receiver)) + + addMapListener( + object : MapListener { + override fun onScroll(event: ScrollEvent?): Boolean { + mapCenter.let { currentOnCenterChanged?.invoke(it.latitude, it.longitude) } + return false + } + + override fun onZoom(event: ZoomEvent?): Boolean { + mapCenter.let { currentOnCenterChanged?.invoke(it.latitude, it.longitude) } + return false + } + }, + ) } } @@ -105,22 +169,77 @@ fun LocationPickerMap( } } + // Follow the app theme: dim the bright MAPNIK tiles in dark mode (matching the + // display-only LocationPreviewMap). Applied only when the theme flips, not per frame. + LaunchedEffect(mapView, darkTheme) { + mapView.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else MUTED_TILE_FILTER) + mapView.invalidate() + } + AndroidView( modifier = modifier, factory = { mapView }, update = { map -> - map.overlays.removeAll { it is Marker } - if (pickedLatitude != null && pickedLongitude != null) { - val point = GeoPoint(pickedLatitude, pickedLongitude) - val marker = - Marker(map).apply { - position = point - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - setInfoWindow(null) - } - map.overlays.add(marker) + if (recenter != null && recenter != lastRecenter[0]) { + lastRecenter[0] = recenter + if (recenterZoom != null) { + map.controller.animateTo(recenter, recenterZoom, 800L) + } else { + map.controller.animateTo(recenter) + } + } + + // Animate the zoom when the caller asks (e.g. the area-size level changed), + // keeping the current center. Guarded so it fires once per distinct value. + if (zoomTo != null && zoomTo != lastZoomTo[0]) { + lastZoomTo[0] = zoomTo + map.controller.zoomTo(zoomTo, 400L) + } + + // Outline the selected geohash cell so the user sees exactly which region a + // post/filter will cover. Rebuilt only when the cell bounds change. + if (highlight != lastHighlight[0]) { + lastHighlight[0] = highlight + map.overlays.removeAll { it is Polygon } + if (highlight != null) { + map.overlays.add( + 0, + Polygon(map).apply { + points = + listOf( + GeoPoint(highlight.latNorth, highlight.lonWest), + GeoPoint(highlight.latNorth, highlight.lonEast), + GeoPoint(highlight.latSouth, highlight.lonEast), + GeoPoint(highlight.latSouth, highlight.lonWest), + ) + fillPaint.color = ColorUtils.setAlphaComponent(highlightColor, 38) + outlinePaint.color = highlightColor + outlinePaint.strokeWidth = 4f + setOnClickListener { _, _, _ -> false } + }, + ) + } + map.invalidate() + } + + // Only rebuild the marker overlay when the picked point actually changes. The + // update block runs on every scroll-driven recomposition, so unconditionally + // clearing/re-adding the marker and invalidating would be per-frame waste. + val marker = if (pickedLatitude != null && pickedLongitude != null) GeoPoint(pickedLatitude, pickedLongitude) else null + if (marker != lastMarker[0]) { + lastMarker[0] = marker + map.overlays.removeAll { it is Marker } + if (marker != null) { + map.overlays.add( + Marker(map).apply { + position = marker + setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + setInfoWindow(null) + }, + ) + } + map.invalidate() } - map.invalidate() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt index 984c3fc985..5d9161d1e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/LocationPreviewMap.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.note.creators.location import android.graphics.ColorFilter +import android.graphics.ColorMatrix import android.graphics.ColorMatrixColorFilter import android.graphics.drawable.BitmapDrawable import android.view.MotionEvent @@ -57,7 +58,7 @@ private const val DEFAULT_ZOOM = 16.0 * unlike a plain colour invert (forests → magenta) or a desaturating invert * (everything → black). */ -private val NIGHT_TILE_FILTER: ColorFilter = +internal val NIGHT_TILE_FILTER: ColorFilter = ColorMatrixColorFilter( floatArrayOf( 0.574f, @@ -83,6 +84,44 @@ private val NIGHT_TILE_FILTER: ColorFilter = ), ) +/** + * Light-mode "calm" filter for the busy MAPNIK tiles: desaturate (mute the loud + * road/POI colours) and lift brightness a touch so labels read softer. Keeps the + * map legible for picking a spot without the full-colour visual noise. + */ +internal val MUTED_TILE_FILTER: ColorFilter = + ColorMatrixColorFilter( + ColorMatrix().apply { + setSaturation(0.55f) + postConcat( + ColorMatrix( + floatArrayOf( + 0.92f, + 0f, + 0f, + 0f, + 16f, + 0f, + 0.92f, + 0f, + 0f, + 16f, + 0f, + 0f, + 0.92f, + 0f, + 16f, + 0f, + 0f, + 0f, + 1f, + 0f, + ), + ), + ) + }, + ) + /** * A small OpenStreetMap (osmdroid) preview centered on [latitude]/[longitude] * with a single pin at that point. @@ -107,6 +146,7 @@ fun LocationPreviewMap( longitude: Double, modifier: Modifier = Modifier, zoom: Double = DEFAULT_ZOOM, + aspectRatio: Float = 1f, pinColor: Color? = null, pinEmoji: String? = null, pinAlpha: Float = 1f, @@ -166,15 +206,15 @@ fun LocationPreviewMap( } AndroidView( - modifier = modifier.fillMaxWidth().aspectRatio(1f), + modifier = modifier.fillMaxWidth().aspectRatio(aspectRatio), factory = { mapView }, update = { map -> val point = GeoPoint(latitude, longitude) map.controller.setZoom(zoom) map.controller.setCenter(point) - // Follow the app theme: dim the bright MAPNIK tiles in dark mode. - map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else null) + // Follow the app theme: dim in dark mode, mute the busy colours in light mode. + map.overlayManager.tilesOverlay.setColorFilter(if (darkTheme) NIGHT_TILE_FILTER else MUTED_TILE_FILTER) map.overlays.removeAll { it is Marker } val marker = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 79bc61906c..f6cf99ee14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -111,6 +112,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip73ExternalIds.scope import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -165,6 +167,15 @@ open class CommentPostViewModel : var externalIdentity by mutableStateOf(null) var replyingTo: Note? by mutableStateOf(null) + /** The geohash channel this comment is scoped to, or null when it isn't a geo-post. */ + val geohashScope: String? + get() = (externalIdentity as? GeohashId)?.geohash + + /** Retarget a geo-post to a different location channel (from the map picker). */ + fun setGeohashScope(geohash: String) { + externalIdentity = GeohashId(geohash) + } + // The signature pre-filled by applySignature(), so an untouched signature-only // message is treated as blank instead of auto-saved as a junk draft. private var appliedSignature: String? = null @@ -242,6 +253,7 @@ open class CommentPostViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -510,6 +522,7 @@ open class CommentPostViewModel : replyingTo?.let { observeCommunityRules(it) } wantsToAddGeoHash = draftEvent.hasGeohashes() + pickedGeoHash = draftEvent.getGeoHash() notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } @@ -641,7 +654,7 @@ open class CommentPostViewModel : ) tagger.run() - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val geoHash = (if (wantsToAddGeoHash) pickedGeoHash else null) ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) @@ -867,6 +880,7 @@ open class CommentPostViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsSecretEmoji = false wantsAnonymousPost = false anonymousSignerCache = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index e67b48fc9e..6baef9f1a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll @@ -41,8 +42,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -72,7 +79,9 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection +import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog +import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.pow.PowOverrideButton @@ -97,6 +106,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet @@ -235,9 +245,16 @@ private fun GenericCommentPostBody( ) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState, reverseScrolling = true)) { postViewModel.externalIdentity?.let { - Row { - DisplayExternalId(it, accountViewModel, nav) + if (it is GeohashId) { + // Geo-post: the interactive location channel (with retarget) replaces + // the static external-id marker so there's a single location control. + GeoPostLocationChannel(postViewModel) Spacer(modifier = StdVertSpacer) + } else { + Row { + DisplayExternalId(it, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } } } @@ -330,7 +347,7 @@ private fun GenericCommentPostBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - LocationAsHash(postViewModel) + GeoHashPostSection(postViewModel) } } @@ -439,6 +456,56 @@ private fun GenericCommentPostBody( } } +/** + * For a geo-post (a comment scoped to a geohash channel), shows which place it will post + * to and lets the user retarget it via the map picker. Hidden for non-geo comments. + */ +@Composable +private fun GeoPostLocationChannel(postViewModel: CommentPostViewModel) { + val scope = postViewModel.geohashScope ?: return + var showPicker by remember { mutableStateOf(false) } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { showPicker = true } + .padding(horizontal = Size10dp, vertical = 6.dp), + verticalAlignment = CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f).padding(start = 10.dp)) { + Text( + text = stringRes(R.string.geo_post_posting_to), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LoadCityName(geohashStr = scope) { cityName -> + Text(text = cityName, style = MaterialTheme.typography.bodyLarge, maxLines = 1) + } + } + TextButton(onClick = { showPicker = true }) { + Text(stringRes(R.string.geo_post_change_place)) + } + } + + if (showPicker) { + GeohashLocationPickerDialog( + initialGeohash = scope, + onDismiss = { showPicker = false }, + onConfirm = { cell -> + postViewModel.setGeohashScope(cell) + showPicker = false + }, + ) + } +} + @Composable private fun BottomRowActions(postViewModel: CommentPostViewModel) { val scrollState = rememberScrollState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 80a1ce25b2..38570df353 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -97,6 +97,14 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_aroundme), ) + // A UI-only entry: selecting it opens the map picker (handled in FeedFilterSpinner) + // and applies the chosen place as a TopFilter.Geohash for this screen's feed. + val teleport = + FeedDefinition( + code = TopFilter.TeleportPicker, + name = ResourceName(R.string.follow_list_teleport), + ) + val muteListFollow = FeedDefinition( code = TopFilter.MuteList(account.muteList.getMuteListAddress()), @@ -115,9 +123,9 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_all_favorite_dvms), ) - val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, muteListFollow) - val defaultNotificationLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, selectedFollow, globalFollow, muteListFollow) + val defaultNotificationLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, selectedFollow, globalFollow, muteListFollow) fun mergePeopleLists( peopleLists: List, @@ -261,7 +269,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow), peopleLists, interests, listOf(muteListFollow), @@ -303,7 +311,7 @@ class TopNavFilterState( listOf( // Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the // music + playlists screens can show only the user's own published items. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), peopleLists, interests, listOf(muteListFollow), @@ -321,7 +329,7 @@ class TopNavFilterState( listOf( // Git repository announcements can be narrowed by author, hashtag and geohash, // so this mirrors the kind3 catalog plus "Mine" — the user's own repositories. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), peopleLists, interests, listOf(muteListFollow), @@ -355,7 +363,7 @@ class TopNavFilterState( // Relay-group discovery routes to relays by author, hashtag and geohash, plus // favorited + joined-group relay chips; "Mine" (the joined-groups view) sits last // in the base group to match the ordering every other feed uses. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), peopleLists, interests, joinedRelayChips, @@ -374,7 +382,7 @@ class TopNavFilterState( listOf( // Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the // podcasts + episodes screens can show only the user's own published shows/episodes. - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow), peopleLists, interests, listOf(muteListFollow), @@ -387,7 +395,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow), peopleLists, listOf(muteListFollow), ).flatten().toImmutableList(), @@ -415,7 +423,7 @@ class TopNavFilterState( checkNotInMainThread() emit( listOf( - listOf(allFollows, userFollows, kind3Follows, aroundMe, selectedFollow, globalFollow), + listOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, selectedFollow, globalFollow), peopleLists, listOf(muteListFollow), ).flatten().toImmutableList(), @@ -455,22 +463,22 @@ class TopNavFilterState( val musicRoutes = _musicRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) val gitRepositoryRoutes = _gitRepositoryRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) val relayGroupsDiscoveryRoutes = _relayGroupsDiscoveryRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) val podcastRoutes = _podcastRoutes .flowOn(Dispatchers.IO) - .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, teleport, globalFollow, mineFollow, muteListFollow)) fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt index d7f2611d31..2f3f4d8db2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashTeleportScreen.kt @@ -20,163 +20,55 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPickerMap +import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel -import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon +import com.vitorpamplona.amethyst.ui.stringRes /** - * Teleport: tap a point on the map to join a remote geohash cell (at a chosen + * Teleport: pick a point on the map to join a remote geohash cell (at a chosen * precision level) you are not physically in. Joining follows the cell (kind * 10081) and opens the chat with the teleport flag set, so outgoing messages * carry the ["t","teleport"] marker. + * + * The map/search/precision UI is the shared [GeohashLocationPickerContent]; this + * screen only supplies the top bar and the teleport-specific confirm action. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun GeohashTeleportScreen( accountViewModel: AccountViewModel, nav: INav, ) { - var pickedLat by remember { mutableStateOf(null) } - var pickedLon by remember { mutableStateOf(null) } - var level by remember { mutableStateOf(GeohashChannelLevel.CITY) } - - val cell = - remember(pickedLat, pickedLon, level) { - val lat = pickedLat - val lon = pickedLon - if (lat != null && lon != null) GeoHash.encode(lat, lon, level.chars).toString() else null - } - Scaffold( topBar = { TopBarExtensibleWithBackButton( - title = { Text("Teleport", fontWeight = FontWeight.Bold) }, + title = { Text(stringRes(R.string.geohash_teleport_title), fontWeight = FontWeight.Bold) }, popBack = nav::popBack, ) }, ) { pad -> - Column( - Modifier - .fillMaxSize() - .padding(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding()), - ) { - LocationPickerMap( - latitude = 20.0, - longitude = 0.0, - pickedLatitude = pickedLat, - pickedLongitude = pickedLon, - modifier = Modifier.weight(1f).fillMaxWidth(), - onPick = { lat, lon -> - pickedLat = lat - pickedLon = lon - }, - ) - - Surface( - shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 3.dp, - shadowElevation = 8.dp, - modifier = Modifier.fillMaxWidth(), - ) { - Column(Modifier.fillMaxWidth().padding(16.dp)) { - Text( - if (cell == null) "Tap the map to pick a spot" else "Precision", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Row( - Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - GeohashChannelLevel.ordered.forEach { lvl -> - FilterChip( - selected = lvl == level, - onClick = { level = lvl }, - label = { Text(lvl.label()) }, - ) - } - } - - if (cell != null) { - Row( - Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primaryContainer, - modifier = Modifier.size(40.dp), - ) { - Box(contentAlignment = Alignment.Center) { - SymbolIcon( - symbol = MaterialSymbols.LocationOn, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(22.dp), - ) - } - } - Column(Modifier.weight(1f).padding(start = 12.dp)) { - LoadCityName(geohashStr = cell) { cityName -> - Text(cityName, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, maxLines = 1) - } - Text( - "#$cell", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - Button( - onClick = { - accountViewModel.followGeohash(cell) - nav.popBack() - nav.nav(Route.GeohashChat(cell, teleported = true)) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("✈ Teleport here", fontWeight = FontWeight.SemiBold) - } - } - } - } - } + GeohashLocationPickerContent( + initialGeohash = null, + confirmLabel = stringRes(R.string.geohash_teleport_action), + onConfirm = { cell -> + accountViewModel.followGeohash(cell) + nav.popBack() + nav.nav(Route.GeohashChat(cell, teleported = true)) + }, + modifier = + Modifier + .fillMaxSize() + .padding(top = pad.calculateTopPadding(), bottom = pad.calculateBottomPadding()), + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 2d92030b7b..b100b957b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -246,6 +246,7 @@ class ChatNewMessageViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -370,6 +371,7 @@ class ChatNewMessageViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -579,7 +581,7 @@ class ChatNewMessageViewModel : val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val message = messageText val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null @@ -649,6 +651,7 @@ class ChatNewMessageViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 8941be093f..d87f54987d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -100,7 +100,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.previews.PreviewUrl @@ -250,7 +250,7 @@ fun GroupDMScreenContent( } if (postViewModel.wantsToAddGeoHash) { - LocationAsHash(postViewModel) + GeoHashPostSection(postViewModel) } if (postViewModel.wantsForwardZapTo) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt index 76222e2b00..0618c5188a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupMetadataScreen.kt @@ -39,12 +39,17 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -57,6 +62,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -71,10 +77,14 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog +import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName +import com.vitorpamplona.amethyst.ui.note.creators.location.LocationPreviewMap import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash import com.vitorpamplona.quartz.nip29RelayGroups.GroupId /** @@ -370,17 +380,8 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { placeholder = { Text(stringRes(R.string.relay_group_field_topics_hint)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) - OutlinedTextField( - value = viewModel.geohash.value, - onValueChange = { - viewModel.geohash.value = it - viewModel.markTouched() - }, - singleLine = true, - label = { Text(stringRes(R.string.relay_group_field_geohash)) }, - placeholder = { Text(stringRes(R.string.relay_group_field_geohash_hint)) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) + Spacer(Modifier.height(8.dp)) + GroupLocationField(viewModel) Spacer(Modifier.height(12.dp)) Text( @@ -423,6 +424,185 @@ private fun GroupMetadataFields(viewModel: RelayGroupMetadataViewModel) { } } +/** + * The group's discovery location. Map-first: an inviting card opens a full-screen + * map picker ([GeohashLocationPickerDialog]) that turns a tapped/searched place + * into the geohash the ViewModel already stores. A collapsed "enter manually" + * field keeps the paste-a-known-geohash path for power users. The geohash text in + * [RelayGroupMetadataViewModel.geohash] stays the single source of truth. + */ +@Composable +private fun GroupLocationField(viewModel: RelayGroupMetadataViewModel) { + var showPicker by remember { mutableStateOf(false) } + var showManual by remember { mutableStateOf(false) } + + val geohash = + viewModel.geohash.value.text + .trim() + + if (geohash.isNotBlank()) { + SelectedLocationCard( + geohash = geohash, + onEdit = { showPicker = true }, + onClear = { + viewModel.geohash.value = TextFieldValue("") + viewModel.markTouched() + }, + ) + } else { + AddLocationCard(onClick = { showPicker = true }) + } + + TextButton( + onClick = { showManual = !showManual }, + modifier = Modifier.padding(top = 2.dp), + ) { + Icon( + symbol = MaterialSymbols.Edit, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.relay_group_location_manual), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 6.dp), + ) + } + if (showManual) { + OutlinedTextField( + value = viewModel.geohash.value, + onValueChange = { + viewModel.geohash.value = it + viewModel.markTouched() + }, + singleLine = true, + label = { Text(stringRes(R.string.relay_group_field_geohash)) }, + placeholder = { Text(stringRes(R.string.relay_group_field_geohash_hint)) }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + ) + } + + if (showPicker) { + GeohashLocationPickerDialog( + initialGeohash = geohash.ifBlank { null }, + onDismiss = { showPicker = false }, + onConfirm = { cell -> + viewModel.geohash.value = TextFieldValue(cell) + viewModel.markTouched() + showPicker = false + }, + ) + } +} + +/** Empty-state call to action inviting the user to pin the group on a map. */ +@Composable +private fun AddLocationCard(onClick: () -> Unit) { + OutlinedCard( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + Column(Modifier.weight(1f)) { + Text( + text = stringRes(R.string.relay_group_location_add), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringRes(R.string.relay_group_location_add_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowForwardIos, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } +} + +/** Filled-state card: a themed map thumbnail + the resolved place name and geohash. */ +@Composable +private fun SelectedLocationCard( + geohash: String, + onEdit: () -> Unit, + onClear: () -> Unit, +) { + val decoded = remember(geohash) { GeoHash.decode(geohash) } + + Card( + onClick = onEdit, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + if (decoded != null) { + LocationPreviewMap( + latitude = decoded.centerLat, + longitude = decoded.centerLon, + zoom = 12.0, + aspectRatio = 2.4f, + modifier = Modifier.fillMaxWidth(), + ) + } + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.LocationOn, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Column(Modifier.weight(1f)) { + LoadCityName(geohashStr = geohash) { cityName -> + Text( + text = cityName, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + ) + } + Text( + text = "#$geohash", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onClear) { + Icon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.relay_group_location_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + @Composable private fun LabeledSwitchRow( label: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 7ca1594deb..1332417908 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -193,6 +193,7 @@ open class ChannelNewMessageViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // Geohash location chat (Bitchat interop): messages are signed with an anonymous per-cell @@ -306,6 +307,7 @@ open class ChannelNewMessageViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -514,7 +516,7 @@ open class ChannelNewMessageViewModel : val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val channelRelays = channel.relays() - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null @@ -738,6 +740,7 @@ open class ChannelNewMessageViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null forwardZapTo = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 93c8a6b0ae..2465929d40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -106,7 +106,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription @@ -442,7 +442,7 @@ private fun MarkdownPostScreenBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - LocationAsHash(postViewModel) { + GeoHashPostSection(postViewModel) { SettingsRow( R.string.geohash_exclusive, R.string.geohash_exclusive_explainer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 47686de456..7dd5c3c036 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -205,6 +205,7 @@ class LongFormPostViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null var wantsExclusiveGeoPost by mutableStateOf(false) @@ -328,6 +329,7 @@ class LongFormPostViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash if (geohash != null) { wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } @@ -411,7 +413,7 @@ class LongFormPostViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null val emojis = account.emoji.findEmojiTags(tagger.message) @@ -631,6 +633,7 @@ class LongFormPostViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsExclusiveGeoPost = false wantsSecretEmoji = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 9f32382d97..2e95142599 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -65,7 +65,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton @@ -250,7 +250,7 @@ private fun NewProductBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), ) { - LocationAsHash(postViewModel) + GeoHashPostSection(postViewModel) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 6e7554d7dd..fce9567e51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -185,6 +185,7 @@ open class NewProductViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -284,6 +285,7 @@ open class NewProductViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -353,7 +355,7 @@ open class NewProductViewModel : val urls = findURLs(tagger.message) val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null @@ -484,6 +486,7 @@ open class NewProductViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 342e495a1d..db7e95f3be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.foundation.verticalScroll @@ -106,7 +107,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField @@ -527,17 +528,12 @@ private fun NewPostScreenBody( } if (postViewModel.wantsToAddGeoHash) { - Row( - verticalAlignment = CenterVertically, - modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp), - ) { - LocationAsHash(postViewModel) { - SettingsRow( - R.string.geohash_exclusive, - R.string.geohash_exclusive_explainer, - ) { - Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) - } + GeoHashPostSection(postViewModel) { + SettingsRow( + R.string.geohash_exclusive, + R.string.geohash_exclusive_explainer, + ) { + Switch(postViewModel.wantsExclusiveGeoPost, onCheckedChange = { postViewModel.wantsExclusiveGeoPost = it }) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index ea22e857a2..e2d1fd6afb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -329,6 +329,8 @@ open class ShortNotePostViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) var location: StateFlow? = null + + override var pickedGeoHash by mutableStateOf(null) var wantsExclusiveGeoPost by mutableStateOf(false) // ZapRaiser @@ -753,6 +755,7 @@ open class ShortNotePostViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash if (geohash != null) { wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } @@ -858,6 +861,7 @@ open class ShortNotePostViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash if (geohash != null) { wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } @@ -931,6 +935,7 @@ open class ShortNotePostViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash if (geohash != null) { wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND } @@ -1236,7 +1241,13 @@ open class ShortNotePostViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = + if (wantsToAddGeoHash) { + // A map-picked geohash wins over the live GPS fix. + pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + } else { + null + } val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null val emojis = account.emoji.findEmojiTags(tagger.message) @@ -1563,6 +1574,7 @@ open class ShortNotePostViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsExclusiveGeoPost = false wantsSecretEmoji = false wantsAnonymousPost = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 63a55b94c4..e7e86b5f3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -178,6 +178,7 @@ open class NestNewMessageViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -272,6 +273,7 @@ open class NestNewMessageViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -427,7 +429,7 @@ open class NestNewMessageViewModel : val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) - val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null + val geoHash = if (wantsToAddGeoHash) (pickedGeoHash ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()) else null val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null @@ -482,6 +484,7 @@ open class NestNewMessageViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null forwardZapTo = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index f10bd8db4e..56e1382b6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -76,7 +76,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePick import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.NewPostInvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton -import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash +import com.vitorpamplona.amethyst.ui.note.creators.location.GeoHashPostSection import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest @@ -233,7 +233,7 @@ fun PublicMessageScreenContent( } if (postViewModel.wantsToAddGeoHash) { - LocationAsHash(postViewModel) + GeoHashPostSection(postViewModel) } if (postViewModel.wantsForwardZapTo) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index 68ffbf6006..4007134010 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -197,6 +197,7 @@ class NewPublicMessageViewModel : // GeoHash var wantsToAddGeoHash by mutableStateOf(false) + override var pickedGeoHash by mutableStateOf(null) var location: StateFlow? = null // ZapRaiser @@ -300,6 +301,7 @@ class NewPublicMessageViewModel : val geohash = draftEvent.getGeoHash() wantsToAddGeoHash = geohash != null + pickedGeoHash = geohash val zapraiser = draftEvent.zapraiserAmount() wantsZapraiser = zapraiser != null @@ -388,7 +390,7 @@ class NewPublicMessageViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val geoHash = (if (wantsToAddGeoHash) pickedGeoHash else null) ?: (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null val emojis = account.emoji.findEmojiTags(tagger.message) @@ -528,6 +530,7 @@ class NewPublicMessageViewModel : wantsToMarkAsSensitive = false contentWarningDescription = "" wantsToAddGeoHash = false + pickedGeoHash = null wantsSecretEmoji = false forwardZapTo.value = SplitBuilder() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 439e4029b8..bc61271482 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1630,6 +1630,11 @@ All User Follows Default Follow List Around Me + Teleport to a place… + Follow this location + Unfollow this location + Posting to + Change Global Curated Mine @@ -2147,6 +2152,10 @@ Expose Location as Adds a Geohash of your location to the post. The public will know you are within 5km (3mi) of the current location + Teleport + ✈ Teleport here + Pick a place on the map + Change place on the map Location-exclusive Post Only followers of the location will see it. Your general followers won\'t see it. @@ -2356,7 +2365,20 @@ Topics bitcoin, nostr, art Location (geohash) - u0nd + Tap the map icon to choose a place + Add a location + Pin your group on a map so people nearby can discover it. + Change location + Remove location + Enter a geohash manually + + Choose location + Move the map, search for a place, or use your current location. + Search for a city or address + No matching place found. + Use my current location + Area size + Use this location Structure Nest this group under a parent to build a hierarchy. Parent group diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 590cbff2c3..e75dfdb2c2 100644 Binary files a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 80bac6a653..d8e8b48b29 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -161,6 +161,7 @@ object MaterialSymbols { val MoreVert = MaterialSymbol("\uE5D4") val MoveToInbox = MaterialSymbol("\uE168") val MusicNote = MaterialSymbol("\uE405") + val MyLocation = MaterialSymbol("\uE55C") val News = MaterialSymbol("\uE032") val NoAccounts = MaterialSymbol("\uF03E") val NoEncryption = MaterialSymbol("\uF03F")