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