bitchat-android/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
callebtc fa43821cf4 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.
2026-07-27 12:36:09 +02:00

1050 lines
47 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.bitchat.android.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material.icons.outlined.Public
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
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 androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.nostr.geohashesForSampling
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import com.bitchat.android.net.TorPreferenceManager
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text.
* Selection dots and row icons sit in this column so every option lines up with About settings.
*/
private val ChannelLeadingSlot = SheetRowLeadingSlot
private val ChannelLeadingGutter = SheetRowLeadingGutter
private val ChannelRowHorizontal = SheetRowHorizontal
private val ChannelRowVertical = SheetRowVertical
private val ChannelDividerInset = SheetRowDividerInset
/** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */
private val ChannelSelectedDot = SheetRowSelectedDot
/**
* Pause between applying a channel selection and dismissing the sheet.
*
* Just enough for the active dot to land on the chosen row, so the tap is acknowledged rather than
* answered by the sheet simply disappearing.
*/
private const val SelectionConfirmDelayMs = 180L
/**
* Location Channels sheet: grouped card rows matching About → Settings.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationChannelsSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
viewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val locationManager = LocationChannelManager.getInstance(context)
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle()
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
var customGeohash by remember { mutableStateOf("") }
var customError by remember { mutableStateOf<String?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.98f else 0f,
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
label = "topBarAlpha"
)
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
}
}
}
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val standardGreen = palette.accentGreen
val standardBlue = palette.accentBlue
val nearbyChannels = remember(availableChannels) {
availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
}
val selectedChannelOutsideNearby = remember(selectedChannel, nearbyChannels) {
selectedLocationChannelOutsideNearby(selectedChannel, nearbyChannels)
}
val showNearbyLoading = nearbyChannels.isEmpty() &&
permissionState == LocationChannelManager.PermissionState.AUTHORIZED &&
locationServicesEnabled
if (isPresented) {
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
// Selection is applied immediately so the active dot snaps to the new row, then the
// sheet slides away after a beat. Long enough to register the change, short enough
// that it never feels like waiting.
val animatedDismiss = LocalSheetDismiss.current
val confirmSelectionThenDismiss: () -> Unit = {
coroutineScope.launch {
delay(SelectionConfirmDelayMs)
animatedDismiss?.invoke() ?: onDismiss()
}
}
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
if (locationServicesEnabled &&
permissionState == LocationChannelManager.PermissionState.DENIED
) {
item(key = "permissions") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = stringResource(R.string.location_permission_denied),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = palette.accentRed
)
TextButton(
onClick = {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
context.startActivity(intent)
},
contentPadding = PaddingValues(0.dp)
) {
Text(
text = stringResource(R.string.open_settings),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
// Mesh section: icon + title header, offline subtitle, then selection card
item(key = "mesh_card") {
Column {
SheetIconSectionHeader(
icon = Icons.Filled.Hub,
title = stringResource(R.string.mesh_title),
subtitle = stringResource(R.string.mesh_section_subtitle),
modifier = Modifier.padding(top = 8.dp)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = palette.surface,
shape = AboutCardShape
) {
ChannelOptionRow(
title = meshTitleWithCount(viewModel),
subtitle = stringResource(
R.string.location_bluetooth_subtitle,
bluetoothRangeString()
),
isSelected = selectedChannel is ChannelID.Mesh,
participantCount = meshCount(viewModel),
titleColor = standardBlue,
titleBold = meshCount(viewModel) > 0,
onClick = {
locationManager.select(ChannelID.Mesh)
onDismiss()
}
)
}
}
}
// Location channels: globe + title, geohash subtitle, nearby levels + teleport
item(key = "channels_card") {
Column {
SheetIconSectionHeader(
icon = Icons.Outlined.Public,
title = stringResource(R.string.location_channels_heading),
subtitle = stringResource(R.string.location_channels_desc),
modifier = Modifier.padding(top = 20.dp)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = palette.surface,
shape = AboutCardShape
) {
Column {
selectedChannelOutsideNearby?.let { channel ->
val coverage = coverageString(channel.geohash.length)
val name = bookmarkNames[channel.geohash]
val subtitle = "#${channel.geohash}$coverage" +
(name?.let { "${formattedNamePrefix(channel.level)}$it" } ?: "")
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
ChannelOptionRow(
title = geohashTitleWithCount(channel, participantCount),
subtitle = subtitle,
isSelected = true,
participantCount = participantCount,
titleColor = standardGreen,
titleBold = participantCount > 0,
trailingContent = {
ChannelBookmarkButton(
bookmarked = isBookmarked,
onClick = { bookmarksStore.toggle(channel.geohash) }
)
},
onClick = {
locationManager.select(ChannelID.Location(channel))
onDismiss()
}
)
SheetCardDivider()
}
if (locationServicesEnabled) {
if (nearbyChannels.isNotEmpty()) {
nearbyChannels.forEachIndexed { index, channel ->
if (index > 0) SheetCardDivider()
val coverage = coverageString(channel.geohash.length)
val nameBase = locationNames[channel.level]
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
val subtitlePrefix = "#${channel.geohash}$coverage"
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
ChannelOptionRow(
title = geohashTitleWithCount(channel, participantCount),
subtitle = subtitlePrefix + (namePart?.let { "$it" } ?: ""),
isSelected = isChannelSelected(channel, selectedChannel),
participantCount = participantCount,
titleColor = standardGreen,
titleBold = participantCount > 0,
trailingContent = {
ChannelBookmarkButton(
bookmarked = isBookmarked,
onClick = { bookmarksStore.toggle(channel.geohash) }
)
},
onClick = {
locationManager.setTeleported(false)
locationManager.select(ChannelID.Location(channel))
onDismiss()
}
)
}
SheetCardDivider()
} else if (showNearbyLoading) {
ChannelLoadingRow()
SheetCardDivider()
}
}
CustomGeohashRow(
customGeohash = customGeohash,
onGeohashChange = { value ->
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
customGeohash = value
.lowercase()
.replace("#", "")
.filter { it in allowed }
.take(12)
customError = null
},
onFocusGained = {
coroutineScope.launch {
sheetState.expand()
listState.animateScrollToItem(
index = listState.layoutInfo.totalItemsCount - 1
)
}
},
onOpenMap = {
val normalized = customGeohash.trim().lowercase().replace("#", "")
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)
},
onTeleport = {
val normalized = customGeohash.trim().lowercase().replace("#", "")
if (validateGeohash(normalized)) {
val level = levelForLength(normalized.length)
val channel = GeohashChannel(level = level, geohash = normalized)
locationManager.setTeleported(true)
locationManager.select(ChannelID.Location(channel))
onDismiss()
} else {
customError = context.getString(R.string.invalid_geohash)
}
}
)
}
}
AnimatedVisibility(
visible = customError != null,
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
expandVertically(
tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
),
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
shrinkVertically(
tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
)
) {
// Held across the exit animation: by the time it plays, the error
// itself has already been cleared.
val shownError = remember(customError) { customError ?: "" }
Text(
text = shownError,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = palette.accentRed,
modifier = Modifier.padding(
start = AboutHorizontalPadding + ChannelRowHorizontal,
top = 8.dp
)
)
}
}
}
if (bookmarks.isNotEmpty()) {
item(key = "bookmarks_card") {
Column {
AboutSectionLabel(text = stringResource(R.string.bookmarked))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = palette.surface,
shape = AboutCardShape
) {
Column {
bookmarks.forEachIndexed { index, gh ->
if (index > 0) SheetCardDivider()
val level = levelForLength(gh.length)
val channel = GeohashChannel(level = level, geohash = gh)
val coverage = coverageString(gh.length)
val name = bookmarkNames[gh]
val subtitle = "#$gh$coverage" +
(name?.let { "${formattedNamePrefix(level)}$it" } ?: "")
val participantCount = geohashParticipantCounts[gh] ?: 0
ChannelOptionRow(
title = geohashHashTitleWithCount(gh, participantCount),
subtitle = subtitle,
isSelected = isChannelSelected(channel, selectedChannel),
participantCount = participantCount,
titleBold = participantCount > 0,
trailingContent = {
ChannelBookmarkButton(
bookmarked = true,
onClick = { bookmarksStore.toggle(gh) }
)
},
onClick = {
val inRegional = availableChannels.any { it.geohash == gh }
locationManager.setTeleported(
!inRegional && availableChannels.isNotEmpty()
)
locationManager.select(ChannelID.Location(channel))
onDismiss()
}
)
LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) }
}
}
}
}
}
}
item(key = "tor_routing") {
val torProvider = remember { ArtiTorManager.getInstance() }
val torAvailable = remember { torProvider.isTorAvailable() }
var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) }
Column {
AboutSectionLabel(text = stringResource(R.string.about_network))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding),
color = palette.surface,
shape = AboutCardShape
) {
ChannelSettingsToggleRow(
icon = Icons.Filled.Security,
title = stringResource(R.string.location_tor_routing_title),
subtitle = stringResource(R.string.location_tor_routing_desc),
checked = torMode == TorMode.ON,
enabled = torAvailable,
statusIndicator = {
BitchatBadge(text = stringResource(R.string.badge_recommended))
},
onCheckedChange = { enabled ->
if (torAvailable) {
torMode = if (enabled) TorMode.ON else TorMode.OFF
TorPreferenceManager.set(context, torMode)
}
}
)
}
if (!torAvailable) {
Text(
text = stringResource(R.string.tor_not_available_in_this_build),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = palette.textTertiary,
modifier = Modifier.padding(
start = AboutHorizontalPadding + ChannelRowHorizontal,
top = 8.dp
)
)
}
}
}
item(key = "location_toggle") {
SheetDestructiveButton(
text = if (locationServicesEnabled) {
stringResource(R.string.disable_location_services)
} else {
stringResource(R.string.enable_location_services)
},
isDestructive = locationServicesEnabled,
onClick = {
if (locationServicesEnabled) {
locationManager.disableLocationServices()
} else {
locationManager.enableLocationServices()
}
},
modifier = Modifier.padding(
start = AboutHorizontalPadding,
end = AboutHorizontalPadding,
top = 24.dp
)
)
}
}
BitchatSheetTopBar(
onClose = onDismiss,
modifier = modifier.align(Alignment.TopCenter),
title = {
BitchatSheetTitle(
text = stringResource(R.string.location_channels_title)
)
}
)
}
}
}
DisposableEffect(isPresented, permissionState, locationServicesEnabled) {
if (isPresented &&
permissionState == LocationChannelManager.PermissionState.AUTHORIZED &&
locationServicesEnabled
) {
locationManager.refreshChannels()
locationManager.beginLiveRefresh()
}
onDispose { locationManager.endLiveRefresh() }
}
// Sampling management: update sampling when channels/bookmarks change
LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) {
if (isPresented) {
val geohashes = geohashesForSampling(
availableChannels = availableChannels,
bookmarks = bookmarks,
notesRevealed = notesRevealed,
)
viewModel.beginGeohashSampling(geohashes)
} else {
viewModel.endGeohashSampling()
}
}
DisposableEffect(Unit) {
onDispose { viewModel.endGeohashSampling() }
}
}
/**
* Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing.
* Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint).
*/
@Composable
private fun ChannelOptionRow(
title: String,
subtitle: String,
isSelected: Boolean,
participantCount: Int,
titleColor: Color? = null,
titleBold: Boolean = false,
leadingIcon: ImageVector? = null,
trailingContent: (@Composable (() -> Unit))? = null,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val (baseTitle, countSuffix) = splitTitleAndCount(title)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(ChannelLeadingSlot),
contentAlignment = Alignment.Center
) {
when {
isSelected -> {
Box(
modifier = Modifier
.size(ChannelSelectedDot)
.background(palette.accentGreen, CircleShape)
)
}
leadingIcon != null -> {
Icon(
imageVector = leadingIcon,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
}
}
}
Spacer(modifier = Modifier.width(ChannelLeadingGutter))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = baseTitle,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium,
color = titleColor ?: palette.textPrimary
)
countSuffix?.let { count ->
AnimatedCountLabel(
count = participantCount,
text = count,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = palette.textSecondary
)
}
}
Text(
text = subtitle,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
lineHeight = 17.sp,
color = palette.textSecondary
)
}
if (trailingContent != null) {
Spacer(modifier = Modifier.width(8.dp))
trailingContent()
}
}
}
@Composable
private fun ChannelBookmarkButton(
bookmarked: Boolean,
onClick: () -> Unit
) {
val palette = LocalBitchatPalette.current
Box(
modifier = Modifier
.size(36.dp)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (bookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = stringResource(
if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark
),
tint = if (bookmarked) palette.accentGreen else palette.textSecondary,
modifier = Modifier.size(22.dp)
)
}
}
@Composable
private fun ChannelLoadingRow() {
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical),
horizontalArrangement = Arrangement.spacedBy(ChannelLeadingGutter),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(ChannelLeadingSlot),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = palette.textSecondary
)
}
Text(
text = stringResource(R.string.finding_nearby_channels),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = palette.textSecondary
)
}
}
/**
* Teleport / custom geohash control unified into the same card as channel options.
*/
@Composable
private fun CustomGeohashRow(
customGeohash: String,
onGeohashChange: (String) -> Unit,
onFocusGained: () -> Unit,
onOpenMap: () -> Unit,
onTeleport: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val normalized = customGeohash.trim().lowercase().replace("#", "")
val isValid = validateGeohash(normalized)
// Typing the last character of a valid geohash arms the button; cross-fading both the label
// and its container makes that the moment the row confirms the input is usable.
val teleportColor by animateColorAsState(
targetValue = if (isValid) colorScheme.primary else palette.textTertiary,
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "teleportLabel"
)
val teleportContainer by animateColorAsState(
targetValue = if (isValid) {
colorScheme.primary.copy(alpha = 0.16f)
} else {
palette.surfaceVariant
},
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "teleportContainer"
)
val teleportInteraction = remember { MutableInteractionSource() }
val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(ChannelLeadingSlot),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.PinDrop,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
}
Spacer(modifier = Modifier.width(ChannelLeadingGutter))
Text(
text = stringResource(R.string.hash_symbol),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = palette.textTertiary
)
Spacer(modifier = Modifier.width(4.dp))
BasicTextField(
value = customGeohash,
onValueChange = onGeohashChange,
textStyle = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.primary
),
cursorBrush = SolidColor(colorScheme.primary),
singleLine = true,
modifier = Modifier
.weight(1f)
.onFocusChanged { if (it.isFocused) onFocusGained() },
decorationBox = { inner ->
if (customGeohash.isEmpty()) {
Text(
text = stringResource(R.string.geohash_placeholder),
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
color = palette.textTertiary
)
}
inner()
}
)
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.pressScaleClickable(onClick = onOpenMap),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Map,
contentDescription = stringResource(R.string.cd_open_map),
tint = palette.textSecondary,
modifier = Modifier.size(22.dp)
)
}
Surface(
onClick = onTeleport,
enabled = isValid,
shape = RoundedCornerShape(8.dp),
color = teleportContainer,
interactionSource = teleportInteraction,
modifier = Modifier.scale(teleportScale)
) {
Text(
text = stringResource(R.string.teleport).uppercase(),
fontSize = 11.sp,
letterSpacing = 0.8.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace,
color = teleportColor,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)
)
}
}
}
/** Settings-toggle row geometry (icon + title/subtitle + switch), local copy for this sheet. */
@Composable
private fun ChannelSettingsToggleRow(
icon: ImageVector,
title: String,
subtitle: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
statusIndicator: (@Composable () -> Unit)? = null
) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (enabled) colorScheme.primary else palette.textTertiary,
modifier = Modifier.size(ChannelLeadingSlot)
)
Spacer(modifier = Modifier.width(ChannelLeadingGutter))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = title,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = if (enabled) palette.textPrimary else palette.textTertiary
)
statusIndicator?.invoke()
}
Text(
text = subtitle,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
lineHeight = 17.sp,
color = if (enabled) palette.textSecondary else palette.textTertiary
)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(
checked = checked,
onCheckedChange = { if (enabled) onCheckedChange(it) },
enabled = enabled,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = palette.accentGreen,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = palette.surfaceVariant
)
)
}
}
// MARK: - Helper Functions
private fun splitTitleAndCount(title: String): Pair<String, String?> {
val lastBracketIndex = title.lastIndexOf('[')
return if (lastBracketIndex != -1) {
Pair(title.substring(0, lastBracketIndex).trim(), title.substring(lastBracketIndex))
} else {
Pair(title, null)
}
}
@Composable
private fun meshTitleWithCount(viewModel: ChatViewModel): String {
val meshCount = meshCount(viewModel)
val ctx = LocalContext.current
val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount)
val meshLabel = stringResource(R.string.mesh_title)
return "$meshLabel [$peopleText]"
}
private fun meshCount(viewModel: ChatViewModel): Int {
val myID = viewModel.myPeerID
return viewModel.connectedPeers.value?.count { it != myID } ?: 0
}
@Composable
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
val ctx = LocalContext.current
val isHighPrecision = channel.level.precision > 5
val peopleText = if (isHighPrecision && participantCount == 0) {
ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?")
} else {
ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount)
}
val levelName = when (channel.level) {
GeohashChannelLevel.BUILDING -> "Building"
GeohashChannelLevel.BLOCK -> stringResource(R.string.location_level_block)
GeohashChannelLevel.NEIGHBORHOOD -> stringResource(R.string.location_level_neighborhood)
GeohashChannelLevel.CITY -> stringResource(R.string.location_level_city)
GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province)
GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region)
}
return "$levelName [$peopleText]"
}
@Composable
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
val ctx = LocalContext.current
val level = levelForLength(geohash.length)
val isHighPrecision = level.precision > 5
val peopleText = if (isHighPrecision && participantCount == 0) {
ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?")
} else {
ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount)
}
return "#$geohash [$peopleText]"
}
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
return when (selectedChannel) {
is ChannelID.Location -> selectedChannel.channel == channel
else -> false
}
}
/**
* Returns the active location channel when it has no row in the nearby-channel list.
*
* This commonly happens after teleporting to a remote geohash. Keeping the selected channel in the
* main channel card makes its selection and bookmark action available even before it is bookmarked.
*/
internal fun selectedLocationChannelOutsideNearby(
selectedChannel: ChannelID?,
nearbyChannels: List<GeohashChannel>
): GeohashChannel? {
val selected = (selectedChannel as? ChannelID.Location)?.channel ?: return null
return selected.takeUnless { active ->
nearbyChannels.any { nearby ->
nearby.geohash.equals(active.geohash, ignoreCase = true)
}
}
}
private fun validateGeohash(geohash: String): Boolean {
if (geohash.isEmpty() || geohash.length > 12) return false
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
return geohash.all { it in allowed }
}
private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) {
in 0..2 -> GeohashChannelLevel.REGION
in 3..4 -> GeohashChannelLevel.PROVINCE
5 -> GeohashChannelLevel.CITY
6 -> GeohashChannelLevel.NEIGHBORHOOD
7 -> GeohashChannelLevel.BLOCK
8 -> GeohashChannelLevel.BUILDING
else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK
}
}
private fun coverageString(precision: Int): String {
val maxMeters = when (precision) {
2 -> 1_250_000.0
3 -> 156_000.0
4 -> 39_100.0
5 -> 4_890.0
6 -> 1_220.0
7 -> 153.0
8 -> 38.2
9 -> 4.77
10 -> 1.19
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
}
return "~${formatDistance(maxMeters / 1000.0)} km"
}
private fun formatDistance(value: Double): String {
return when {
value >= 100 -> String.format("%.0f", value)
value >= 10 -> String.format("%.1f", value)
else -> String.format("%.1f", value)
}
}
private fun bluetoothRangeString(): String = "~1050 m"
private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~"