bitchat-android/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt
callebtc 7025009788
ui: complete the redesign — palette, top bar, composer, About, and a motion pass (#774)
* first pass

* pass 2

* cleanup

* capitalization

* strings

* input bar fixes

* fixes

* notes

* nice

* nicer

* lists

* cleanup

* button

* fixes

* animations

* Fix layout jumpiness in chat and geohash people list

Three separate causes of things moving when they should not:

- Chat lurched whenever a bottom sheet closed. Placement animation is meant
  to soften insertions and removals, but any relayout moves every item --
  a sheet's text field opening the keyboard changes the chat's IME inset,
  and closing it changes it back. Placement animation is now armed only
  briefly around a real change to the message list, so items otherwise
  track the viewport exactly.

- Anon list changed height as participants churned. Rows sized to their
  content, so any reorder could change the card's height; and the card
  sized to the live anon count, which moves constantly in a busy geohash.
  Rows now have an exact height, and a trimmed anon card reserves the full
  capped height regardless of how many are present beyond the cap.

- Anons are now their own trailing section rather than a tail on each of
  "on location" and "teleported in", which had pushed the few recognisable
  names out of view twice over. Self is never grouped as an anon.

Adds 7 tests covering the sectioning and the fixed-length behaviour.

* Group geohash people as People and Anon

Replaces the "on location" / "teleported in" / "anonymous" split with two
sections: peers who announced a nickname, then the anons.

Teleport state was never worth a section of its own -- every row already
carries it as a distinct glyph -- and splitting on it fragmented the short
list people actually read, in a channel where most participants are
anonymous anyway.

Self stays in the People section even when unnamed.

* Key message list state per conversation

Switching channels reused every piece of state in MessagesList, because none
of it was keyed on which conversation was being shown:

- The LazyListState carried the previous channel's scroll offset, so the new
  channel opened at a stale position and then corrected itself.
- hasScrolledToInitialPosition and followIncomingMessages carried over, so a
  channel entered after scrolling up in another one did not land on its
  newest message at all.
- The arrival tracker had never seen the incoming channel's ids, so a
  backlog of six or fewer messages was treated as six simultaneous arrivals
  and each one slid in.
- previousMessageCount carried over, arming placement animation for the
  relayout that the switch itself caused.

All of it is now keyed on a conversationKey derived the same way
displayMessages is. The tracker also detects a list sharing no ids with the
previous one and adopts it silently, which covers /clear and any caller that
does not supply a distinct key.

Adds 4 tests for wholesale replacement, including the case that the burst
cap cannot catch on its own.

* fix location channel layout

* icon

* location sheet

* move location error

* fix location channel lifecycle bug

* remove empty lable

* geist mono

* timestamp no seconds

* new icons

* icons

* cleanup

* mentions

* fix mentions

* grouping of geohash channel list

* colors

* fix mention colors

* Bring private and group chat headers up to the main header's layout

Both conversation headers were built on TopAppBar with a centred title, a
back arrow on the left and everything else crowded into the title slot, at
14sp with 14dp icons. Moving between the timeline and a conversation visibly
shifted the bar's height, insets and type.

Introduces ConversationHeader, built from the main header's own tokens rather
than TopAppBar: same ChatHeaderHeight, same 12/8dp edge insets, leading glyph
in a 44dp slot so it lands exactly where the brand mark does, same -6dp
optical nudge pulling the title toward it, same 17sp label.

- Drops the back button; the close action on the right is the way out.
  Leaving a channel outright already lives on its row in the network sheet,
  so it does not need a second home beside the exit.
- Leading glyph is the transport: globe over the internet, wifi/bluetooth/
  routed on the mesh, matching the main header's channel button.
- Actions are right-aligned and unweighted -- favourite, encryption state,
  close -- so a long title yields space to them instead of pushing them off
  screen.
- Private chat titles use the primary green like every other header label,
  rather than orange for Nostr-reachable peers.

Height and edge insets now belong to each header variant instead of the
ChatFloatingHeader wrapper, which was applying them a second time to the
channel header.

Adds nine spec icons in the existing 20x20 / 1.25-stroke language -- bluetooth,
wifi, routed, close, check, warning, sync, lock_open, envelope -- so the
headers and peer rows no longer mix Material glyphs into the set.

* color
2026-07-27 17:33:11 +02:00

290 lines
15 KiB
Kotlin

package com.bitchat.android.ui
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 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.WebResourceRequest
import android.webkit.WebViewClient
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.bitchat.android.ui.theme.BitchatTheme
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.res.stringResource
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
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 : OrientationAwareActivity() {
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 {
BitchatTheme {
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
val colorScheme = MaterialTheme.colorScheme
val standardGreen = colorScheme.primary
Scaffold { padding ->
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
settings.allowFileAccess = true
settings.allowContentAccess = true
webChromeClient = WebChromeClient()
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return true
// Block navigation away from the local geohash picker asset
return !url.startsWith("file:///android_asset/geohash_picker.html")
}
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 = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
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 = BitchatFontFamily,
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 = stringResource(R.string.cd_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 = stringResource(R.string.cd_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 = stringResource(R.string.cd_select_geohash))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.select),
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = BitchatFontFamily
)
}
}
}
}
}
}
}
}
}
}