diff --git a/app/src/main/java/com/bitchat/android/geohash/Geohash.kt b/app/src/main/java/com/bitchat/android/geohash/Geohash.kt index 0194fe70..6907a42d 100644 --- a/app/src/main/java/com/bitchat/android/geohash/Geohash.kt +++ b/app/src/main/java/com/bitchat/android/geohash/Geohash.kt @@ -11,6 +11,8 @@ object Geohash { private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray() private val charToValue: Map = 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 { - 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) + ) } } diff --git a/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt new file mode 100644 index 00000000..bce90c7c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/GeohashBookmarksStore.kt @@ -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() + + private val _bookmarks = MutableLiveData>(emptyList()) + val bookmarks: LiveData> = _bookmarks + + private val _bookmarkNames = MutableLiveData>(emptyMap()) + val bookmarkNames: LiveData> = _bookmarkNames + + // For throttling / preventing duplicate geocode lookups + private val resolving = mutableSetOf() + + 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>() {}.type + val arr = gson.fromJson>(arrJson, listType) + val seen = mutableSetOf() + val ordered = mutableListOf() + 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>() {}.type + val dict = gson.fromJson>(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()) + prefs.edit().putString(STORE_KEY, json).apply() + } catch (_: Exception) {} + } + + private fun persistNames() { + try { + val json = gson.toJson(_bookmarkNames.value ?: emptyMap()) + 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() + 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 + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index c33c3c14..3b695167 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -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( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index de85c597..7d8e6f92 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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}") diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index e21dd8f5..a9843d4b 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -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(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 "~" }