feat(ui): refactor chat screen architecture with new MainScaffold component

- Extract MainScaffold as new top-level container component for improved UI organization
- Add TimeFormat.kt module with TimeFormatPreferenceManager for 12/24-hour and seconds display preferences
- Initialize TimeFormatPreferenceManager in BitchatApplication startup sequence
- Simplify notification handling by consolidating showMeshPeerList() and showPrivateChatSheet() into openPrivateChat()
- Optimize QR code generation performance by replacing per-pixel setPixel calls with batch setPixels operation
- Update QrCodeGenerator.generateQrBitmap() from private to public API for broader accessibility
- Refactor state management methods: rename getPrivateChatSheetPeerValue() to getOpenPrivateChatPeerValue() and setPrivateChatSheetPeer() to setOpenPrivateChatPeer()
- Remove unused androidx.core.graphics.set import from QrCodeGenerator
- Improve documentation and code clarity across UI components and services
- Update test coverage in ChatUIUtilsTest to reflect API changes
This commit is contained in:
SammyZi 2026-09-10 20:57:36 +05:30
parent 945c9e025b
commit 5af9c248d0
24 changed files with 2095 additions and 1654 deletions

View File

@ -51,6 +51,9 @@ class BitchatApplication : Application() {
// Initialize chat UI mode (matrix transcript vs bubbles)
com.bitchat.android.ui.theme.ChatUiModeManager.init(this)
// Initialize transcript clock settings (12/24-hour, seconds)
com.bitchat.android.ui.theme.TimeFormatPreferenceManager.init(this)
// Initialize debug preference manager (persists debug toggles)
try { com.bitchat.android.ui.debug.DebugPreferenceManager.init(this) } catch (_: Exception) { }

View File

@ -39,7 +39,7 @@ import com.bitchat.android.onboarding.OnboardingCoordinator
import com.bitchat.android.onboarding.OnboardingState
import com.bitchat.android.onboarding.PermissionExplanationScreen
import com.bitchat.android.onboarding.PermissionManager
import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.MainScaffold
import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.OrientationAwareActivity
import com.bitchat.android.ui.theme.BitchatTheme
@ -330,7 +330,7 @@ class MainActivity : OrientationAwareActivity() {
// Add the callback - this will be automatically removed when the activity is destroyed
onBackPressedDispatcher.addCallback(this, backCallback)
ChatScreen(viewModel = chatViewModel)
MainScaffold(viewModel = chatViewModel)
}
OnboardingState.ERROR -> {
@ -810,9 +810,8 @@ class MainActivity : OrientationAwareActivity() {
if (peerID != null) {
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
// Open the private chat sheet with this peer
chatViewModel.showMeshPeerList()
chatViewModel.showPrivateChatSheet(peerID)
// Open the conversation; the shell switches to the Chats tab behind it
chatViewModel.openPrivateChat(peerID)
// Clear notifications for this sender since user is now viewing the chat
chatViewModel.clearNotificationsForSender(peerID)

View File

@ -3,7 +3,6 @@ package com.bitchat.android.hotspot
import android.graphics.Bitmap
import android.util.Log
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
@ -69,7 +68,7 @@ object QrCodeGenerator {
* @param sizePx Size of the QR code in pixels
* @return Bitmap of the QR code, or null on error
*/
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
if (data.isBlank() || sizePx <= 0) {
Log.w(TAG, "Invalid data or size: data.length=${data.length}, sizePx=$sizePx")
return null
@ -91,24 +90,28 @@ object QrCodeGenerator {
/**
* Convert BitMatrix to Bitmap.
* Pattern from VerificationSheet.kt.
*
* Fills an IntArray and hands it over in a single setPixels call. Bitmap.setPixel crosses into
* native code once per pixel, so the obvious nested loop spends seconds on a full-screen QR
* before the sheet can draw its first frame.
*/
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bitmap = createBitmap(width, height)
val black = android.graphics.Color.BLACK
val white = android.graphics.Color.WHITE
val pixels = IntArray(width * height)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap[x, y] = if (matrix[x, y]) {
android.graphics.Color.BLACK
} else {
android.graphics.Color.WHITE
}
for (y in 0 until height) {
val row = y * width
for (x in 0 until width) {
pixels[row + x] = if (matrix[x, y]) black else white
}
}
return bitmap
return createBitmap(width, height).apply {
setPixels(pixels, 0, width, 0, 0, width, height)
}
}
/**

View File

@ -81,9 +81,9 @@ object ConversationAliasResolver {
}
// Switch sheet peer if currently viewing an alias that got merged
val sheetPeer = state.getPrivateChatSheetPeerValue()
val sheetPeer = state.getOpenPrivateChatPeerValue()
if (sheetPeer != null && mergeKeys.contains(sheetPeer)) {
state.setPrivateChatSheetPeer(targetConversationID)
state.setOpenPrivateChatPeer(targetConversationID)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -59,7 +59,7 @@ import com.bitchat.android.ui.theme.BitchatMotion
* - ChatUIUtils: Utility functions for formatting and colors
*/
@Composable
fun ChatScreen(viewModel: ChatViewModel) {
fun ChatScreen(viewModel: ChatViewModel, onOpenPeopleTab: () -> Unit = {}) {
val colorScheme = MaterialTheme.colorScheme
val messages by viewModel.messages.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
@ -77,17 +77,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
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) }
@ -107,13 +99,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
)
}
// 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()
@ -136,8 +122,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
val isMeshTimeline =
currentChannel == null &&
selectedLocationChannel is ChannelID.Mesh &&
selectedPrivatePeer == null &&
privateChatSheetPeer == null
selectedPrivatePeer == null
val processLifecycleOwner = remember { ProcessLifecycleOwner.get() }
DisposableEffect(processLifecycleOwner, nearbyNotesController) {
@ -166,9 +151,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
LaunchedEffect(isMeshTimeline, privateChatSheetPeer, selectedPrivatePeer) {
LaunchedEffect(isMeshTimeline, selectedPrivatePeer) {
when {
privateChatSheetPeer != null -> liveVoiceManager.showDirectMessage(privateChatSheetPeer!!)
selectedPrivatePeer != null -> liveVoiceManager.showDirectMessage(selectedPrivatePeer!!)
isMeshTimeline -> liveVoiceManager.showPublicMesh()
else -> liveVoiceManager.clearVisibleConversation()
@ -455,7 +439,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
nickname = nickname,
viewModel = viewModel,
colorScheme = colorScheme,
onSidebarToggle = { viewModel.showMeshPeerList() },
onSidebarToggle = onOpenPeopleTab,
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true },
@ -515,25 +499,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
// 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 },
onLocationNotesFromChannelsClick = {
@ -550,12 +515,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
selectedUserForSheet = selectedUserForSheet,
selectedMessageForSheet = selectedMessageForSheet,
viewModel = viewModel,
showVerificationSheet = showVerificationSheet,
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
showSecurityVerificationSheet = showSecurityVerificationSheet,
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet,
showMeshPeerListSheet = showMeshPeerListSheet,
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
)
legacyPrivateMediaConsent?.let { request ->
@ -814,14 +773,6 @@ private fun ChatFloatingHeader(
@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,
onLocationNotesFromChannelsClick: () -> Unit,
@ -832,40 +783,7 @@ private fun ChatDialogs(
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(
@ -893,45 +811,4 @@ private fun ChatDialogs(
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()
}
)
}
}
}}

View File

@ -123,11 +123,8 @@ class ChatState(
private val _showAppInfo = MutableStateFlow<Boolean>(false)
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
private val _showMeshPeerList = MutableStateFlow(false)
val showMeshPeerList: StateFlow<Boolean> = _showMeshPeerList.asStateFlow()
private val _privateChatSheetPeer = MutableStateFlow<String?>(null)
val privateChatSheetPeer: StateFlow<String?> = _privateChatSheetPeer.asStateFlow()
private val _openPrivateChatPeer = MutableStateFlow<String?>(null)
val openPrivateChatPeer: StateFlow<String?> = _openPrivateChatPeer.asStateFlow()
private val _showVerificationSheet = MutableStateFlow(false)
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
@ -195,8 +192,7 @@ class ChatState(
fun getShowAppInfoValue() = _showAppInfo.value
fun getGeohashPeopleValue() = _geohashPeople.value
fun getShowMeshPeerListValue() = _showMeshPeerList.value
fun getPrivateChatSheetPeerValue() = _privateChatSheetPeer.value
fun getOpenPrivateChatPeerValue() = _openPrivateChatPeer.value
fun getTeleportedGeoValue() = _teleportedGeo.value
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
@ -349,11 +345,7 @@ class ChatState(
_geohashParticipantCounts.value = counts
}
fun setShowMeshPeerList(show: Boolean) {
_showMeshPeerList.value = show
}
fun setPrivateChatSheetPeer(peerID: String?) {
_privateChatSheetPeer.value = peerID
fun setOpenPrivateChatPeer(peerID: String?) {
_openPrivateChatPeer.value = peerID
}
}

View File

@ -1,5 +1,7 @@
package com.bitchat.android.ui
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
@ -10,6 +12,7 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatPalette
import com.bitchat.android.ui.theme.ChatVisualTokens
import com.bitchat.android.ui.theme.TimeFormatPreference
import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.*
@ -22,9 +25,43 @@ import java.util.*
/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */
internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha
/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */
/**
* Compact transcript timestamp; seconds add noise without helping conversation scanning.
*
* Only a fixed fallback for the `timeFormatter` default arguments below, which the tests rely on.
* Rendering code must take its formatter from [chatTimeFormatter] instead - this pattern is always
* 24-hour and ignores the device setting.
*/
internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm"
/**
* Transcript time formatter honouring the clock settings.
*
* The fixed "HH:mm" pattern always rendered 24-hour, so a phone set to 12-hour showed a message
* sent at 1:42 pm as `13:42` while its own status bar said `1:45`. getBestDateTimePattern also
* settles the ordering and separator per locale, which a hand-written "h:mm a" would not.
*/
internal fun chatTimeFormatter(
context: Context,
preference: TimeFormatPreference = TimeFormatPreference.System,
showSeconds: Boolean = false
): SimpleDateFormat {
val is24Hour = when (preference) {
TimeFormatPreference.System -> DateFormat.is24HourFormat(context)
TimeFormatPreference.TwelveHour -> false
TimeFormatPreference.TwentyFourHour -> true
}
val skeleton = buildString {
append(if (is24Hour) 'H' else 'h')
append('m')
if (showSeconds) append('s')
}
return SimpleDateFormat(
DateFormat.getBestDateTimePattern(Locale.getDefault(), skeleton),
Locale.getDefault()
)
}
/** Background opacity for a mention chip referring to somebody else. */
internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha

View File

@ -112,7 +112,7 @@ fun ChatUserSheet(
// Mesh chat
val peerID = selectedMessage?.senderPeerID ?: viewModel.getPeerIDForNickname(targetNickname)
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
viewModel.openPrivateChat(peerID)
}
}
onDismiss()

View File

@ -399,8 +399,7 @@ class ChatViewModel(
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val showMeshPeerList: StateFlow<Boolean> = state.showMeshPeerList
val privateChatSheetPeer: StateFlow<String?> = state.privateChatSheetPeer
val openPrivateChatPeer: StateFlow<String?> = state.openPrivateChatPeer
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
@ -742,7 +741,7 @@ class ChatViewModel(
// Clear mesh mention notifications since user is now back in mesh chat
clearMeshMentionNotifications()
// Ensure sheet is hidden
hidePrivateChatSheet()
closePrivateChat()
}
internal suspend fun deletePrivateConversation(
@ -786,13 +785,13 @@ class ChatViewModel(
privateChatManager.endPrivateChat()
setCurrentPrivateChatPeer(null)
}
val sheetPeer = state.getPrivateChatSheetPeerValue()
val sheetPeer = state.getOpenPrivateChatPeerValue()
if (
sheetPeer != null &&
ContactDirectory.canonicalConversationId(sheetPeer)
.equals(canonicalID, ignoreCase = true)
) {
hidePrivateChatSheet()
closePrivateChat()
}
clearNotificationsForSender(canonicalID)
notificationManager.removeConversationShortcut(canonicalID)
@ -917,7 +916,7 @@ class ChatViewModel(
canonical ?: targetKey
}
showPrivateChatSheet(openPeer)
openPrivateChat(openPeer)
} catch (e: Exception) {
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
}
@ -989,8 +988,8 @@ class ChatViewModel(
if (canonical != state.getSelectedPrivateChatPeerValue()) {
privateChatManager.startPrivateChat(canonical, mesh)
// If we're in the private chat sheet, update its active peer too
if (state.getPrivateChatSheetPeerValue() != null) {
showPrivateChatSheet(canonical)
if (state.getOpenPrivateChatPeerValue() != null) {
openPrivateChat(canonical)
}
}
}
@ -1303,21 +1302,12 @@ class ChatViewModel(
notificationManager.clearMeshMentionNotifications()
}
private var reopenSidebarAfterVerification = false
fun showVerificationSheet(fromSidebar: Boolean = false) {
if (fromSidebar) {
reopenSidebarAfterVerification = true
}
fun showVerificationSheet() {
state.setShowVerificationSheet(true)
}
fun hideVerificationSheet() {
state.setShowVerificationSheet(false)
if (reopenSidebarAfterVerification) {
reopenSidebarAfterVerification = false
state.setShowMeshPeerList(true)
}
}
fun showSecurityVerificationSheet() {
@ -1328,21 +1318,13 @@ class ChatViewModel(
state.setShowSecurityVerificationSheet(false)
}
fun showMeshPeerList() {
state.setShowMeshPeerList(true)
}
fun hideMeshPeerList() {
state.setShowMeshPeerList(false)
}
fun showPrivateChatSheet(peerID: String) {
fun openPrivateChat(peerID: String) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
state.setPrivateChatSheetPeer(conversationID)
state.setOpenPrivateChatPeer(conversationID)
}
fun hidePrivateChatSheet() {
state.setPrivateChatSheetPeer(null)
fun closePrivateChat() {
state.setOpenPrivateChatPeer(null)
}
fun getPeerFingerprintForDisplay(peerID: String): String? {
@ -1648,19 +1630,19 @@ class ChatViewModel(
*/
fun startGeohashDM(pubkeyHex: String) {
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
showPrivateChatSheet(convKey)
openPrivateChat(convKey)
}
}
fun startGeohashDMByNickname(nickname: String) {
geohashViewModel.startGeohashDMByNickname(nickname) { convKey ->
showPrivateChatSheet(convKey)
openPrivateChat(convKey)
}
}
fun startGeohashDMByShortId(shortId: String) {
geohashViewModel.startGeohashDMByShortId(shortId) { convKey ->
showPrivateChatSheet(convKey)
openPrivateChat(convKey)
}
}
@ -1685,6 +1667,11 @@ class ChatViewModel(
state.setShowAppInfo(false)
}
fun dismissPasswordPrompt() {
state.setShowPasswordPrompt(false)
state.setPasswordPromptChannel(null)
}
/**
* Handle Android back navigation
* Returns true if the back press was handled, false if it should be passed to the system
@ -1703,7 +1690,7 @@ class ChatViewModel(
true
}
// Exit private chat
state.getSelectedPrivateChatPeerValue() != null || state.getPrivateChatSheetPeerValue() != null -> {
state.getSelectedPrivateChatPeerValue() != null || state.getOpenPrivateChatPeerValue() != null -> {
endPrivateChat()
true
}

View File

@ -23,7 +23,7 @@ import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
/**
* Location Notes button for MainHeader.
* Location Notes button for MainHeader - the entry point for reading and writing notes here.
* Mesh-only with location authorized. Tor health tints the glyph with muted colours
* and a slow glow while connecting (via [rememberTorConnectionVisual]).
*/
@ -44,12 +44,10 @@ fun LocationNotesButton(
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
val locationEnabled = locationPermissionGranted && locationServicesEnabled
val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.collectAsStateWithLifecycle()
val notesCount = notes.size
// Keep the header quiet until there is at least one nearby note worth opening.
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled && notesCount > 0) {
// Shown whenever notes are usable here, not only once notes exist. Gating on a non-empty list
// kept the header tidy but left writing the first note with no affordance at all - the only
// way in was a row below the fold of the channels sheet.
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
val contentDescription = stringResource(R.string.cd_location_notes)
val torVisual = rememberTorConnectionVisual(normal = colorScheme.primary)

View File

@ -0,0 +1,366 @@
package com.bitchat.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCode
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.ui.theme.BitchatFontFamily
/**
* The app's four destinations, in bar order.
*
* `Public` is the mesh/geohash timeline the app used to be entirely made of; the other three were
* modal bottom sheets before this became a tabbed app.
*/
enum class AppTab(
val labelRes: Int,
val iconRes: Int? = null,
val iconVector: ImageVector? = null
) {
Public(R.string.tab_public, iconRes = R.drawable.ic_spec_globe),
Chats(R.string.tab_chats, iconRes = R.drawable.ic_spec_envelope),
People(R.string.people, iconRes = R.drawable.ic_spec_people),
Settings(R.string.about_tab_settings, iconVector = Icons.Outlined.Settings)
}
/**
* Tab shell hosting the whole app.
*
* A `when` over a saveable enum rather than a NavHost: four fixed destinations sharing one
* ChatViewModel need no route DSL, no argument encoding and no graph. The one nested destination -
* an open conversation - is a nullable peer id held by the Chats tab.
*/
@Composable
fun MainScaffold(viewModel: ChatViewModel, modifier: Modifier = Modifier) {
// Public leads the bar and is the default: a first-run user has no conversations, and the mesh
// timeline is the thing the app exists for.
var tab by rememberSaveable { mutableStateOf(AppTab.Public) }
// Which conversation is open is the ViewModel's business, not the shell's: notification taps,
// deep links and alias re-resolution all set it from outside compose. The shell only reads it.
val openConversation by viewModel.openPrivateChatPeer.collectAsStateWithLifecycle()
val openConversationFor: (String) -> Unit = { peerID ->
viewModel.openPrivateChat(peerID)
tab = AppTab.Chats
}
// Closing a conversation is already handled by the activity's back callback via
// ChatViewModel.handleBackPressed, so this only covers the remaining case: returning to the
// default tab before back is allowed to leave the app.
BackHandler(enabled = openConversation == null && tab != AppTab.Public) {
tab = AppTab.Public
}
val inConversation = openConversation != null
Scaffold(
modifier = modifier.fillMaxSize(),
// The screens below draw their own status-bar treatment (the chat header fades into it),
// so the Scaffold must not pad the top. The bottom bar still reports its own height.
contentWindowInsets = WindowInsets(0),
bottomBar = {
AnimatedVisibility(
visible = !inConversation,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut()
) {
BitchatNavigationBar(
viewModel = viewModel,
selected = tab,
onSelect = { tab = it }
)
}
}
) { innerPadding ->
// consumeWindowInsets is what keeps the bar from being counted twice: without it the
// screens' own navigationBars/ime padding would stack on top of the space the Scaffold
// already reserved for the bar.
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
) {
val peer = openConversation
if (peer != null) {
PrivateChatContent(
peerID = peer,
viewModel = viewModel,
onBack = viewModel::endPrivateChat
)
} else {
when (tab) {
AppTab.Chats -> TabScreen(title = stringResource(R.string.conversations)) {
ChatsListContent(
viewModel = viewModel,
onOpenConversation = openConversationFor,
contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)
)
}
AppTab.Public -> ChatScreen(
viewModel = viewModel,
onOpenPeopleTab = { tab = AppTab.People }
)
AppTab.People -> TabScreen(
title = stringResource(R.string.your_network),
actions = {
IconButton(
onClick = { viewModel.showVerificationSheet() },
modifier = Modifier.size(44.dp)
) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = stringResource(R.string.verify_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp)
)
}
}
) {
PeopleListContent(
viewModel = viewModel,
onOpenConversation = openConversationFor,
contentPadding = PaddingValues(top = 8.dp, bottom = 24.dp)
)
}
AppTab.Settings -> Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.windowInsetsPadding(WindowInsets.statusBars)
) {
AboutContent(
initialTab = AboutTab.Settings,
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp)
)
}
}
}
}
AppDialogs(viewModel)
}
}
/**
* Surfaces that can be opened from more than one tab, hosted by the shell rather than by a tab.
*
* These used to live inside ChatScreen. Once ChatScreen became one tab among four, anything
* declared there only existed while that tab was on screen - so tapping the QR button in People
* flipped the ViewModel flag but showed nothing until you happened to navigate to Public, where
* the sheet was waiting.
*/
@Composable
private fun AppDialogs(viewModel: ChatViewModel) {
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
val showVerification by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerification by
viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
val showPasswordPrompt by viewModel.showPasswordPrompt.collectAsStateWithLifecycle()
val passwordPromptChannel by viewModel.passwordPromptChannel.collectAsStateWithLifecycle()
var passwordInput by remember { mutableStateOf("") }
var showDebugSheet by remember { mutableStateOf(false) }
PasswordPromptDialog(
show = showPasswordPrompt,
channelName = passwordPromptChannel,
passwordInput = passwordInput,
onPasswordChange = { passwordInput = it },
onConfirm = {
val channel = passwordPromptChannel
if (passwordInput.isNotEmpty() && channel != null) {
if (viewModel.joinChannel(channel, passwordInput)) passwordInput = ""
}
},
onDismiss = {
passwordInput = ""
viewModel.dismissPasswordPrompt()
}
)
AboutSheet(
isPresented = showAppInfo,
onDismiss = viewModel::hideAppInfo,
onShowDebug = { showDebugSheet = true }
)
if (showDebugSheet) {
com.bitchat.android.ui.debug.DebugSettingsSheet(
isPresented = true,
onDismiss = { showDebugSheet = false },
meshService = viewModel.meshService
)
}
if (showVerification) {
VerificationSheet(
isPresented = true,
onDismiss = viewModel::hideVerificationSheet,
viewModel = viewModel
)
}
if (showSecurityVerification) {
SecurityVerificationSheet(
isPresented = true,
onDismiss = viewModel::hideSecurityVerificationSheet,
viewModel = viewModel
)
}
}
/**
* Chrome for the list tabs: a title bar built from the same tokens as the chat header, so switching
* tabs does not shift the bar's height, insets or type.
*/
@Composable
private fun TabScreen(
title: String,
modifier: Modifier = Modifier,
actions: @Composable () -> Unit = {},
content: @Composable () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.fillMaxSize()
.background(colorScheme.background)
.windowInsetsPadding(WindowInsets.statusBars)
) {
androidx.compose.foundation.layout.Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(ChatHeaderHeight)
.padding(start = 16.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
color = colorScheme.primary
)
actions()
}
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
Box(modifier = Modifier.fillMaxSize()) { content() }
}
}
}
@Composable
private fun BitchatNavigationBar(
viewModel: ChatViewModel,
selected: AppTab,
onSelect: (AppTab) -> Unit
) {
val hasUnreadPrivate by viewModel.hasUnreadPrivateMessages.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.hasUnreadChannels.collectAsStateWithLifecycle()
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val peerCount = connectedPeers.count { it != viewModel.myPeerID }
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp
) {
AppTab.entries.forEach { entry ->
val label = stringResource(entry.labelRes)
NavigationBarItem(
selected = entry == selected,
onClick = { onSelect(entry) },
icon = {
BadgedBox(
badge = {
when (entry) {
AppTab.Chats -> if (hasUnreadPrivate) Badge()
AppTab.Public -> if (hasUnreadChannels) Badge()
AppTab.People -> if (peerCount > 0) {
Badge { Text(peerCount.toString()) }
}
AppTab.Settings -> Unit
}
}
) {
if (entry.iconVector != null) {
Icon(
imageVector = entry.iconVector,
contentDescription = label,
modifier = Modifier.size(22.dp)
)
} else {
Icon(
painter = painterResource(entry.iconRes!!),
contentDescription = label,
modifier = Modifier.size(22.dp)
)
}
}
},
label = {
Text(
text = label,
fontFamily = BitchatFontFamily,
style = MaterialTheme.typography.labelSmall
)
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.surfaceVariant
)
)
}
}
}

View File

@ -130,10 +130,10 @@ class MeshDelegateHandler(
}
}
state.getPrivateChatSheetPeerValue()?.let { sheetPeer ->
state.getOpenPrivateChatPeerValue()?.let { sheetPeer ->
val canonical = ContactDirectory.canonicalConversationId(sheetPeer)
if (canonical != sheetPeer) {
state.setPrivateChatSheetPeer(canonical)
state.setOpenPrivateChatPeer(canonical)
}
}

View File

@ -13,7 +13,6 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@ -25,7 +24,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
@ -52,11 +50,6 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.favorites.FavoriteRelationship
import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.ChannelID
@ -80,54 +73,42 @@ import kotlinx.coroutines.delay
* Extracted from ChatScreen.kt for better organization
*/
@OptIn(ExperimentalMaterial3Api::class)
/**
* Conversations tab body: private conversations, then joined channels.
*
* Lifted verbatim out of the old MeshPeerListSheet so the same list can be a first-class tab
* destination instead of a modal. The rows, swipe actions and delete flow are unchanged - only the
* container and the "open a conversation" callback differ.
*/
@Composable
fun MeshPeerListSheet(
isPresented: Boolean,
fun ChatsListContent(
viewModel: ChatViewModel,
onDismiss: () -> Unit,
onShowVerification: () -> Unit,
modifier: Modifier = Modifier
onOpenConversation: (String) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp)
) {
val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val conversations by viewModel.conversations.collectAsStateWithLifecycle()
val conversationStoreState by viewModel.conversationStoreState.collectAsStateWithLifecycle()
val peerDirect by viewModel.peerDirect.collectAsStateWithLifecycle()
val geohashPeopleCount = geohashPeople.size
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers
.collectAsStateWithLifecycle()
val directPeerIdentityIDs = remember(peerDirect) {
peerDirect
.asSequence()
.filter { (_, isDirect) -> isDirect }
.mapTo(mutableSetOf()) { (peerID, _) -> peerID.lowercase() }
}
val wifiAwareIdentityIDs = remember(wifiAwarePeerIDs) {
wifiAwarePeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
}
val conversationIdentityAliases = remember(conversations) {
conversations
.flatMapTo(mutableSetOf()) { it.identityAliases }
}
val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
val aliases = runCatching {
ContactDirectory.aliasesForConversation(peerID)
}.getOrDefault(setOf(peerID))
aliases.any { it.lowercase() in conversationIdentityAliases }
}
var pendingConversationDelete by remember {
mutableStateOf<ConversationSummary?>(null)
val wifiAwareIdentityIDs = remember(wifiAwareConnected) {
wifiAwareConnected.keys.mapTo(mutableSetOf()) { it.lowercase() }
}
var pendingConversationDelete by remember { mutableStateOf<ConversationSummary?>(null) }
var conversationQuery by rememberSaveable { mutableStateOf("") }
val filteredConversations = remember(conversations, conversationQuery) {
val query = conversationQuery.trim()
@ -143,423 +124,347 @@ fun MeshPeerListSheet(
val offlineConversations = remember(filteredConversations) {
filteredConversations.filterNot(ConversationSummary::isConnected)
}
val sheetScope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
// Scroll state for animated top bar
val listState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
}
}
val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.95f else 0f,
label = "topBarAlpha"
)
if (isPresented) {
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
sheetState = sheetState,
Box(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = contentPadding
) {
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp)
) {
val peopleCount = when (selectedLocationChannel) {
is ChannelID.Location -> geohashPeopleCount
else -> visibleConnectedPeers.count { it != viewModel.myPeerID }
}
if (conversations.size >= CONVERSATION_SEARCH_THRESHOLD) {
item(key = "private_conversations_search") {
OutlinedTextField(
value = conversationQuery,
onValueChange = { conversationQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = BitchatFontFamily
),
placeholder = {
Text(
stringResource(R.string.search_conversations),
fontFamily = BitchatFontFamily
)
},
leadingIcon = {
Icon(Icons.Outlined.Search, contentDescription = null)
},
trailingIcon = if (conversationQuery.isNotEmpty()) {
{
IconButton(onClick = { conversationQuery = "" }) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.clear)
)
}
}
} else {
null
},
shape = MaterialTheme.shapes.medium
)
}
}
item(key = "private_conversations_header") {
SheetIconSectionHeader(
iconRes = R.drawable.ic_spec_envelope,
title = stringResource(R.string.conversations),
modifier = Modifier.padding(top = 8.dp)
when {
conversationStoreState is
com.bitchat.android.services.ConversationStoreState.Loading &&
conversations.isEmpty() -> {
item(key = "private_conversations_loading") {
ConversationSectionStatus(
icon = { CircularProgressIndicator(Modifier.size(20.dp)) },
text = stringResource(R.string.loading_conversations)
)
}
}
if (conversations.size >= CONVERSATION_SEARCH_THRESHOLD) {
item(key = "private_conversations_search") {
OutlinedTextField(
value = conversationQuery,
onValueChange = { conversationQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = BitchatFontFamily
),
placeholder = {
Text(
stringResource(R.string.search_conversations),
fontFamily = BitchatFontFamily
)
},
leadingIcon = {
Icon(Icons.Outlined.Search, contentDescription = null)
},
trailingIcon = if (conversationQuery.isNotEmpty()) {
{
IconButton(onClick = { conversationQuery = "" }) {
Icon(
Icons.Outlined.Close,
contentDescription = stringResource(R.string.clear)
)
}
}
} else {
null
},
shape = RoundedCornerShape(14.dp)
)
}
}
when {
conversationStoreState is
com.bitchat.android.services.ConversationStoreState.Loading &&
conversations.isEmpty() -> {
item(key = "private_conversations_loading") {
ConversationSectionStatus(
icon = { CircularProgressIndicator(Modifier.size(20.dp)) },
text = stringResource(R.string.loading_conversations)
conversationStoreState is
com.bitchat.android.services.ConversationStoreState.Error &&
conversations.isEmpty() -> {
item(key = "private_conversations_error") {
ConversationSectionStatus(
icon = {
Icon(
Icons.Outlined.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
}
conversationStoreState is
com.bitchat.android.services.ConversationStoreState.Error &&
conversations.isEmpty() -> {
item(key = "private_conversations_error") {
ConversationSectionStatus(
icon = {
Icon(
Icons.Outlined.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
text = stringResource(R.string.conversation_storage_error)
)
}
}
conversations.isEmpty() -> {
item(key = "private_conversations_empty") {
ConversationSectionStatus(
icon = {
Icon(
painterResource(R.drawable.ic_spec_envelope),
contentDescription = null
)
},
text = stringResource(R.string.no_conversations_yet)
)
}
}
filteredConversations.isEmpty() -> {
item(key = "private_conversations_no_results") {
ConversationSectionStatus(
icon = {
Icon(Icons.Outlined.SearchOff, contentDescription = null)
},
text = stringResource(R.string.no_conversation_results)
)
}
}
},
text = stringResource(R.string.conversation_storage_error)
)
}
}
if (onlineConversations.isNotEmpty()) {
item(key = "private_conversations_online_label") {
ConversationGroupLabel(
text = stringResource(R.string.online_conversations)
)
}
itemsIndexed(
items = onlineConversations,
key = { _, conversation ->
"conversation:${conversation.conversationID}"
}
) { index, conversation ->
ConversationSwipeItem(
conversation = conversation,
directPeerIdentityIDs = directPeerIdentityIDs,
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
viewModel = viewModel,
isFirst = index == 0,
isLast = index == onlineConversations.lastIndex,
onPrivateChatStart = { conversationID ->
viewModel.showPrivateChatSheet(conversationID)
onDismiss()
},
onDeleteRequested = { pendingConversationDelete = it },
onReadStateRequested = { item, isRead ->
sheetScope.launch {
viewModel.setConversationRead(item.conversationID, isRead)
}
},
modifier = Modifier.animateItem()
)
}
conversations.isEmpty() -> {
item(key = "private_conversations_empty") {
ConversationSectionStatus(
icon = {
Icon(
painterResource(R.drawable.ic_spec_envelope),
contentDescription = null
)
},
text = stringResource(R.string.no_conversations_yet)
)
}
}
if (offlineConversations.isNotEmpty()) {
item(key = "private_conversations_offline_label") {
ConversationGroupLabel(
text = stringResource(R.string.offline_conversations)
)
}
itemsIndexed(
items = offlineConversations,
key = { _, conversation ->
"conversation:${conversation.conversationID}"
}
) { index, conversation ->
ConversationSwipeItem(
conversation = conversation,
directPeerIdentityIDs = directPeerIdentityIDs,
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
viewModel = viewModel,
isFirst = index == 0,
isLast = index == offlineConversations.lastIndex,
onPrivateChatStart = { conversationID ->
viewModel.showPrivateChatSheet(conversationID)
onDismiss()
},
onDeleteRequested = { pendingConversationDelete = it },
onReadStateRequested = { item, isRead ->
sheetScope.launch {
viewModel.setConversationRead(item.conversationID, isRead)
}
},
modifier = Modifier.animateItem()
)
}
filteredConversations.isEmpty() -> {
item(key = "private_conversations_no_results") {
ConversationSectionStatus(
icon = { Icon(Icons.Outlined.SearchOff, contentDescription = null) },
text = stringResource(R.string.no_conversation_results)
)
}
}
}
// Channels section
if (joinedChannels.isNotEmpty()) {
item(key = "channels_section") {
if (onlineConversations.isNotEmpty()) {
item(key = "private_conversations_online_label") {
ConversationGroupLabel(text = stringResource(R.string.online_conversations))
}
itemsIndexed(
items = onlineConversations,
key = { _, conversation -> "conversation:" + conversation.conversationID }
) { index, conversation ->
ConversationSwipeItem(
conversation = conversation,
directPeerIdentityIDs = directPeerIdentityIDs,
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
viewModel = viewModel,
isFirst = index == 0,
isLast = index == onlineConversations.lastIndex,
onPrivateChatStart = onOpenConversation,
onDeleteRequested = { pendingConversationDelete = it },
onReadStateRequested = { item, isRead ->
scope.launch {
viewModel.setConversationRead(item.conversationID, isRead)
}
},
modifier = Modifier.animateItem()
)
}
}
if (offlineConversations.isNotEmpty()) {
item(key = "private_conversations_offline_label") {
ConversationGroupLabel(text = stringResource(R.string.offline_conversations))
}
itemsIndexed(
items = offlineConversations,
key = { _, conversation -> "conversation:" + conversation.conversationID }
) { index, conversation ->
ConversationSwipeItem(
conversation = conversation,
directPeerIdentityIDs = directPeerIdentityIDs,
wifiAwareIdentityIDs = wifiAwareIdentityIDs,
viewModel = viewModel,
isFirst = index == 0,
isLast = index == offlineConversations.lastIndex,
onPrivateChatStart = onOpenConversation,
onDeleteRequested = { pendingConversationDelete = it },
onReadStateRequested = { item, isRead ->
scope.launch {
viewModel.setConversationRead(item.conversationID, isRead)
}
},
modifier = Modifier.animateItem()
)
}
}
if (joinedChannels.isNotEmpty()) {
item(key = "channels_section") {
Column {
SheetIconSectionHeader(
iconRes = R.drawable.ic_spec_chat_bubbles,
title = stringResource(R.string.channels),
modifier = Modifier.padding(
top = if (conversations.isNotEmpty()) 20.dp else 8.dp
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = MaterialTheme.colorScheme.surface,
shape = AboutCardShape
) {
Column {
SheetIconSectionHeader(
iconRes = R.drawable.ic_spec_chat_bubbles,
title = stringResource(R.string.channels),
modifier = Modifier.padding(
top = if (conversations.isNotEmpty()) 20.dp else 8.dp
joinedChannels.toList().forEachIndexed { index, channel ->
if (index > 0) SheetCardDivider()
val unreadCount = unreadChannelMessages[channel] ?: 0
ChannelRow(
channel = channel,
isSelected = channel == currentChannel,
unreadCount = unreadCount,
colorScheme = colorScheme,
onChannelClick = {
if (channel.startsWith("@")) {
val peerName = channel.removePrefix("@")
val peerID = peerNicknames.entries
.firstOrNull { it.value == peerName }?.key
if (peerID != null) onOpenConversation(peerID)
} else {
viewModel.switchToChannel(channel)
}
},
onLeaveChannel = { viewModel.leaveChannel(channel) },
)
)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AboutHorizontalPadding)
.padding(top = 10.dp),
color = MaterialTheme.colorScheme.surface,
shape = AboutCardShape
) {
Column {
joinedChannels.toList().forEachIndexed { index, channel ->
if (index > 0) SheetCardDivider()
val isSelected = channel == currentChannel
val unreadCount = unreadChannelMessages[channel] ?: 0
ChannelRow(
channel = channel,
isSelected = isSelected,
unreadCount = unreadCount,
colorScheme = colorScheme,
onChannelClick = {
if (channel.startsWith("@")) {
val peerName = channel.removePrefix("@")
val peerID =
peerNicknames.entries.firstOrNull { it.value == peerName }?.key
if (peerID != null) {
viewModel.showPrivateChatSheet(peerID)
onDismiss()
}
} else {
viewModel.switchToChannel(channel)
onDismiss()
}
},
onLeaveChannel = {
viewModel.leaveChannel(channel)
},
)
}
}
}
}
}
}
// People / geohash participants
item(key = "people_section") {
when (selectedLocationChannel) {
is ChannelID.Location -> {
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = onDismiss,
excludedIdentityAliases = conversationIdentityAliases,
modifier = Modifier.padding(
top = if (
joinedChannels.isNotEmpty() ||
conversations.isNotEmpty()
) 20.dp else 8.dp
)
)
}
else -> {
PeopleSection(
modifier = Modifier.padding(
top = if (
joinedChannels.isNotEmpty() ||
conversations.isNotEmpty()
) 20.dp else 8.dp
),
connectedPeers = visibleConnectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
peopleCount = peopleCount,
excludedIdentityAliases = conversationIdentityAliases,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
onDismiss()
}
)
}
}
}
}
// TopBar (animated)
BitchatSheetTopBar(
title = {
BitchatSheetTitle(text = stringResource(id = R.string.your_network))
},
backgroundAlpha = topBarAlpha,
actions = {
if (selectedLocationChannel !is ChannelID.Location) {
IconButton(
onClick = onShowVerification,
modifier = Modifier.size(44.dp)
) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = stringResource(R.string.verify_title),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp)
)
}
}
},
onClose = onDismiss,
)
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
)
}
}
pendingConversationDelete?.let { conversation ->
val deleteFailedMessage = stringResource(R.string.conversation_delete_failed)
val deletedMessage = stringResource(
R.string.conversation_deleted,
conversation.displayName
)
val undoLabel = stringResource(R.string.undo)
val restoreFailedMessage =
stringResource(R.string.conversation_restore_failed)
AlertDialog(
onDismissRequest = { pendingConversationDelete = null },
icon = {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = null
)
},
title = {
Text(
text = stringResource(R.string.delete_conversation_title),
fontFamily = BitchatFontFamily
)
},
text = {
Text(
text = stringResource(
R.string.delete_conversation_message,
conversation.displayName
),
fontFamily = BitchatFontFamily
)
},
confirmButton = {
TextButton(
onClick = {
pendingConversationDelete = null
sheetScope.launch {
val deletion = viewModel.deletePrivateConversation(
conversation.conversationID
)
if (deletion == null) {
snackbarHostState.showSnackbar(
message = deleteFailedMessage
)
return@launch
}
val result = snackbarHostState.showSnackbar(
message = deletedMessage,
actionLabel = undoLabel,
withDismissAction = true,
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
if (!viewModel.restoreDeletedConversation(deletion)) {
snackbarHostState.showSnackbar(
restoreFailedMessage
)
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(16.dp)
)
}
pendingConversationDelete?.let { conversation ->
val deleteFailedMessage = stringResource(R.string.conversation_delete_failed)
val deletedMessage = stringResource(
R.string.conversation_deleted,
conversation.displayName
)
val undoLabel = stringResource(R.string.undo)
val restoreFailedMessage = stringResource(R.string.conversation_restore_failed)
AlertDialog(
onDismissRequest = { pendingConversationDelete = null },
icon = { Icon(imageVector = Icons.Outlined.Delete, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.delete_conversation_title),
fontFamily = BitchatFontFamily
)
},
text = {
Text(
text = stringResource(
R.string.delete_conversation_message,
conversation.displayName
),
fontFamily = BitchatFontFamily
)
},
confirmButton = {
TextButton(
onClick = {
pendingConversationDelete = null
scope.launch {
val deletion = viewModel.deletePrivateConversation(
conversation.conversationID
)
if (deletion == null) {
snackbarHostState.showSnackbar(message = deleteFailedMessage)
return@launch
}
val result = snackbarHostState.showSnackbar(
message = deletedMessage,
actionLabel = undoLabel,
withDismissAction = true,
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
if (!viewModel.restoreDeletedConversation(deletion)) {
snackbarHostState.showSnackbar(restoreFailedMessage)
}
}
}
) {
Text(
text = stringResource(R.string.delete),
color = MaterialTheme.colorScheme.error,
fontFamily = BitchatFontFamily
)
}
},
dismissButton = {
TextButton(onClick = { pendingConversationDelete = null }) {
Text(
text = stringResource(android.R.string.cancel),
fontFamily = BitchatFontFamily
)
}
) {
Text(
text = stringResource(R.string.delete),
color = MaterialTheme.colorScheme.error,
fontFamily = BitchatFontFamily
)
}
)
},
dismissButton = {
TextButton(onClick = { pendingConversationDelete = null }) {
Text(
text = stringResource(android.R.string.cancel),
fontFamily = BitchatFontFamily
)
}
}
)
}
}
/**
* People tab body: mesh peers, or geohash participants when a location channel is selected.
*
* Every connected peer is listed, including ones you already have a conversation with. The old
* combined sheet hid those, but only because the conversation list sat directly above the people
* list in the same scroll - the exclusion existed to stop one person appearing twice on one screen.
* Chats and People are separate tabs now, so that same filter just made anyone you had ever
* messaged disappear from People entirely.
*/
@Composable
fun PeopleListContent(
viewModel: ChatViewModel,
onOpenConversation: (String) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp)
) {
val colorScheme = MaterialTheme.colorScheme
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers
.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
val peopleCount = when (selectedLocationChannel) {
is ChannelID.Location -> geohashPeople.size
else -> connectedPeers.count { it != viewModel.myPeerID }
}
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = contentPadding
) {
item(key = "people_section") {
when (selectedLocationChannel) {
is ChannelID.Location -> {
GeohashPeopleList(
viewModel = viewModel,
onTapPerson = {}
)
}
else -> {
PeopleSection(
connectedPeers = connectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
peopleCount = peopleCount,
viewModel = viewModel,
onPrivateChatStart = onOpenConversation
)
}
}
}
}
}
@ -1180,13 +1085,18 @@ private fun ConversationRow(
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
val assignedColor = colorForPeer(peerIdentity, palette)
val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
val justNow = stringResource(R.string.time_now)
val relativeTime by produceState(
initialValue = conversationRelativeTime(conversation.latestMessageAt),
conversation.latestMessageAt
initialValue = relativeTimeLabel(conversation.latestMessageAt, justNow),
conversation.latestMessageAt,
justNow
) {
while (true) {
value = conversationRelativeTime(conversation.latestMessageAt)
delay(DateUtils.MINUTE_IN_MILLIS)
value = relativeTimeLabel(conversation.latestMessageAt, justNow)
// Re-tick every 15s, not every minute: a row that says "now" has to become "1 min.
// ago" reasonably close to the minute mark, and a full-minute delay lands anywhere
// inside it depending on when the row first composed.
delay(15_000L)
}
}
val unreadDescription = if (conversation.unreadCount > 0) {
@ -1448,13 +1358,23 @@ private fun ConversationRow(
}
}
private fun conversationRelativeTime(timestamp: Long): String =
DateUtils.getRelativeTimeSpanString(
timestamp,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
).toString()
/**
* Relative time for a conversation row.
*
* Anything under a minute is [justNow]: getRelativeTimeSpanString with a minute resolution renders
* a message that just arrived as "0 min. ago", which reads like a bug rather than a timestamp.
*/
private fun relativeTimeLabel(timestamp: Long, justNow: String): String =
if (System.currentTimeMillis() - timestamp < DateUtils.MINUTE_IN_MILLIS) {
justNow
} else {
DateUtils.getRelativeTimeSpanString(
timestamp,
System.currentTimeMillis(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
).toString()
}
@Composable
private fun PeerItem(
@ -1669,13 +1589,12 @@ private fun convertRSSIToSignalStrength(rssi: Int?): Int {
/**
* Nested Private Chat Sheet - iOS-style nested bottom sheet
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivateChatSheet(
isPresented: Boolean,
fun PrivateChatContent(
peerID: String,
viewModel: ChatViewModel,
onDismiss: () -> Unit
onBack: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
@ -1799,18 +1718,22 @@ fun PrivateChatSheet(
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
label = "favoriteStarTint"
)
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
if (isPresented) {
BitchatBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Box(modifier = Modifier.fillMaxSize()) {
// Full-screen destination rather than a sheet: the status-bar inset is applied here so the
// header sits directly under the system bar and everything below it keeps the sheet's layout.
Box(
modifier = modifier
.fillMaxSize()
.background(colorScheme.background)
.windowInsetsPadding(WindowInsets.statusBars)
) {
Column(
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxSize()
// Same treatment as the main chat screen: the composer rides above the
// keyboard while the header, aligned to the Box, stays put.
.windowInsetsPadding(
WindowInsets.ime.union(WindowInsets.navigationBars)
)
) {
Spacer(modifier = Modifier.height(ChatHeaderHeight))
@ -1967,11 +1890,8 @@ fun PrivateChatSheet(
}
}
val dismiss = LocalSheetDismiss.current
CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() })
CloseButton(onClick = onBack)
}
}
}
}
}
}

View File

@ -408,7 +408,14 @@ fun MessageItem(
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) }
val context = LocalContext.current
val timeFormat by com.bitchat.android.ui.theme.TimeFormatPreferenceManager.formatFlow
.collectAsState()
val showSeconds by com.bitchat.android.ui.theme.TimeFormatPreferenceManager.showSecondsFlow
.collectAsState()
val timeFormatter = remember(context, timeFormat, showSeconds) {
chatTimeFormatter(context, timeFormat, showSeconds)
}
Column(
modifier = modifier

View File

@ -1,12 +1,17 @@
package com.bitchat.android.ui
import android.content.pm.ActivityInfo
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Display
import androidx.appcompat.app.AppCompatActivity
import com.bitchat.android.utils.DeviceUtils
/**
* Base activity that automatically sets orientation based on device type.
* Base activity for the app's own screens: device-appropriate orientation, and the display's
* highest refresh rate.
*
* Tablets can rotate to landscape, phones are locked to portrait.
*/
abstract class OrientationAwareActivity : AppCompatActivity() {
@ -14,6 +19,58 @@ abstract class OrientationAwareActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setOrientationBasedOnDeviceType()
requestHighestRefreshRate()
}
/**
* Ask the compositor for the fastest mode this panel offers.
*
* Only modes at the resolution already in use are considered - a panel typically lists its
* high refresh rates at more than one resolution, and picking purely by refresh rate would
* silently drop the screen to a lower one.
*
* This is a request, not a guarantee: the system still overrides it for battery saver, for a
* user-set refresh-rate preference, and on panels that gate high rates by content.
*/
private fun requestHighestRefreshRate() {
val display: Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display
} else {
@Suppress("DEPRECATION")
windowManager.defaultDisplay
}
val current = display?.mode ?: return
val sameResolution = display.supportedModes.filter {
it.physicalWidth == current.physicalWidth &&
it.physicalHeight == current.physicalHeight
}
val fastest = sameResolution.maxByOrNull { it.refreshRate } ?: return
// No early return when the reported mode already looks fastest. Display.mode can name the
// panel's default mode while the compositor is actually driving it slower, and skipping
// the request in that case is exactly the situation this method exists to fix.
window.attributes = window.attributes.apply {
preferredDisplayModeId = fastest.modeId
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Second, weaker signal. Some vendors honour the float hint when they ignore a
// specific mode id.
preferredRefreshRate = fastest.refreshRate
}
}
// Logged in full because the request is frequently refused and the mode list is the only
// way to tell why: a panel whose high rates live at another resolution, a vendor
// refresh-rate setting, or a device that simply has one mode.
Log.i(
TAG,
"Requested ${fastest.refreshRate} Hz (mode ${fastest.modeId}); " +
"current ${current.refreshRate} Hz at " +
"${current.physicalWidth}x${current.physicalHeight}; " +
"modes=" + display.supportedModes.joinToString {
"${it.physicalWidth}x${it.physicalHeight}@${it.refreshRate}"
}
)
}
private fun setOrientationBasedOnDeviceType() {
@ -25,4 +82,8 @@ abstract class OrientationAwareActivity : AppCompatActivity() {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
private companion object {
const val TAG = "OrientationAwareActivity"
}
}

View File

@ -44,6 +44,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
@ -193,10 +194,10 @@ private fun buildStatusInfo(
else -> Icons.Outlined.NoEncryption
}
val tint = when {
isVerified -> Color(0xFF32D74B)
sessionState == "failed" -> Color(0xFFFF3B30)
sessionState == "handshaking" -> Color(0xFFFF9500)
sessionState == "established" -> Color(0xFF32D74B)
isVerified -> MaterialTheme.colorScheme.primary
sessionState == "failed" -> MaterialTheme.colorScheme.error
sessionState == "handshaking" -> LocalBitchatPalette.current.accentOrange
sessionState == "established" -> MaterialTheme.colorScheme.primary
else -> accent.copy(alpha = 0.6f)
}
return SecurityStatusInfo(text, icon, tint)
@ -273,9 +274,9 @@ private fun SecurityVerificationActions(
if (isVerified) {
VerificationStatusRow(
icon = Icons.Filled.Verified,
iconTint = Color(0xFF32D74B),
iconTint = MaterialTheme.colorScheme.primary,
text = stringResource(R.string.fingerprint_verified_label),
textTint = Color(0xFF32D74B)
textTint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(R.string.fingerprint_verified_message),
@ -289,8 +290,8 @@ private fun SecurityVerificationActions(
Button(
onClick = { fingerprint?.let(onUnverify) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFFF3B30),
contentColor = Color.White
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
),
modifier = Modifier.fillMaxWidth()
) {
@ -303,9 +304,9 @@ private fun SecurityVerificationActions(
} else {
VerificationStatusRow(
icon = Icons.Filled.Warning,
iconTint = Color(0xFFFF9500),
iconTint = LocalBitchatPalette.current.accentOrange,
text = stringResource(R.string.fingerprint_not_verified_label),
textTint = Color(0xFFFF9500)
textTint = LocalBitchatPalette.current.accentOrange
)
Text(
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
@ -320,8 +321,8 @@ private fun SecurityVerificationActions(
Button(
onClick = { onVerify(fingerprint) },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF34C759),
contentColor = Color.White
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
),
modifier = Modifier.fillMaxWidth()
) {
@ -425,7 +426,7 @@ private fun FingerprintBlock(
Text(
text = stringResource(R.string.fingerprint_pending),
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily),
color = Color(0xFFFF9500),
color = LocalBitchatPalette.current.accentOrange,
modifier = Modifier.padding(16.dp)
)
}

View File

@ -2,7 +2,7 @@ package com.bitchat.android.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.QrCodeScanner
import android.graphics.Bitmap
import com.bitchat.android.hotspot.QrCodeGenerator
import android.os.Handler
import android.os.Looper
import android.util.Log
@ -64,8 +64,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.ui.theme.BitchatFontFamily
@ -81,9 +79,6 @@ import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import com.google.zxing.BarcodeFormat
import com.google.zxing.common.BitMatrix
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@ -101,6 +96,7 @@ fun VerificationSheet(
val accent = MaterialTheme.colorScheme.primary
var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan
var scanStatus by remember { mutableStateOf<Int?>(null) }
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val npub = remember { viewModel.getCurrentNpub() }
@ -177,10 +173,20 @@ fun VerificationSheet(
)
1 -> ScanTabContent(
accent = accent,
statusRes = scanStatus,
// Every rejection path used to return silently, so a scan that could not
// work looked identical to one the camera never saw. Each outcome now says
// which of the three things went wrong.
onScan = { code ->
val qr = VerificationService.verifyScannedQR(code)
if (qr != null && viewModel.beginQRVerification(qr)) {
selectedTab = 0
scanStatus = when {
qr == null -> R.string.verify_scan_invalid
!viewModel.beginQRVerification(qr) ->
R.string.verify_scan_peer_not_connected
else -> {
selectedTab = 0
null
}
}
}
)
@ -318,6 +324,7 @@ private fun MyQrTabContent(
@Composable
private fun ScanTabContent(
accent: Color,
statusRes: Int?,
onScan: (String) -> Unit
) {
val permissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
@ -364,6 +371,20 @@ private fun ScanTabContent(
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
Text(
text = statusRes?.let { stringResource(it) }
?: stringResource(R.string.verify_scan_requires_connection),
color = if (statusRes != null) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
fontFamily = BitchatFontFamily,
fontSize = 12.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
} else {
Column(
modifier = Modifier
@ -479,7 +500,7 @@ private fun ScannerView(
@Composable
private fun QRCodeImage(data: String, size: Dp) {
val sizePx = with(LocalDensity.current) { size.toPx().toInt() }
val bitmap = remember(data, sizePx) { generateQrBitmap(data, sizePx) }
val bitmap = remember(data, sizePx) { QrCodeGenerator.generateQrBitmap(data, sizePx) }
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
@ -489,29 +510,6 @@ private fun QRCodeImage(data: String, size: Dp) {
}
}
private fun generateQrBitmap(data: String, sizePx: Int): Bitmap? {
if (data.isBlank() || sizePx <= 0) return null
return try {
val matrix = QRCodeWriter().encode(data, BarcodeFormat.QR_CODE, sizePx, sizePx)
bitmapFromMatrix(matrix)
} catch (_: Exception) {
null
}
}
private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val bitmap = createBitmap(width, height)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap[x, y] =
if (matrix[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE
}
}
return bitmap
}
private class QRCodeAnalyzer(
private val onCode: (String) -> Unit
) : ImageAnalysis.Analyzer {

View File

@ -15,16 +15,15 @@ import androidx.compose.ui.graphics.Color
data class BitchatPalette(
// MARK: - Form controls
/**
* Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted
* Material outline: the composer is the one surface the user stares at while typing.
* Resting border for text inputs. A touch darker than the Material outline: the composer is
* the one surface the user stares at while typing, so it needs to read as a distinct field.
*/
val inputOutline: Color,
/** Border for a focused text input. A step brighter, still neutral. */
val inputOutlineFocused: Color,
/**
* Fill for text inputs. Near-black / near-white and completely untinted, for the same reason
* as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any
* tint of its own compounds into something muddy.
* Fill for text inputs. A step off the chat background so the field has an edge even where
* the outline is faint.
*/
val inputSurface: Color,
/** Fill for a focused text input. A barely perceptible lift. */
@ -49,24 +48,24 @@ data class BitchatPalette(
)
val DarkBitchatPalette = BitchatPalette(
inputOutline = Color(0xFF333635),
inputOutlineFocused = Color(0xFF5A605D),
inputSurface = Color(0xFF0B0B0B),
inputSurfaceFocused = Color(0xFF151515),
inputButton = Color(0xFF1E1E1E),
textTertiary = Color(0xFF6B776B),
inputOutline = Color(0xFF33333A),
inputOutlineFocused = Color(0xFF5A5A64),
inputSurface = Color(0xFF121215),
inputSurfaceFocused = Color(0xFF1B1B20),
inputButton = Color(0xFF25252B),
textTertiary = Color(0xFF71717C),
accentOrange = Color(0xFFFF9F0A),
accentPurple = Color(0xFFBF5AF2),
peerColors = PeerColorStyle.Dark,
)
val LightBitchatPalette = BitchatPalette(
inputOutline = Color(0xFFCFD3D1),
inputOutlineFocused = Color(0xFF8E9490),
inputSurface = Color(0xFFFAFAFA),
inputSurfaceFocused = Color(0xFFF2F2F2),
inputButton = Color(0xFFE8E8E8),
textTertiary = Color(0xFF757F75),
inputOutline = Color(0xFFD1D1D8),
inputOutlineFocused = Color(0xFF8E8E9A),
inputSurface = Color(0xFFFAFAFC),
inputSurfaceFocused = Color(0xFFF2F2F5),
inputButton = Color(0xFFE9E9EE),
textTertiary = Color(0xFF7A7A85),
accentOrange = Color(0xFFFF9500),
accentPurple = Color(0xFFAF52DE),
peerColors = PeerColorStyle.Light,

View File

@ -56,7 +56,7 @@ internal object ChatVisualTokens {
* Author-colour wash inside a bubble. Matches the mention-chip treatment so a tinted
* bubble stays legible on both the near-black and near-white chat surfaces.
*/
const val BubbleBackgroundAlpha: Float = 0.18f
const val BubbleBackgroundAlpha: Float = 0.24f
/** Author-colour hairline around a bubble; stronger than the fill so the shape reads. */
const val BubbleBorderAlpha: Float = 0.38f

View File

@ -19,6 +19,11 @@ import androidx.compose.ui.platform.LocalView
// Standard UI semantics live in Material so stock components and custom Bitchat composables
// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors.
//
// The neutrals are true greys. Green survives only as the accent - primary, and the peer hues -
// because a green cast on every surface is what made the app read as a terminal rather than a
// chat client. The surfaceContainer* roles are spelled out because M3's defaults for them are
// derived from its purple baseline, which shows up in dialogs, snackbars and the nav bar.
internal val DarkBitchatColorScheme = darkColorScheme(
primary = Color(0xFF32D74B),
onPrimary = Color.Black,
@ -30,14 +35,19 @@ internal val DarkBitchatColorScheme = darkColorScheme(
onSecondaryContainer = Color(0xFFC2E0FF),
tertiary = DarkBitchatPalette.accentOrange,
onTertiary = Color.Black,
background = Color(0xFF000000),
onBackground = Color(0xFFF5F5F5),
surface = Color(0xFF0E150E),
onSurface = Color(0xFFF5F5F5),
surfaceVariant = Color(0xFF182118),
onSurfaceVariant = Color(0xFF9AA69A),
outline = Color(0xFF2A3A2A),
outlineVariant = Color(0xFF1C271C),
background = Color(0xFF0B0B0D),
onBackground = Color(0xFFF3F3F5),
surface = Color(0xFF16161A),
onSurface = Color(0xFFF3F3F5),
surfaceVariant = Color(0xFF1F1F24),
onSurfaceVariant = Color(0xFF9C9CA6),
surfaceContainerLowest = Color(0xFF08080A),
surfaceContainerLow = Color(0xFF121215),
surfaceContainer = Color(0xFF16161A),
surfaceContainerHigh = Color(0xFF1F1F24),
surfaceContainerHighest = Color(0xFF29292F),
outline = Color(0xFF2E2E34),
outlineVariant = Color(0xFF212126),
error = Color(0xFFFF453A),
onError = Color.Black
)
@ -54,13 +64,18 @@ internal val LightBitchatColorScheme = lightColorScheme(
tertiary = LightBitchatPalette.accentOrange,
onTertiary = Color.Black,
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF131A13),
surface = Color(0xFFF2F6F2),
onSurface = Color(0xFF131A13),
surfaceVariant = Color(0xFFE7EDE7),
onSurfaceVariant = Color(0xFF4C574C),
outline = Color(0xFFCBD6CB),
outlineVariant = Color(0xFFDEE6DE),
onBackground = Color(0xFF15151A),
surface = Color(0xFFF4F4F6),
onSurface = Color(0xFF15151A),
surfaceVariant = Color(0xFFEAEAEE),
onSurfaceVariant = Color(0xFF55555F),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF8F8FA),
surfaceContainer = Color(0xFFF4F4F6),
surfaceContainerHigh = Color(0xFFEEEEF2),
surfaceContainerHighest = Color(0xFFE7E7EC),
outline = Color(0xFFD6D6DC),
outlineVariant = Color(0xFFE6E6EB),
error = Color(0xFFD70015),
onError = Color.White
)

View File

@ -0,0 +1,56 @@
package com.bitchat.android.ui.theme
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Clock style for transcript timestamps.
*
* [System] follows the device's own 12/24-hour setting, which is the right default - a phone set
* to 12-hour showing `13:42` in one app and `1:42 PM` everywhere else reads as a bug. The explicit
* options exist because some people want a 24-hour transcript on a 12-hour phone regardless.
*/
enum class TimeFormatPreference {
System,
TwelveHour,
TwentyFourHour
}
/**
* SharedPreferences-backed clock settings with StateFlows. Mirrors [ThemePreferenceManager].
*
* Both values live in one object because they are one user-facing concern - how a timestamp is
* written - and a second singleton would only duplicate the init/set plumbing.
*/
object TimeFormatPreferenceManager {
private const val PREFS_NAME = "bitchat_settings"
private const val KEY_TIME_FORMAT = "time_format"
private const val KEY_SHOW_SECONDS = "time_show_seconds"
private val _formatFlow = MutableStateFlow(TimeFormatPreference.System)
val formatFlow: StateFlow<TimeFormatPreference> = _formatFlow
private val _showSecondsFlow = MutableStateFlow(false)
val showSecondsFlow: StateFlow<Boolean> = _showSecondsFlow
fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val saved = prefs.getString(KEY_TIME_FORMAT, TimeFormatPreference.System.name)
_formatFlow.value = runCatching { TimeFormatPreference.valueOf(saved!!) }
.getOrDefault(TimeFormatPreference.System)
_showSecondsFlow.value = prefs.getBoolean(KEY_SHOW_SECONDS, false)
}
fun set(context: Context, preference: TimeFormatPreference) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_TIME_FORMAT, preference.name).apply()
_formatFlow.value = preference
}
fun setShowSeconds(context: Context, showSeconds: Boolean) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_SHOW_SECONDS, showSeconds).apply()
_showSecondsFlow.value = showSeconds
}
}

View File

@ -23,6 +23,11 @@
<string name="no_one_connected">No one connected</string>
<string name="emergency_clear_hint">Triple tap to clear all data</string>
<string name="your_network">Network</string>
<!-- Bottom tab bar -->
<string name="tab_chats">Chats</string>
<string name="tab_public">Public</string>
<string name="time_now">now</string>
<!-- Battery Optimization Strings -->
<string name="battery_optimization_detected">Battery Optimization Detected</string>
@ -171,8 +176,15 @@
<!-- About sheet: section labels -->
<string name="about_section_about">About</string>
<string name="about_section_theme">Theme</string>
<string name="about_section_clock">Clock</string>
<string name="about_section_settings">Settings</string>
<!-- Settings: transcript clock -->
<string name="clock_12_hour">12-hour</string>
<string name="clock_24_hour">24-hour</string>
<string name="clock_show_seconds">Show seconds</string>
<string name="clock_show_seconds_subtitle">Add seconds to message timestamps, e.g. 13:45:40</string>
<!-- About sheet: feature list -->
<string name="about_no_tracking_title">No Tracking</string>
<string name="about_no_tracking_desc">No servers, accounts, or data collection.</string>
@ -475,6 +487,9 @@
<string name="debug_recent_scan_results">Recent scan results</string>
<string name="verify_title">Verify</string>
<string name="verify_scan_requires_connection">Verifies someone already on your mesh. Both phones need Bluetooth on and to be in range — scanning does not connect you.</string>
<string name="verify_scan_invalid">Not a valid bitchat code, or it expired. Codes are good for 5 minutes — ask them to reopen theirs.</string>
<string name="verify_scan_peer_not_connected">That person is not connected to you yet. Wait for them to appear under People, then scan again.</string>
<string name="verify_my_qr_title">Scan to verify me</string>
<string name="verify_scan_prompt_friend">Scan someone else\'s QR</string>
<string name="verify_scan_someone">Scan someone else\'s QR</string>

View File

@ -489,7 +489,7 @@ class ChatUIUtilsTest {
@Test
fun `material owns standard text while Bitchat palette owns peer chroma`() {
assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
assertEquals(Color(0xFFF3F3F5), DarkBitchatColorScheme.onSurface)
assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
assertTrue(
LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors