mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 01:05:20 +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:
@@ -11,6 +11,8 @@ object Geohash {
|
|||||||
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
|
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
|
||||||
private val charToValue: Map<Char, Int> = base32Chars.withIndex().associate { it.value to it.index }
|
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.
|
* Encodes the provided coordinates into a geohash string.
|
||||||
* @param latitude Latitude in degrees (-90...90)
|
* @param latitude Latitude in degrees (-90...90)
|
||||||
@@ -69,14 +71,24 @@ object Geohash {
|
|||||||
* @return Pair(latitude, longitude)
|
* @return Pair(latitude, longitude)
|
||||||
*/
|
*/
|
||||||
fun decodeToCenter(geohash: String): Pair<Double, Double> {
|
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 latInterval = -90.0 to 90.0
|
||||||
var lonInterval = -180.0 to 180.0
|
var lonInterval = -180.0 to 180.0
|
||||||
var isEven = true
|
var isEven = true
|
||||||
|
|
||||||
geohash.lowercase().forEach { ch ->
|
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)) {
|
for (mask in intArrayOf(16, 8, 4, 2, 1)) {
|
||||||
if (isEven) {
|
if (isEven) {
|
||||||
val mid = (lonInterval.first + lonInterval.second) / 2
|
val mid = (lonInterval.first + lonInterval.second) / 2
|
||||||
@@ -96,9 +108,11 @@ object Geohash {
|
|||||||
isEven = !isEven
|
isEven = !isEven
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Bounds(
|
||||||
val latCenter = (latInterval.first + latInterval.second) / 2
|
latMin = minOf(latInterval.first, latInterval.second),
|
||||||
val lonCenter = (lonInterval.first + lonInterval.second) / 2
|
latMax = maxOf(latInterval.first, latInterval.second),
|
||||||
return latCenter to lonCenter
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -519,6 +519,11 @@ private fun MainHeader(
|
|||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -565,12 +570,37 @@ private fun MainHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location channels button (matching iOS implementation)
|
// Location channels button (matching iOS implementation) and bookmark grouped tightly
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) {
|
||||||
LocationChannelsButton(
|
LocationChannelsButton(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onClick = onLocationChannelsClick
|
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
|
// Tor status cable icon when Tor is enabled
|
||||||
TorStatusIcon(modifier = Modifier.size(14.dp))
|
TorStatusIcon(modifier = Modifier.size(14.dp))
|
||||||
|
|
||||||
@@ -621,7 +651,7 @@ private fun LocationChannelsButton(
|
|||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
contentColor = badgeColor
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -719,8 +719,14 @@ class ChatViewModel(
|
|||||||
// Clear all notifications
|
// Clear all notifications
|
||||||
notificationManager.clearAllNotifications()
|
notificationManager.clearAllNotifications()
|
||||||
|
|
||||||
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
// Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch
|
||||||
try {
|
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()
|
geohashViewModel.panicReset()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ import android.net.Uri
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
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.Map
|
||||||
import androidx.compose.material.icons.filled.PinDrop
|
import androidx.compose.material.icons.filled.PinDrop
|
||||||
|
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.ui.Alignment
|
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.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import kotlinx.coroutines.launch
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import com.bitchat.android.geohash.ChannelID
|
import com.bitchat.android.geohash.ChannelID
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import com.bitchat.android.geohash.GeohashChannel
|
import com.bitchat.android.geohash.GeohashChannel
|
||||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||||
import com.bitchat.android.geohash.LocationChannelManager
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
import java.util.*
|
import com.bitchat.android.geohash.GeohashBookmarksStore
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Location Channels Sheet for selecting geohash-based location channels
|
* Location Channels Sheet for selecting geohash-based location channels
|
||||||
@@ -48,16 +50,20 @@ fun LocationChannelsSheet(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val locationManager = LocationChannelManager.getInstance(context)
|
val locationManager = LocationChannelManager.getInstance(context)
|
||||||
|
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
|
||||||
|
|
||||||
// Observe location manager state
|
// Observe location manager state
|
||||||
val permissionState by locationManager.permissionState.observeAsState()
|
val permissionState by locationManager.permissionState.observeAsState()
|
||||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||||
val selectedChannel by locationManager.selectedChannel.observeAsState()
|
val selectedChannel by locationManager.selectedChannel.observeAsState()
|
||||||
val teleported by locationManager.teleported.observeAsState(false)
|
|
||||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
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())
|
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
@@ -125,7 +131,7 @@ fun LocationChannelsSheet(
|
|||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Location Services Control - Show permission handling if enabled
|
// Permission controls if services enabled
|
||||||
if (locationServicesEnabled) {
|
if (locationServicesEnabled) {
|
||||||
when (permissionState) {
|
when (permissionState) {
|
||||||
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
|
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
|
||||||
@@ -144,7 +150,6 @@ fun LocationChannelsSheet(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LocationChannelManager.PermissionState.DENIED,
|
LocationChannelManager.PermissionState.DENIED,
|
||||||
LocationChannelManager.PermissionState.RESTRICTED -> {
|
LocationChannelManager.PermissionState.RESTRICTED -> {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -154,7 +159,6 @@ fun LocationChannelsSheet(
|
|||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
color = Color.Red.copy(alpha = 0.8f)
|
color = Color.Red.copy(alpha = 0.8f)
|
||||||
)
|
)
|
||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
@@ -171,7 +175,6 @@ fun LocationChannelsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LocationChannelManager.PermissionState.AUTHORIZED -> {
|
LocationChannelManager.PermissionState.AUTHORIZED -> {
|
||||||
Text(
|
Text(
|
||||||
text = "✓ location permission granted",
|
text = "✓ location permission granted",
|
||||||
@@ -180,7 +183,6 @@ fun LocationChannelsSheet(
|
|||||||
color = standardGreen
|
color = standardGreen
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -199,6 +201,7 @@ fun LocationChannelsSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Channel list (iOS-style plain list)
|
// Channel list (iOS-style plain list)
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
@@ -211,6 +214,7 @@ fun LocationChannelsSheet(
|
|||||||
isSelected = selectedChannel is ChannelID.Mesh,
|
isSelected = selectedChannel is ChannelID.Mesh,
|
||||||
titleColor = standardBlue,
|
titleColor = standardBlue,
|
||||||
titleBold = meshCount(viewModel) > 0,
|
titleBold = meshCount(viewModel) > 0,
|
||||||
|
trailingContent = null,
|
||||||
onClick = {
|
onClick = {
|
||||||
locationManager.select(ChannelID.Mesh)
|
locationManager.select(ChannelID.Mesh)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
@@ -225,9 +229,9 @@ fun LocationChannelsSheet(
|
|||||||
val nameBase = locationNames[channel.level]
|
val nameBase = locationNames[channel.level]
|
||||||
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
||||||
val subtitlePrefix = "#${channel.geohash} • $coverage"
|
val subtitlePrefix = "#${channel.geohash} • $coverage"
|
||||||
// CRITICAL FIX: Use reactive participant count from LiveData
|
|
||||||
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
|
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
|
||||||
val highlight = participantCount > 0
|
val highlight = participantCount > 0
|
||||||
|
val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
|
||||||
|
|
||||||
ChannelRow(
|
ChannelRow(
|
||||||
title = geohashTitleWithCount(channel, participantCount),
|
title = geohashTitleWithCount(channel, participantCount),
|
||||||
@@ -235,6 +239,15 @@ fun LocationChannelsSheet(
|
|||||||
isSelected = isChannelSelected(channel, selectedChannel),
|
isSelected = isChannelSelected(channel, selectedChannel),
|
||||||
titleColor = standardGreen,
|
titleColor = standardGreen,
|
||||||
titleBold = highlight,
|
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 = {
|
onClick = {
|
||||||
// Selecting a suggested nearby channel is not a teleport
|
// Selecting a suggested nearby channel is not a teleport
|
||||||
locationManager.setTeleported(false)
|
locationManager.setTeleported(false)
|
||||||
@@ -259,6 +272,58 @@ 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)
|
// Custom geohash teleport (iOS-style inline form)
|
||||||
item {
|
item {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -441,20 +506,16 @@ fun LocationChannelsSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle management
|
// Lifecycle management: when presented, sample both nearby and bookmarked geohashes
|
||||||
LaunchedEffect(isPresented) {
|
LaunchedEffect(isPresented, availableChannels, bookmarks) {
|
||||||
if (isPresented) {
|
if (isPresented) {
|
||||||
// Refresh channels when opening (only if location services are enabled)
|
|
||||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||||
locationManager.refreshChannels()
|
locationManager.refreshChannels()
|
||||||
}
|
}
|
||||||
// Begin periodic refresh while sheet is open (only if location services are enabled)
|
|
||||||
if (locationServicesEnabled) {
|
if (locationServicesEnabled) {
|
||||||
locationManager.beginLiveRefresh()
|
locationManager.beginLiveRefresh()
|
||||||
}
|
}
|
||||||
|
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
|
||||||
// Begin multi-channel sampling for counts
|
|
||||||
val geohashes = availableChannels.map { it.geohash }
|
|
||||||
viewModel.beginGeohashSampling(geohashes)
|
viewModel.beginGeohashSampling(geohashes)
|
||||||
} else {
|
} else {
|
||||||
locationManager.endLiveRefresh()
|
locationManager.endLiveRefresh()
|
||||||
@@ -475,12 +536,6 @@ fun LocationChannelsSheet(
|
|||||||
locationManager.refreshChannels()
|
locationManager.refreshChannels()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// React to available channels changes
|
|
||||||
LaunchedEffect(availableChannels) {
|
|
||||||
val geohashes = availableChannels.map { it.geohash }
|
|
||||||
viewModel.beginGeohashSampling(geohashes)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -490,6 +545,7 @@ private fun ChannelRow(
|
|||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
titleColor: Color? = null,
|
titleColor: Color? = null,
|
||||||
titleBold: Boolean = false,
|
titleBold: Boolean = false,
|
||||||
|
trailingContent: (@Composable (() -> Unit))? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
// iOS-style list row (plain button, no card background)
|
// iOS-style list row (plain button, no card background)
|
||||||
@@ -544,14 +600,20 @@ private fun ChannelRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Text(
|
Icon(
|
||||||
text = "✔︎",
|
imageVector = Icons.Filled.Check,
|
||||||
fontSize = 16.sp,
|
contentDescription = "Selected",
|
||||||
fontFamily = FontFamily.Monospace,
|
tint = Color(0xFF32D74B), // iOS green for checkmark
|
||||||
color = 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]"
|
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 {
|
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
|
||||||
return when (selectedChannel) {
|
return when (selectedChannel) {
|
||||||
is ChannelID.Location -> selectedChannel.channel == channel
|
is ChannelID.Location -> selectedChannel.channel == channel
|
||||||
@@ -645,9 +712,5 @@ private fun bluetoothRangeString(): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun formattedNamePrefix(level: GeohashChannelLevel): String {
|
private fun formattedNamePrefix(level: GeohashChannelLevel): String {
|
||||||
// return when (level) {
|
|
||||||
// GeohashChannelLevel.REGION -> ""
|
|
||||||
// else -> "~"
|
|
||||||
// }
|
|
||||||
return "~"
|
return "~"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user