feat: geohash bookmarks (iOS parity) (#410)

* Geohash bookmarks (iOS parity):
- Add GeohashBookmarksStore (persist bookmarks + friendly names)
- Integrate bookmark toggle into LocationChannelsSheet (nearby + bookmarked sections)
- Add header toggle for current geohash bookmark
- Sample counts for union of nearby + bookmarks while sheet open
- Add Geohash.decodeToBounds() to support friendly name resolution
- Fix coroutine usage and Gson type inference issues

* fix UI

* clear bookmarks on triple tap

* adjust icons

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
lollerfirst
2025-09-14 03:46:37 +02:00
committed by GitHub
co-authored by callebtc
parent eba0d36015
commit 779717217f
5 changed files with 429 additions and 83 deletions
@@ -11,6 +11,8 @@ object Geohash {
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index }
data class Bounds(val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double)
/**
* Encodes the provided coordinates into a geohash string.
* @param latitude Latitude in degrees (-90...90)
@@ -69,14 +71,24 @@ object Geohash {
* @return Pair(latitude, longitude)
*/
fun decodeToCenter(geohash: String): Pair<Double, Double> {
if (geohash.isEmpty()) return 0.0 to 0.0
val b = decodeToBounds(geohash)
val latCenter = (b.latMin + b.latMax) / 2
val lonCenter = (b.lonMin + b.lonMax) / 2
return latCenter to lonCenter
}
/**
* Decodes a geohash string to bounding box (lat/lon min/max).
*/
fun decodeToBounds(geohash: String): Bounds {
if (geohash.isEmpty()) return Bounds(0.0, 0.0, 0.0, 0.0)
var latInterval = -90.0 to 90.0
var lonInterval = -180.0 to 180.0
var isEven = true
geohash.lowercase().forEach { ch ->
val cd = charToValue[ch] ?: return 0.0 to 0.0
val cd = charToValue[ch] ?: return Bounds(0.0, 0.0, 0.0, 0.0)
for (mask in intArrayOf(16, 8, 4, 2, 1)) {
if (isEven) {
val mid = (lonInterval.first + lonInterval.second) / 2
@@ -96,9 +108,11 @@ object Geohash {
isEven = !isEven
}
}
val latCenter = (latInterval.first + latInterval.second) / 2
val lonCenter = (lonInterval.first + lonInterval.second) / 2
return latCenter to lonCenter
return Bounds(
latMin = minOf(latInterval.first, latInterval.second),
latMax = maxOf(latInterval.first, latInterval.second),
lonMin = minOf(lonInterval.first, lonInterval.second),
lonMax = maxOf(lonInterval.first, lonInterval.second)
)
}
}
@@ -0,0 +1,233 @@
package com.bitchat.android.geohash
import android.content.Context
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Locale
/**
* Stores a user-maintained list of bookmarked geohash channels.
* - Persistence: SharedPreferences (JSON string array)
* - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
*/
class GeohashBookmarksStore private constructor(private val context: Context) {
companion object {
private const val TAG = "GeohashBookmarksStore"
private const val STORE_KEY = "locationChannel.bookmarks"
private const val NAMES_STORE_KEY = "locationChannel.bookmarkNames"
@Volatile private var INSTANCE: GeohashBookmarksStore? = null
fun getInstance(context: Context): GeohashBookmarksStore {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: GeohashBookmarksStore(context.applicationContext).also { INSTANCE = it }
}
}
private val allowedChars = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
fun normalize(raw: String): String {
return raw.trim().lowercase(Locale.US)
.replace("#", "")
.filter { allowedChars.contains(it) }
}
}
private val gson = Gson()
private val prefs = context.getSharedPreferences("geohash_prefs", Context.MODE_PRIVATE)
private val membership = mutableSetOf<String>()
private val _bookmarks = MutableLiveData<List<String>>(emptyList())
val bookmarks: LiveData<List<String>> = _bookmarks
private val _bookmarkNames = MutableLiveData<Map<String, String>>(emptyMap())
val bookmarkNames: LiveData<Map<String, String>> = _bookmarkNames
// For throttling / preventing duplicate geocode lookups
private val resolving = mutableSetOf<String>()
init { load() }
fun isBookmarked(geohash: String): Boolean = membership.contains(normalize(geohash))
fun toggle(geohash: String) {
val gh = normalize(geohash)
if (membership.contains(gh)) remove(gh) else add(gh)
}
fun add(geohash: String) {
val gh = normalize(geohash)
if (gh.isEmpty() || membership.contains(gh)) return
membership.add(gh)
_bookmarks.postValue(listOf(gh) + (_bookmarks.value ?: emptyList()))
persist()
// Resolve friendly name asynchronously
resolveNameIfNeeded(gh)
}
fun remove(geohash: String) {
val gh = normalize(geohash)
if (!membership.contains(gh)) return
membership.remove(gh)
_bookmarks.postValue((_bookmarks.value ?: emptyList()).filterNot { it == gh })
// Remove stored name to avoid stale cache growth
val names = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
if (names.remove(gh) != null) {
_bookmarkNames.postValue(names)
persistNames()
}
persist()
}
// MARK: - Persistence
private fun load() {
try {
val arrJson = prefs.getString(STORE_KEY, null)
if (!arrJson.isNullOrEmpty()) {
val listType = object : TypeToken<List<String>>() {}.type
val arr = gson.fromJson<List<String>>(arrJson, listType)
val seen = mutableSetOf<String>()
val ordered = mutableListOf<String>()
arr.forEach { raw ->
val gh = normalize(raw)
if (gh.isNotEmpty() && !seen.contains(gh)) {
seen.add(gh)
ordered.add(gh)
}
}
membership.clear(); membership.addAll(seen)
_bookmarks.postValue(ordered)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmarks: ${e.message}")
}
try {
val namesJson = prefs.getString(NAMES_STORE_KEY, null)
if (!namesJson.isNullOrEmpty()) {
val mapType = object : TypeToken<Map<String, String>>() {}.type
val dict = gson.fromJson<Map<String, String>>(namesJson, mapType)
_bookmarkNames.postValue(dict)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load bookmark names: ${e.message}")
}
}
private fun persist() {
try {
val json = gson.toJson(_bookmarks.value ?: emptyList<String>())
prefs.edit().putString(STORE_KEY, json).apply()
} catch (_: Exception) {}
}
private fun persistNames() {
try {
val json = gson.toJson(_bookmarkNames.value ?: emptyMap<String, String>())
prefs.edit().putString(NAMES_STORE_KEY, json).apply()
} catch (_: Exception) {}
}
// MARK: - Destructive Reset
fun clearAll() {
try {
membership.clear()
_bookmarks.postValue(emptyList())
_bookmarkNames.postValue(emptyMap())
prefs.edit()
.remove(STORE_KEY)
.remove(NAMES_STORE_KEY)
.apply()
// Clear any in-flight resolutions to avoid repopulating
resolving.clear()
Log.i(TAG, "Cleared all geohash bookmarks and names")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear geohash bookmarks: ${e.message}")
}
}
// MARK: - Friendly Name Resolution
fun resolveNameIfNeeded(geohash: String) {
val gh = normalize(geohash)
if (gh.isEmpty()) return
if (_bookmarkNames.value?.containsKey(gh) == true) return
if (resolving.contains(gh)) return
if (!Geocoder.isPresent()) return
resolving.add(gh)
CoroutineScope(Dispatchers.IO).launch {
try {
val geocoder = Geocoder(context, Locale.getDefault())
val name: String? = if (gh.length <= 2) {
// Composite admin name from multiple points
val b = Geohash.decodeToBounds(gh)
val points = listOf(
Location(LocationManager.GPS_PROVIDER).apply { latitude = (b.latMin + b.latMax) / 2; longitude = (b.lonMin + b.lonMax) / 2 },
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMin },
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMin; longitude = b.lonMax },
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMin },
Location(LocationManager.GPS_PROVIDER).apply { latitude = b.latMax; longitude = b.lonMax }
)
val admins = linkedSetOf<String>()
for (loc in points) {
try {
@Suppress("DEPRECATION")
val list = geocoder.getFromLocation(loc.latitude, loc.longitude, 1)
val a = list?.firstOrNull()
val admin = a?.adminArea?.takeIf { !it.isNullOrEmpty() }
val country = a?.countryName?.takeIf { !it.isNullOrEmpty() }
if (admin != null) admins.add(admin)
else if (country != null) admins.add(country)
} catch (_: Exception) {}
if (admins.size >= 2) break
}
when (admins.size) {
0 -> null
1 -> admins.first()
else -> admins.elementAt(0) + " and " + admins.elementAt(1)
}
} else {
val center = Geohash.decodeToCenter(gh)
@Suppress("DEPRECATION")
val list = geocoder.getFromLocation(center.first, center.second, 1)
val a = list?.firstOrNull()
pickNameForLength(gh.length, a)
}
if (!name.isNullOrEmpty()) {
val current = _bookmarkNames.value?.toMutableMap() ?: mutableMapOf()
current[gh] = name
_bookmarkNames.postValue(current)
persistNames()
}
} catch (e: Exception) {
Log.w(TAG, "Name resolution failed for #$gh: ${e.message}")
} finally {
resolving.remove(gh)
}
}
}
private fun pickNameForLength(len: Int, address: android.location.Address?): String? {
if (address == null) return null
return when (len) {
in 0..2 -> address.adminArea ?: address.countryName
in 3..4 -> address.adminArea ?: address.subAdminArea ?: address.countryName
5 -> address.locality ?: address.subAdminArea ?: address.adminArea
in 6..7 -> address.subLocality ?: address.locality ?: address.adminArea
else -> address.subLocality ?: address.locality ?: address.adminArea ?: address.countryName
}
}
}
@@ -518,7 +518,12 @@ private fun MainHeader(
val isConnected by viewModel.isConnected.observeAsState(false)
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
// Bookmarks store for current geohash toggle (iOS parity)
val context = androidx.compose.ui.platform.LocalContext.current
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -565,11 +570,36 @@ private fun MainHeader(
)
}
// Location channels button (matching iOS implementation)
LocationChannelsButton(
viewModel = viewModel,
onClick = onLocationChannelsClick
)
// Location channels button (matching iOS implementation) and bookmark grouped tightly
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) {
LocationChannelsButton(
viewModel = viewModel,
onClick = onLocationChannelsClick
)
// Bookmark toggle for current geohash (not shown for mesh)
val currentGeohash: String? = when (val sc = selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
else -> null
}
if (currentGeohash != null) {
val isBookmarked = bookmarks.contains(currentGeohash)
Box(
modifier = Modifier
.padding(start = 1.dp) // minimal gap between geohash and bookmark
.size(20.dp)
.clickable { bookmarksStore.toggle(currentGeohash) },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = "Toggle bookmark",
tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
modifier = Modifier.size(16.dp)
)
}
}
}
// Tor status cable icon when Tor is enabled
TorStatusIcon(modifier = Modifier.size(14.dp))
@@ -621,7 +651,7 @@ private fun LocationChannelsButton(
containerColor = Color.Transparent,
contentColor = badgeColor
),
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp)
contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -719,8 +719,14 @@ class ChatViewModel(
// Clear all notifications
notificationManager.clearAllNotifications()
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
// Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch
try {
// Clear geohash bookmarks too (panic should remove everything)
try {
val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication())
store.clearAll()
} catch (_: Exception) { }
geohashViewModel.panicReset()
} catch (e: Exception) {
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
@@ -5,15 +5,16 @@ import android.net.Uri
import android.provider.Settings
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
@@ -22,17 +23,18 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import kotlinx.coroutines.launch
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.bitchat.android.geohash.ChannelID
import kotlinx.coroutines.launch
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import java.util.*
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -48,32 +50,36 @@ fun LocationChannelsSheet(
) {
val context = LocalContext.current
val locationManager = LocationChannelManager.getInstance(context)
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
// Observe location manager state
val permissionState by locationManager.permissionState.observeAsState()
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
val selectedChannel by locationManager.selectedChannel.observeAsState()
val teleported by locationManager.teleported.observeAsState(false)
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
// CRITICAL FIX: Observe reactive participant counts for real-time updates
// Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
val bookmarkNames by bookmarksStore.bookmarkNames.observeAsState(emptyMap())
// Observe reactive participant counts
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
// UI state
var customGeohash by remember { mutableStateOf("") }
var customError by remember { mutableStateOf<String?>(null) }
var isInputFocused by remember { mutableStateOf(false) }
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = isInputFocused
)
val coroutineScope = rememberCoroutineScope()
// Scroll state for LazyColumn
val listState = rememberLazyListState()
val mapPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
@@ -85,13 +91,13 @@ fun LocationChannelsSheet(
}
}
}
// iOS system colors (matches iOS exactly)
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
val standardBlue = Color(0xFF007AFF) // iOS blue
if (isPresented) {
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -117,15 +123,15 @@ fun LocationChannelsSheet(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy.",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
// Location Services Control - Show permission handling if enabled
// Permission controls if services enabled
if (locationServicesEnabled) {
when (permissionState) {
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
@@ -144,7 +150,6 @@ fun LocationChannelsSheet(
)
}
}
LocationChannelManager.PermissionState.DENIED,
LocationChannelManager.PermissionState.RESTRICTED -> {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -154,7 +159,6 @@ fun LocationChannelsSheet(
fontFamily = FontFamily.Monospace,
color = Color.Red.copy(alpha = 0.8f)
)
TextButton(
onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
@@ -171,7 +175,6 @@ fun LocationChannelsSheet(
}
}
}
LocationChannelManager.PermissionState.AUTHORIZED -> {
Text(
text = "✓ location permission granted",
@@ -180,7 +183,6 @@ fun LocationChannelsSheet(
color = standardGreen
)
}
null -> {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -197,8 +199,9 @@ fun LocationChannelsSheet(
}
}
}
// Channel list (iOS-style plain list)
LazyColumn(
state = listState,
modifier = Modifier.weight(1f)
@@ -211,13 +214,14 @@ fun LocationChannelsSheet(
isSelected = selectedChannel is ChannelID.Mesh,
titleColor = standardBlue,
titleBold = meshCount(viewModel) > 0,
trailingContent = null,
onClick = {
locationManager.select(ChannelID.Mesh)
onDismiss()
}
)
}
// Nearby options (only show if location services are enabled)
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
items(availableChannels) { channel ->
@@ -225,16 +229,25 @@ fun LocationChannelsSheet(
val nameBase = locationNames[channel.level]
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
val subtitlePrefix = "#${channel.geohash}$coverage"
// CRITICAL FIX: Use reactive participant count from LiveData
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
val highlight = participantCount > 0
val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
ChannelRow(
title = geohashTitleWithCount(channel, participantCount),
subtitle = subtitlePrefix + (namePart?.let { "$it" } ?: ""),
isSelected = isChannelSelected(channel, selectedChannel),
titleColor = standardGreen,
titleBold = highlight,
trailingContent = {
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = if (isBookmarked) "Unbookmark" else "Bookmark",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
},
onClick = {
// Selecting a suggested nearby channel is not a teleport
locationManager.setTeleported(false)
@@ -258,7 +271,59 @@ fun LocationChannelsSheet(
}
}
}
// Bookmarked geohashes
if (bookmarks.isNotEmpty()) {
item {
Text(
text = "bookmarked",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)
)
}
items(bookmarks) { gh ->
val level = levelForLength(gh.length)
val channel = GeohashChannel(level = level, geohash = gh)
val coverage = coverageString(gh.length)
val subtitlePrefix = "#${gh}$coverage"
val name = bookmarkNames[gh]
val subtitle = subtitlePrefix + (name?.let { "${formattedNamePrefix(level)}$it" } ?: "")
val participantCount = geohashParticipantCounts[gh] ?: 0
val title = geohashHashTitleWithCount(gh, participantCount)
ChannelRow(
title = title,
subtitle = subtitle,
isSelected = isChannelSelected(channel, selectedChannel),
titleColor = null,
titleBold = participantCount > 0,
trailingContent = {
IconButton(onClick = { bookmarksStore.toggle(gh) }) {
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = "Remove bookmark",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
},
onClick = {
// For bookmarked selection, mark teleported based on regional membership
val inRegional = availableChannels.any { it.geohash == gh }
if (!inRegional && availableChannels.isNotEmpty()) {
locationManager.setTeleported(true)
} else {
locationManager.setTeleported(false)
}
locationManager.select(ChannelID.Location(channel))
onDismiss()
}
)
LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) }
}
}
// Custom geohash teleport (iOS-style inline form)
item {
Surface(
@@ -278,7 +343,7 @@ fun LocationChannelsSheet(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
BasicTextField(
value = customGeohash,
onValueChange = { newValue ->
@@ -289,7 +354,7 @@ fun LocationChannelsSheet(
.replace("#", "")
.filter { it in allowed }
.take(12)
customGeohash = filtered
customError = null
},
@@ -325,9 +390,9 @@ fun LocationChannelsSheet(
innerTextField()
}
)
val normalized = customGeohash.trim().lowercase().replace("#", "")
// Map picker button
IconButton(onClick = {
val initial = when {
@@ -346,9 +411,9 @@ fun LocationChannelsSheet(
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
val isValid = validateGeohash(normalized)
// iOS-style teleport button
Button(
onClick = {
@@ -388,7 +453,7 @@ fun LocationChannelsSheet(
}
}
}
customError?.let { error ->
Text(
text = error,
@@ -400,7 +465,7 @@ fun LocationChannelsSheet(
}
}
}
// Location services toggle button
item {
Button(
@@ -440,47 +505,37 @@ fun LocationChannelsSheet(
}
}
}
// Lifecycle management
LaunchedEffect(isPresented) {
// Lifecycle management: when presented, sample both nearby and bookmarked geohashes
LaunchedEffect(isPresented, availableChannels, bookmarks) {
if (isPresented) {
// Refresh channels when opening (only if location services are enabled)
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
}
// Begin periodic refresh while sheet is open (only if location services are enabled)
if (locationServicesEnabled) {
locationManager.beginLiveRefresh()
}
// Begin multi-channel sampling for counts
val geohashes = availableChannels.map { it.geohash }
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
viewModel.beginGeohashSampling(geohashes)
} else {
locationManager.endLiveRefresh()
viewModel.endGeohashSampling()
}
}
// React to permission changes
LaunchedEffect(permissionState) {
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
}
}
// React to location services enable/disable
LaunchedEffect(locationServicesEnabled) {
if (locationServicesEnabled && permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
locationManager.refreshChannels()
}
}
// React to available channels changes
LaunchedEffect(availableChannels) {
val geohashes = availableChannels.map { it.geohash }
viewModel.beginGeohashSampling(geohashes)
}
}
@Composable
@@ -490,6 +545,7 @@ private fun ChannelRow(
isSelected: Boolean,
titleColor: Color? = null,
titleBold: Boolean = false,
trailingContent: (@Composable (() -> Unit))? = null,
onClick: () -> Unit
) {
// iOS-style list row (plain button, no card background)
@@ -516,7 +572,7 @@ private fun ChannelRow(
) {
// Split title to handle count part with smaller font (iOS style)
val (baseTitle, countSuffix) = splitTitleAndCount(title)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = baseTitle,
@@ -525,7 +581,7 @@ private fun ChannelRow(
fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal,
color = titleColor ?: MaterialTheme.colorScheme.onSurface
)
countSuffix?.let { count ->
Text(
text = count,
@@ -535,7 +591,7 @@ private fun ChannelRow(
)
}
}
Text(
text = subtitle,
fontSize = 12.sp,
@@ -543,14 +599,20 @@ private fun ChannelRow(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
if (isSelected) {
Text(
text = "✔︎",
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
color = Color(0xFF32D74B) // iOS green for checkmark
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (isSelected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
tint = Color(0xFF32D74B), // iOS green for checkmark
modifier = Modifier.size(20.dp)
)
}
if (trailingContent != null) {
trailingContent()
}
}
}
}
@@ -587,6 +649,11 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int
return "${channel.level.displayName.lowercase()} [$participantCount $noun]"
}
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
val noun = if (participantCount == 1) "person" else "people"
return "#$geohash [$participantCount $noun]"
}
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
return when (selectedChannel) {
is ChannelID.Location -> selectedChannel.channel == channel
@@ -625,7 +692,7 @@ private fun coverageString(precision: Int): String {
10 -> 1.19
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
}
// Use metric system for simplicity (could be made locale-aware)
val km = maxMeters / 1000.0
return "~${formatDistance(km)} km"
@@ -645,9 +712,5 @@ private fun bluetoothRangeString(): String {
}
private fun formattedNamePrefix(level: GeohashChannelLevel): String {
// return when (level) {
// GeohashChannelLevel.REGION -> ""
// else -> "~"
// }
return "~"
}