2026-07-27 13:27:15 +02:00

829 lines
34 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 com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatFontFamily
// [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition
// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.animation.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.nostr.LocationNotesManager
import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.ui.media.FullScreenImageViewer
import com.bitchat.android.ui.theme.BitchatMotion
import com.bitchat.android.ui.theme.LocalBitchatPalette
/**
* Main ChatScreen - REFACTORED to use component-based architecture
* This is now a coordinator that orchestrates the following UI components:
* - ChatHeader: App bar, navigation, peer counter
* - MessageComponents: Message display and formatting
* - InputComponents: Message input and command suggestions
* - SidebarComponents: Navigation drawer with channels and people
* - AboutSheet: App info and password prompts
* - ChatUIUtils: Utility functions for formatting and colors
*/
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
val colorScheme = MaterialTheme.colorScheme
val palette = LocalBitchatPalette.current
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle()
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
var showPasswordDialog by remember { mutableStateOf(false) }
var passwordInput by remember { mutableStateOf("") }
var showLocationChannelsSheet by remember { mutableStateOf(false) }
var showLocationNotesSheet by remember { mutableStateOf(false) }
var showUserSheet by remember { mutableStateOf(false) }
var selectedUserForSheet by remember { mutableStateOf("") }
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
var showFullScreenImageViewer by remember { mutableStateOf(false) }
var viewerImagePaths by remember { mutableStateOf(emptyList<String>()) }
var initialViewerIndex by remember { mutableStateOf(0) }
var forceScrollToBottom by remember { mutableStateOf(false) }
var isScrolledUp by remember { mutableStateOf(false) }
// Show password dialog when needed
LaunchedEffect(showPasswordPrompt) {
showPasswordDialog = showPasswordPrompt
}
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val nearbyNotesController = remember { NearbyNotesController.shared }
val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle()
val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
val availableLocationChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nearbyNotes by remember { LocationNotesManager.getInstance() }
.notes
.collectAsStateWithLifecycle()
val buildingGeohash = availableLocationChannels
.firstOrNull { it.level == GeohashChannelLevel.BUILDING }
?.geohash
val isMeshTimeline =
currentChannel == null &&
selectedLocationChannel is ChannelID.Mesh &&
selectedPrivatePeer == null &&
privateChatSheetPeer == null
val processLifecycleOwner = remember { ProcessLifecycleOwner.get() }
DisposableEffect(processLifecycleOwner, nearbyNotesController) {
val lifecycle = processLifecycleOwner.lifecycle
val observer = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(true)
}
override fun onStop(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(false)
}
}
lifecycle.addObserver(observer)
nearbyNotesController.updateAppForeground(
lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
)
onDispose {
lifecycle.removeObserver(observer)
nearbyNotesController.updateAppForeground(false)
}
}
DisposableEffect(
isMeshTimeline,
locationEnabled,
locationPermissionState,
buildingGeohash,
nearbyNotesController,
) {
nearbyNotesController.updateAvailability(
locationEnabled = locationEnabled,
locationAuthorized =
locationPermissionState == LocationChannelManager.PermissionState.AUTHORIZED,
buildingGeohash = buildingGeohash,
)
if (isMeshTimeline) nearbyNotesController.activate()
onDispose {
if (isMeshTimeline) nearbyNotesController.deactivate()
}
}
// Determine what messages to show based on current context (unified timelines)
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
val displayMessages = when {
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
else -> {
val locationChannel = selectedLocationChannel
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
val geokey = "geo:${locationChannel.channel.geohash}"
channelMessages[geokey] ?: emptyList()
} else {
messages // Mesh timeline
}
}
}
// Identity of the timeline on screen, derived exactly like displayMessages above. Drives the
// per-conversation scroll position and animation state in MessagesList.
val conversationKey = when {
currentChannel != null -> "channel:$currentChannel"
else -> {
val locationChannel = selectedLocationChannel
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
"geo:${locationChannel.channel.geohash}"
} else {
"mesh"
}
}
}
// Determine whether to show media buttons (only hide in geohash location chats)
val showMediaButtons = when {
currentChannel != null -> true
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
}
// Use WindowInsets to handle keyboard properly
Box(
modifier = Modifier
.fillMaxSize()
.background(colorScheme.background) // Extend background to fill entire screen including status bar
) {
val headerHeight = ChatHeaderHeight
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
// Both bars are translucent and the conversation scrolls underneath them, so their
// heights are reserved as list padding instead of as layout space. The composer's height
// varies (suggestion rows, wrapped lines), so it is measured rather than assumed.
var composerHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
// Main content area that responds to keyboard/window insets
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets
.windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded
) {
Box(modifier = Modifier.weight(1f)) {
// Messages area - takes up available space, will compress when keyboard appears
// Nearby-notes strip and the reveal hint both live in this Box alongside the
// list, rather than in a Column above it, because the conversation has to scroll
// underneath the translucent bars. Their heights are reserved as list padding.
var notesStripHeight by remember { mutableStateOf(0.dp) }
val showNotesStrip =
isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.fillMaxSize(),
conversationKey = conversationKey,
contentPadding = PaddingValues(
top = statusBarHeight + headerHeight +
(if (showNotesStrip) notesStripHeight else 0.dp),
bottom = composerHeight
),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
// Extract base nickname and hash suffix from full sender name
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
// Check if we're in a geohash channel to include hash suffix
val selectedLocationChannel = viewModel.selectedLocationChannel.value
val mentionText = if (
selectedLocationChannel is ChannelID.Location &&
hashSuffix.isNotEmpty()
) {
// In geohash chat - include the hash suffix from the full display name
"@$baseName$hashSuffix"
} else {
// Regular chat - just the base nickname
"@$baseName"
}
val newText = when {
currentText.isEmpty() -> "$mentionText "
currentText.endsWith(" ") -> "$currentText$mentionText "
else -> "$currentText $mentionText "
}
messageText = TextFieldValue(
text = newText,
selection = TextRange(newText.length)
)
},
onMessageLongPress = { message ->
// Message long press - open user action sheet with message context
// Extract base nickname from message sender (contains all necessary info)
val (baseName, _) = splitSuffix(message.sender)
selectedUserForSheet = baseName
selectedMessageForSheet = message
showUserSheet = true
},
onCancelTransfer = { msg ->
viewModel.cancelMediaSend(msg.id)
},
onImageClick = { currentPath, allImagePaths, initialIndex ->
viewerImagePaths = allImagePaths
initialViewerIndex = initialIndex
showFullScreenImageViewer = true
}
)
if (showNotesStrip) {
NearbyNotesStrip(
noteCount = nearbyNotes.size,
onClick = { showLocationNotesSheet = true },
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = statusBarHeight + headerHeight)
.onSizeChanged { size ->
notesStripHeight = with(density) { size.height.toDp() }
},
)
}
// Input area - overlays the bottom of the conversation
// Bridge file share from lower-level input to ViewModel
androidx.compose.runtime.LaunchedEffect(Unit) {
com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path ->
viewModel.sendFileNote(peer, channel, path)
}
}
ChatInputSection(
modifier = Modifier
.align(Alignment.BottomCenter)
.onSizeChanged { size ->
composerHeight = with(density) { size.height.toDp() }
},
messageText = messageText,
onMessageTextChange = { newText: TextFieldValue ->
messageText = newText
viewModel.updateCommandSuggestions(newText.text)
viewModel.updateMentionSuggestions(newText.text)
},
onSend = {
if (messageText.text.trim().isNotEmpty()) {
viewModel.sendMessage(messageText.text.trim())
messageText = TextFieldValue("")
forceScrollToBottom = !forceScrollToBottom // Toggle to trigger scroll
}
},
onSendVoiceNote = { peer, onionOrChannel, path ->
viewModel.sendVoiceNote(peer, onionOrChannel, path)
},
onSendImageNote = { peer, onionOrChannel, path ->
viewModel.sendImageNote(peer, onionOrChannel, path)
},
onSendFileNote = { peer, onionOrChannel, path ->
viewModel.sendFileNote(peer, onionOrChannel, path)
},
showCommandSuggestions = showCommandSuggestions,
commandSuggestions = commandSuggestions,
showMentionSuggestions = showMentionSuggestions,
mentionSuggestions = mentionSuggestions,
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
val commandText = viewModel.selectCommandSuggestion(suggestion)
messageText = TextFieldValue(
text = commandText,
selection = TextRange(commandText.length)
)
},
onMentionSuggestionClick = { mention: String ->
val mentionText = viewModel.selectMentionSuggestion(mention, messageText.text)
messageText = TextFieldValue(
text = mentionText,
selection = TextRange(mentionText.length)
)
},
selectedPrivatePeer = null,
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme,
showMediaButtons = showMediaButtons
)
}
}
// Floating header - positioned absolutely at top, ignores keyboard
ChatFloatingHeader(
headerHeight = headerHeight,
selectedPrivatePeer = null,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
colorScheme = colorScheme,
onSidebarToggle = { viewModel.showMeshPeerList() },
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true },
onLocationNotesClick = {
nearbyNotesController.reveal()
showLocationNotesSheet = true
}
)
// Scroll-to-bottom floating button
AnimatedVisibility(
visible = isScrolledUp,
// Short and eased: the button appears mid-scroll, so a slow entrance draws the eye
// away from the messages the user is actually reading.
enter = slideInVertically(
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
initialOffsetY = { it / 2 }
) + fadeIn(tween(BitchatMotion.STANDARD_MS)),
exit = slideOutVertically(
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
targetOffsetY = { it / 2 }
) + fadeOut(tween(BitchatMotion.QUICK_MS)),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = composerHeight + 8.dp)
.zIndex(1.5f)
.windowInsetsPadding(WindowInsets.navigationBars)
.windowInsetsPadding(WindowInsets.ime)
) {
Surface(
shape = CircleShape,
color = palette.surface,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
border = BorderStroke(1.dp, palette.accentGreen)
) {
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
modifier = Modifier.size(22.dp),
tint = palette.accentGreen
)
}
}
}
}
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
if (showFullScreenImageViewer) {
FullScreenImageViewer(
imagePaths = viewerImagePaths,
initialIndex = initialViewerIndex,
onClose = { showFullScreenImageViewer = false }
)
}
// Dialogs and Sheets
ChatDialogs(
showPasswordDialog = showPasswordDialog,
passwordPromptChannel = passwordPromptChannel,
passwordInput = passwordInput,
onPasswordChange = { passwordInput = it },
onPasswordConfirm = {
if (passwordInput.isNotEmpty()) {
val success = viewModel.joinChannel(passwordPromptChannel!!, passwordInput)
if (success) {
showPasswordDialog = false
passwordInput = ""
}
}
},
onPasswordDismiss = {
showPasswordDialog = false
passwordInput = ""
},
showAppInfo = showAppInfo,
onAppInfoDismiss = { viewModel.hideAppInfo() },
showLocationChannelsSheet = showLocationChannelsSheet,
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
showLocationNotesSheet = showLocationNotesSheet,
onLocationNotesSheetDismiss = { showLocationNotesSheet = false },
showUserSheet = showUserSheet,
onUserSheetDismiss = {
showUserSheet = false
selectedMessageForSheet = null // Reset message when dismissing
},
selectedUserForSheet = selectedUserForSheet,
selectedMessageForSheet = selectedMessageForSheet,
viewModel = viewModel,
showVerificationSheet = showVerificationSheet,
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
showSecurityVerificationSheet = showSecurityVerificationSheet,
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet,
showMeshPeerListSheet = showMeshPeerListSheet,
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
)
legacyPrivateMediaConsent?.let { request ->
AlertDialog(
onDismissRequest = { viewModel.cancelLegacyPrivateMedia(request.requestId) },
title = { Text(stringResource(com.bitchat.android.R.string.private_media_legacy_title)) },
text = {
Text(
stringResource(
com.bitchat.android.R.string.private_media_legacy_body,
request.fileName,
request.recipientNickname,
request.warning
)
)
},
confirmButton = {
TextButton(onClick = { viewModel.approveLegacyPrivateMedia(request.requestId) }) {
Text(stringResource(com.bitchat.android.R.string.private_media_legacy_send_once))
}
},
dismissButton = {
TextButton(onClick = { viewModel.cancelLegacyPrivateMedia(request.requestId) }) {
Text(stringResource(android.R.string.cancel))
}
}
)
}
}
@Composable
private fun NearbyNotesStrip(
noteCount: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "📍 " + if (noteCount == 1) {
stringResource(R.string.nearby_notes_one)
} else {
stringResource(R.string.nearby_notes_many, noteCount)
},
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.primary,
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
)
Text(
text = "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 18.sp,
)
}
}
}
@Composable
fun ChatInputSection(
messageText: TextFieldValue,
onMessageTextChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
onSendVoiceNote: (String?, String?, String) -> Unit,
onSendImageNote: (String?, String?, String) -> Unit,
onSendFileNote: (String?, String?, String) -> Unit,
showCommandSuggestions: Boolean,
commandSuggestions: List<CommandSuggestion>,
showMentionSuggestions: Boolean,
mentionSuggestions: List<String>,
onCommandSuggestionClick: (CommandSuggestion) -> Unit,
onMentionSuggestionClick: (String) -> Unit,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
colorScheme: ColorScheme,
showMediaButtons: Boolean,
modifier: Modifier = Modifier
) {
val palette = LocalBitchatPalette.current
Column(
// Flat, slightly translucent screen background — the same treatment as the top bar, so the
// two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked
// like a smudge above a crisp hairline. The rule is inside the background so the whole bar
// is one surface with a top border, rather than a line floating over the conversation.
modifier = modifier
.fillMaxWidth()
.background(colorScheme.background.copy(alpha = BarBackgroundAlpha))
) {
// Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border.
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
// Command suggestions box
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
CommandSuggestionsBox(
suggestions = commandSuggestions,
onSuggestionClick = onCommandSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
}
// Mention suggestions box
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
MentionSuggestionsBox(
suggestions = mentionSuggestions,
onSuggestionClick = onMentionSuggestionClick,
modifier = Modifier.fillMaxWidth()
)
HorizontalDivider(thickness = 1.dp, color = palette.outlineVariant)
}
MessageInput(
value = messageText,
onValueChange = onMessageTextChange,
onSend = onSend,
onSendVoiceNote = onSendVoiceNote,
onSendImageNote = onSendImageNote,
onSendFileNote = onSendFileNote,
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
showMediaButtons = showMediaButtons,
modifier = Modifier.fillMaxWidth()
)
}
}
/**
* Opacity shared by both bars.
*
* Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads
* as sitting over the content rather than boxing it in — without ever costing legibility.
*/
private const val BarBackgroundAlpha = 0.88f
/**
* Fraction of the header that stays fully opaque, measured from the top.
*
* The header is the one place a gradient earns its keep: the status bar is transparent, so the
* header has to be the true background colour where the two meet or the system bar stops looking
* like part of the app. Everything below that stop matches the composer's flat translucency.
*/
private const val HeaderOpaqueStop = 0.72f
@Composable
private fun ChatFloatingHeader(
headerHeight: Dp,
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
viewModel: ChatViewModel,
colorScheme: ColorScheme,
onSidebarToggle: () -> Unit,
onShowAppInfo: () -> Unit,
onPanicClear: () -> Unit,
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
val palette = LocalBitchatPalette.current
Box(
modifier = Modifier
.fillMaxWidth()
.zIndex(1f)
// Fully opaque where it meets the system status bar, fading to translucent at its
// lower edge. The status bar itself is transparent, so anything less than opaque at
// the top would let the wallpaper or a light system-bar scrim bleed through and the
// header would stop reading as part of the app.
.background(
Brush.verticalGradient(
0f to colorScheme.background,
HeaderOpaqueStop to colorScheme.background,
1f to colorScheme.background.copy(alpha = BarBackgroundAlpha)
)
)
.windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area
) {
// A plain Row rather than M3's TopAppBar. TopAppBar silently injects a 4.dp horizontal
// pad plus a 12.dp title inset and applies its own minimum heights, which made the
// header's spacing impossible to specify exactly.
Row(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.padding(start = 12.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ChatHeaderContent(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
viewModel = viewModel,
onBackClick = {
when {
selectedPrivatePeer != null -> viewModel.endPrivateChat()
currentChannel != null -> viewModel.switchToChannel(null)
}
},
onSidebarClick = onSidebarToggle,
onTripleClick = onPanicClear,
onShowAppInfo = onShowAppInfo,
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = {
// Ensure location is loaded before showing sheet
locationManager.refreshChannels()
onLocationNotesClick()
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatDialogs(
showPasswordDialog: Boolean,
passwordPromptChannel: String?,
passwordInput: String,
onPasswordChange: (String) -> Unit,
onPasswordConfirm: () -> Unit,
onPasswordDismiss: () -> Unit,
showAppInfo: Boolean,
onAppInfoDismiss: () -> Unit,
showLocationChannelsSheet: Boolean,
onLocationChannelsSheetDismiss: () -> Unit,
showLocationNotesSheet: Boolean,
onLocationNotesSheetDismiss: () -> Unit,
showUserSheet: Boolean,
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
selectedMessageForSheet: BitchatMessage?,
viewModel: ChatViewModel,
showVerificationSheet: Boolean,
onVerificationSheetDismiss: () -> Unit,
showSecurityVerificationSheet: Boolean,
onSecurityVerificationSheetDismiss: () -> Unit,
showMeshPeerListSheet: Boolean,
onMeshPeerListDismiss: () -> Unit,
) {
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
// Password dialog
PasswordPromptDialog(
show = showPasswordDialog,
channelName = passwordPromptChannel,
passwordInput = passwordInput,
onPasswordChange = onPasswordChange,
onConfirm = onPasswordConfirm,
onDismiss = onPasswordDismiss
)
// About sheet
var showDebugSheet by remember { mutableStateOf(false) }
AboutSheet(
isPresented = showAppInfo,
onDismiss = onAppInfoDismiss,
onShowDebug = { showDebugSheet = true }
)
if (showDebugSheet) {
com.bitchat.android.ui.debug.DebugSettingsSheet(
isPresented = showDebugSheet,
onDismiss = { showDebugSheet = false },
meshService = viewModel.meshService
)
}
// Location channels sheet
if (showLocationChannelsSheet) {
LocationChannelsSheet(
isPresented = showLocationChannelsSheet,
onDismiss = onLocationChannelsSheetDismiss,
viewModel = viewModel
)
}
// Location notes sheet (extracted to separate presenter)
if (showLocationNotesSheet) {
LocationNotesSheetPresenter(
viewModel = viewModel,
onDismiss = onLocationNotesSheetDismiss
)
}
// User action sheet
if (showUserSheet) {
ChatUserSheet(
isPresented = showUserSheet,
onDismiss = onUserSheetDismiss,
targetNickname = selectedUserForSheet,
selectedMessage = selectedMessageForSheet,
viewModel = viewModel
)
}
// MeshPeerList sheet (network view)
if (showMeshPeerListSheet){
MeshPeerListSheet(
isPresented = showMeshPeerListSheet,
viewModel = viewModel,
onDismiss = onMeshPeerListDismiss,
onShowVerification = {
onMeshPeerListDismiss()
viewModel.showVerificationSheet(fromSidebar = true)
}
)
}
if (showVerificationSheet) {
VerificationSheet(
isPresented = showVerificationSheet,
onDismiss = onVerificationSheetDismiss,
viewModel = viewModel
)
}
if (showSecurityVerificationSheet) {
SecurityVerificationSheet(
isPresented = showSecurityVerificationSheet,
onDismiss = onSecurityVerificationSheetDismiss,
viewModel = viewModel
)
}
if (privateChatSheetPeer != null) {
PrivateChatSheet(
isPresented = true,
peerID = privateChatSheetPeer!!,
viewModel = viewModel,
onDismiss = {
viewModel.hidePrivateChatSheet()
viewModel.endPrivateChat()
}
)
}
}