mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +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:
@@ -41,6 +41,12 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BitchatAndroid"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".ui.GeohashPickerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.BitchatAndroid"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
:root { --text: #333; }
|
||||
html, body, #map { height: 100%; margin: 0; padding: 0; background: #ffffff; }
|
||||
.leaflet-container { background: #ffffff; }
|
||||
.leaflet-div-icon { background: transparent; border: none; }
|
||||
.gh-label { background: transparent; border: none; pointer-events: none; filter: none; }
|
||||
.gh-text {
|
||||
color: #444444;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff, 0 0 2px #ffffff;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
.dark .gh-text {
|
||||
color: #dddddd;
|
||||
text-shadow: 0 0 2px #000000, 0 0 2px #000000, 0 0 2px #000000;
|
||||
}
|
||||
.gh-text-selected {
|
||||
color: #00C851 !important;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script>
|
||||
// Minimal geohash (bounds/encode/adjacent)
|
||||
(function () {
|
||||
const base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
||||
function bounds(geohash) {
|
||||
let evenBit = true; let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
|
||||
geohash = geohash.toLowerCase();
|
||||
for (let i = 0; i < geohash.length; i++) {
|
||||
const idx = base32.indexOf(geohash.charAt(i));
|
||||
if (idx == -1) throw new Error("Invalid geohash");
|
||||
for (let n = 4; n >= 0; n--) {
|
||||
const bitN = (idx >> n) & 1;
|
||||
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (bitN == 1) lonMin = lonMid; else lonMax = lonMid; }
|
||||
else { const latMid = (latMin + latMax) / 2; if (bitN == 1) latMin = latMid; else latMax = latMid; }
|
||||
evenBit = !evenBit;
|
||||
}
|
||||
}
|
||||
return { sw: { lat: latMin, lng: lonMin }, ne: { lat: latMax, lng: lonMax } };
|
||||
}
|
||||
function encode(lat, lon, precision) {
|
||||
let idx = 0, bit = 0, evenBit = true, hash = "";
|
||||
let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
|
||||
while (hash.length < precision) {
|
||||
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (lon >= lonMid) { idx = idx * 2 + 1; lonMin = lonMid; } else { idx = idx * 2; lonMax = lonMid; } }
|
||||
else { const latMid = (latMin + latMax) / 2; if (lat >= latMid) { idx = idx * 2 + 1; latMin = latMid; } else { idx = idx * 2; latMax = latMid; } }
|
||||
evenBit = !evenBit; if (++bit == 5) { hash += base32.charAt(idx); bit = 0; idx = 0; }
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
function adjacent(hash, dir) {
|
||||
const neighbour = { n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"], s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"], e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"], w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"] };
|
||||
const border = { n:["prxz","bcfguvyz"], s:["028b","0145hjnp"], e:["bcfguvyz","prxz"], w:["0145hjnp","028b"] };
|
||||
hash = hash.toLowerCase(); const lastCh = hash.slice(-1); let parent = hash.slice(0, -1); const type = hash.length % 2;
|
||||
if (border[dir][type].indexOf(lastCh) != -1 && parent != "") parent = adjacent(parent, dir);
|
||||
return parent + base32.charAt(neighbour[dir][type].indexOf(lastCh));
|
||||
}
|
||||
window.__geohash = { bounds, encode, adjacent };
|
||||
})();
|
||||
|
||||
const map = L.map("map", { zoomControl: true, minZoom: 2, maxZoom: 21 }).setView([0, 0], 3);
|
||||
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", { maxZoom: 21, attribution: "© OpenStreetMap © Carto", opacity: 1.0 }).addTo(map);
|
||||
|
||||
let selectedGeohash = "";
|
||||
let gridLayer = L.layerGroup().addTo(map);
|
||||
let pinnedPrecision = null;
|
||||
let outlineColor = "#00C851";
|
||||
|
||||
function getNeighbors(hash) {
|
||||
const neighbors = [];
|
||||
// N, S, E, W
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'n'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 's'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(hash, 'w'));
|
||||
// Diagonals
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'w'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'e'));
|
||||
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'w'));
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
function pickPrecisionForViewport() {
|
||||
const c = map.getCenter();
|
||||
const minPx = 80;
|
||||
const maxPx = 240;
|
||||
let chosen = 1;
|
||||
let lastAboveMin = 1;
|
||||
for (let p = 1; p <= 12; p++) {
|
||||
const gh = window.__geohash.encode(c.lat, c.lng, p);
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const pSw = map.latLngToLayerPoint([b.sw.lat, b.sw.lng]);
|
||||
const pNe = map.latLngToLayerPoint([b.ne.lat, b.ne.lng]);
|
||||
const cellPx = Math.min(Math.abs(pNe.x - pSw.x), Math.abs(pSw.y - pNe.y));
|
||||
if (cellPx >= minPx && cellPx <= maxPx) { chosen = p; break; }
|
||||
if (cellPx >= minPx) { lastAboveMin = p; }
|
||||
if (cellPx < minPx) { chosen = lastAboveMin; break; }
|
||||
if (p === 12) { chosen = 12; }
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
function notifySelection() {
|
||||
if (window.Android && window.Android.onGeohashChanged && selectedGeohash) {
|
||||
window.Android.onGeohashChanged(selectedGeohash);
|
||||
}
|
||||
}
|
||||
|
||||
function zoomForPrecision(p) {
|
||||
if (p <= 1) return 1; if (p === 2) return 2; if (p === 3) return 3; if (p === 4) return 4;
|
||||
if (p === 5) return 5; if (p === 6) return 7; if (p === 7) return 9; if (p === 8) return 11;
|
||||
if (p === 9) return 13; if (p === 10) return 15; if (p === 11) return 17;
|
||||
return 18;
|
||||
}
|
||||
|
||||
function updateOverlay() {
|
||||
gridLayer.clearLayers();
|
||||
const c = map.getCenter();
|
||||
const usePinned = pinnedPrecision !== null;
|
||||
const p = usePinned ? pinnedPrecision : pickPrecisionForViewport();
|
||||
selectedGeohash = window.__geohash.encode(c.lat, c.lng, p);
|
||||
notifySelection();
|
||||
|
||||
const centerBounds = window.__geohash.bounds(selectedGeohash);
|
||||
const centerLon = (centerBounds.sw.lng + centerBounds.ne.lng) / 2;
|
||||
const centerLat = (centerBounds.sw.lat + centerBounds.ne.lat) / 2;
|
||||
|
||||
const allHashes = [selectedGeohash, ...getNeighbors(selectedGeohash)];
|
||||
|
||||
const filteredHashes = allHashes.filter(gh => {
|
||||
if (!gh) return false;
|
||||
try {
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const lon = (b.sw.lng + b.ne.lng) / 2;
|
||||
const lat = (b.sw.lat + b.ne.lat) / 2;
|
||||
if (Math.abs(lon - centerLon) > 180) return false; // anti-meridian wrap
|
||||
if (Math.abs(lat - centerLat) > 90) return false; // pole wrap
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
|
||||
filteredHashes.forEach(gh => {
|
||||
const b = window.__geohash.bounds(gh);
|
||||
const sw = [b.sw.lat, b.sw.lng];
|
||||
const ne = [b.ne.lat, b.ne.lng];
|
||||
const isSelected = (gh === selectedGeohash);
|
||||
|
||||
const rect = L.rectangle([sw, ne], {
|
||||
color: isSelected ? outlineColor : '#cccccc',
|
||||
weight: isSelected ? 3 : 1,
|
||||
fillOpacity: 0.0,
|
||||
opacity: 0.9,
|
||||
interactive: false
|
||||
});
|
||||
gridLayer.addLayer(rect);
|
||||
|
||||
const center = [(b.sw.lat + b.ne.lat) / 2, (b.sw.lng + b.ne.lng) / 2];
|
||||
const labelClass = isSelected ? 'gh-text gh-text-selected' : 'gh-text';
|
||||
const label = L.marker(center, {
|
||||
icon: L.divIcon({
|
||||
className: 'gh-label',
|
||||
html: `<span class="${labelClass}">${gh}</span>`
|
||||
}),
|
||||
interactive: false
|
||||
});
|
||||
gridLayer.addLayer(label);
|
||||
});
|
||||
}
|
||||
|
||||
map.on("movestart", () => { pinnedPrecision = null; });
|
||||
map.on("zoomstart", () => { pinnedPrecision = null; });
|
||||
map.on("moveend", updateOverlay);
|
||||
map.on("zoomend", updateOverlay);
|
||||
|
||||
function setCenter(lat, lng) { map.setView([lat, lng], map.getZoom()); }
|
||||
function setPrecision(p) {
|
||||
const clamped = Math.max(1, Math.min(12, p|0));
|
||||
const targetZoom = zoomForPrecision(clamped);
|
||||
map.setZoom(targetZoom);
|
||||
}
|
||||
function focusGeohash(gh) {
|
||||
if (!gh || typeof gh !== 'string') return;
|
||||
const g = gh.toLowerCase();
|
||||
const b = window.__geohash.bounds(g);
|
||||
pinnedPrecision = g.length;
|
||||
map.fitBounds([[b.sw.lat, b.sw.lng],[b.ne.lat, b.ne.lng]], { animate: false, padding: [8,8] });
|
||||
selectedGeohash = g;
|
||||
}
|
||||
function getGeohash() { return selectedGeohash; }
|
||||
|
||||
// Android side will call this with 'dark' or 'light'
|
||||
function setMapTheme(theme) {
|
||||
document.body.className = theme;
|
||||
}
|
||||
|
||||
window.setCenter = setCenter;
|
||||
window.setPrecision = setPrecision;
|
||||
window.focusGeohash = focusGeohash;
|
||||
window.getGeohash = getGeohash;
|
||||
window.setMapTheme = setMapTheme;
|
||||
|
||||
function cleanup() {
|
||||
try { map.off(); } catch (_) {}
|
||||
try { gridLayer.clearLayers(); } catch (_) {}
|
||||
try { map.remove(); } catch (_) {}
|
||||
}
|
||||
window.cleanup = cleanup;
|
||||
|
||||
map.whenReady(updateOverlay);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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