mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 06:45:19 +00:00
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:
@@ -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 "~"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user