mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 18:45:20 +00:00
feat(geohash): add in-app Geohash Picker (#363)
* feat(geohash): add in-app Geohash Picker map with quadrant drill-down and Activity integration - New GeohashPickerActivity hosting a WebView with Leaflet-based picker - Adds map icon next to custom geohash input in LocationChannelsSheet - Picker allows quadrant selection and subquadrant drill-down; returns selected geohash - Register activity in AndroidManifest perf(picker): improve map performance and correctness - Use LayerGroup and clearLayers() on redraw to avoid stale overlays - Use Leaflet canvas renderer for rectangles to reduce DOM and improve perf - Make labels non-interactive and pointer-events: none to avoid gesture overhead - Fix initial mega-label artifact by grouping and clearing labels with cells chore: add asset geohash_picker.html with minimal geohash helpers (bounds/encode/adjacent) * remove accidentally committed submodule. * fix some issues * better geohash labels on map * design wip * countries green * wip * geohashpicker: pan + zoom; start from current geohash channel * better ui * ui elements * nice * readability --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class GeohashPickerActivity : ComponentActivity() {
|
||||
|
||||
companion object {
|
||||
const val EXTRA_INITIAL_GEOHASH = "initial_geohash"
|
||||
const val EXTRA_RESULT_GEOHASH = "result_geohash"
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
|
||||
var geohashToFocus: String? = null
|
||||
var (initLat, initLon) = 0.0 to 0.0
|
||||
|
||||
if (!initialGeohash.isNullOrEmpty()) {
|
||||
geohashToFocus = initialGeohash
|
||||
try {
|
||||
val (lat, lon) = Geohash.decodeToCenter(initialGeohash)
|
||||
initLat = lat
|
||||
initLon = lon
|
||||
} catch (_: Throwable) {}
|
||||
} else {
|
||||
// If no initial geohash, try to use the user's coarsest location
|
||||
val locationManager = LocationChannelManager.getInstance(applicationContext)
|
||||
val channels = locationManager.availableChannels.value
|
||||
if (!channels.isNullOrEmpty()) {
|
||||
val coarsestChannel = channels.minByOrNull { it.geohash.length }
|
||||
if (coarsestChannel != null) {
|
||||
geohashToFocus = coarsestChannel.geohash
|
||||
try {
|
||||
val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash)
|
||||
initLat = lat
|
||||
initLon = lon
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val initialPrecision = geohashToFocus?.length ?: 5
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
|
||||
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// iOS system-like colors used across app
|
||||
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)
|
||||
|
||||
Scaffold { padding ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.cacheMode = WebSettings.LOAD_DEFAULT
|
||||
settings.allowFileAccess = true
|
||||
settings.allowContentAccess = true
|
||||
webChromeClient = WebChromeClient()
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
// Initialize to last/initial geohash if provided, otherwise center
|
||||
if (!geohashToFocus.isNullOrEmpty()) {
|
||||
evaluateJavascript(
|
||||
"window.focusGeohash('${geohashToFocus}')",
|
||||
null
|
||||
)
|
||||
} else {
|
||||
evaluateJavascript(
|
||||
"window.setCenter(${initLat}, ${initLon})",
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Apply theme to map tiles
|
||||
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
|
||||
evaluateJavascript("window.setMapTheme('" + theme + "')", null)
|
||||
}
|
||||
}
|
||||
addJavascriptInterface(object {
|
||||
@JavascriptInterface
|
||||
fun onGeohashChanged(geohash: String) {
|
||||
runOnUiThread {
|
||||
currentGeohash = geohash
|
||||
}
|
||||
}
|
||||
}, "Android")
|
||||
|
||||
loadUrl("file:///android_asset/geohash_picker.html")
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
update = { webView ->
|
||||
webViewRef = webView
|
||||
// ensure it fills parent
|
||||
webView.updateLayoutParams<ViewGroup.LayoutParams> {
|
||||
width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||
}
|
||||
},
|
||||
onRelease = { webView ->
|
||||
// Best-effort cleanup to avoid leaks and timers
|
||||
try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {}
|
||||
try { webView.stopLoading() } catch (_: Throwable) {}
|
||||
try { webView.clearHistory() } catch (_: Throwable) {}
|
||||
try { webView.clearCache(true) } catch (_: Throwable) {}
|
||||
try { webView.loadUrl("about:blank") } catch (_: Throwable) {}
|
||||
try { webView.removeAllViews() } catch (_: Throwable) {}
|
||||
try { webView.destroy() } catch (_: Throwable) {}
|
||||
}
|
||||
)
|
||||
|
||||
// Floating info pill
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 20.dp)
|
||||
.fillMaxWidth(0.75f),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
Text(
|
||||
text = "pan and zoom to select a geohash",
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Floating bottom controls
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Geohash label (monospace, app style)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
Text(
|
||||
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Button row
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Decrease precision
|
||||
Button(
|
||||
onClick = {
|
||||
precision = (precision - 1).coerceAtLeast(1)
|
||||
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Remove, contentDescription = "Decrease precision")
|
||||
}
|
||||
}
|
||||
|
||||
// Increase precision
|
||||
Button(
|
||||
onClick = {
|
||||
precision = (precision + 1).coerceAtMost(12)
|
||||
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Increase precision")
|
||||
}
|
||||
}
|
||||
|
||||
// Select button
|
||||
Button(
|
||||
onClick = {
|
||||
webViewRef?.evaluateJavascript("window.getGeohash()") { value ->
|
||||
val gh = value?.trim('"') ?: currentGeohash
|
||||
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
|
||||
setResult(Activity.RESULT_OK, result)
|
||||
finish()
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Select geohash")
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "select",
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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.Map
|
||||
import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -30,6 +31,8 @@ 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
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
@@ -71,6 +74,18 @@ fun LocationChannelsSheet(
|
||||
// Scroll state for LazyColumn
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val mapPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
|
||||
if (!gh.isNullOrBlank()) {
|
||||
customGeohash = gh
|
||||
customError = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// iOS system colors (matches iOS exactly)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
@@ -312,6 +327,26 @@ fun LocationChannelsSheet(
|
||||
)
|
||||
|
||||
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
||||
|
||||
// Map picker button
|
||||
IconButton(onClick = {
|
||||
val initial = when {
|
||||
normalized.isNotBlank() -> normalized
|
||||
selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
|
||||
else -> ""
|
||||
}
|
||||
val intent = Intent(context, GeohashPickerActivity::class.java).apply {
|
||||
putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
|
||||
}
|
||||
mapPickerLauncher.launch(intent)
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Map,
|
||||
contentDescription = "Open map",
|
||||
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
|
||||
val isValid = validateGeohash(normalized)
|
||||
|
||||
// iOS-style teleport button
|
||||
|
||||
Reference in New Issue
Block a user