mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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.
1050 lines
47 KiB
Kotlin
1050 lines
47 KiB
Kotlin
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 = "~10–50 m"
|
||
|
||
private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~"
|