Merge branch 'main' into gossip-routing-2

This commit is contained in:
callebtc
2025-10-28 09:00:01 +01:00
111 changed files with 12637 additions and 799 deletions
@@ -29,7 +29,8 @@ import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import com.bitchat.android.nostr.PoWPreferenceManager
import com.bitchat.android.ui.debug.DebugSettingsSheet
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* About Sheet for bitchat app information
* Matches the design language of LocationChannelsSheet
@@ -102,7 +103,7 @@ fun AboutSheet(
verticalAlignment = Alignment.Bottom
) {
Text(
text = "bitchat",
text = stringResource(R.string.app_name),
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -112,7 +113,7 @@ fun AboutSheet(
)
Text(
text = "v$versionName",
text = stringResource(R.string.version_prefix, versionName?:""),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.5f),
@@ -123,7 +124,7 @@ fun AboutSheet(
}
Text(
text = "decentralized mesh messaging with end-to-end encryption",
text = stringResource(R.string.about_tagline),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
@@ -141,7 +142,7 @@ fun AboutSheet(
) {
Icon(
imageVector = Icons.Filled.Bluetooth,
contentDescription = "Offline Mesh Chat",
contentDescription = stringResource(R.string.cd_offline_mesh_chat),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
@@ -150,14 +151,14 @@ fun AboutSheet(
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Offline Mesh Chat",
text = stringResource(R.string.about_offline_mesh_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range.",
text = stringResource(R.string.about_offline_mesh_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
@@ -173,7 +174,7 @@ fun AboutSheet(
) {
Icon(
imageVector = Icons.Default.Public,
contentDescription = "Online Geohash Channels",
contentDescription = stringResource(R.string.cd_online_geohash_channels),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
@@ -182,14 +183,14 @@ fun AboutSheet(
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "Online Geohash Channels",
text = stringResource(R.string.about_online_geohash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Connect with people in your area using geohash-based channels. Extend the mesh using public internet relays.",
text = stringResource(R.string.about_online_geohash_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
@@ -205,7 +206,7 @@ fun AboutSheet(
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = "End-to-End Encryption",
contentDescription = stringResource(R.string.cd_end_to_end_encryption),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 2.dp)
@@ -214,14 +215,14 @@ fun AboutSheet(
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = "End-to-End Encryption",
text = stringResource(R.string.about_e2e_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Private messages are encrypted. Channel messages are public.",
text = stringResource(R.string.about_e2e_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
)
@@ -232,7 +233,7 @@ fun AboutSheet(
// Appearance Section
item(key = "appearance_section") {
Text(
text = "appearance",
text = stringResource(R.string.about_appearance),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
@@ -247,24 +248,24 @@ fun AboutSheet(
FilterChip(
selected = themePref.isSystem,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.System) },
label = { Text("system", fontFamily = FontFamily.Monospace) }
label = { Text(stringResource(R.string.about_system), fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = themePref.isLight,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Light) },
label = { Text("light", fontFamily = FontFamily.Monospace) }
label = { Text(stringResource(R.string.about_light), fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = themePref.isDark,
onClick = { com.bitchat.android.ui.theme.ThemePreferenceManager.set(context, com.bitchat.android.ui.theme.ThemePreference.Dark) },
label = { Text("dark", fontFamily = FontFamily.Monospace) }
label = { Text(stringResource(R.string.about_dark), fontFamily = FontFamily.Monospace) }
)
}
}
// Proof of Work Section
item(key = "pow_section") {
Text(
text = "proof of work",
text = stringResource(R.string.about_pow),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
@@ -289,7 +290,7 @@ fun AboutSheet(
FilterChip(
selected = !powEnabled,
onClick = { PoWPreferenceManager.setPowEnabled(false) },
label = { Text("pow off", fontFamily = FontFamily.Monospace) }
label = { Text(stringResource(R.string.about_pow_off), fontFamily = FontFamily.Monospace) }
)
FilterChip(
selected = powEnabled,
@@ -299,7 +300,7 @@ fun AboutSheet(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("pow on", fontFamily = FontFamily.Monospace)
Text(stringResource(R.string.about_pow_on), fontFamily = FontFamily.Monospace)
// Show current difficulty
if (powEnabled) {
Surface(
@@ -313,7 +314,7 @@ fun AboutSheet(
}
Text(
text = "add proof of work to geohash messages for spam deterrence.",
text = stringResource(R.string.about_pow_tip),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.6f)
@@ -326,7 +327,7 @@ fun AboutSheet(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "difficulty: $powDifficulty bits (~${NostrProofOfWork.estimateMiningTime(powDifficulty)})",
text = stringResource(R.string.about_pow_difficulty, powDifficulty, NostrProofOfWork.estimateMiningTime(powDifficulty)),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
)
@@ -353,20 +354,20 @@ fun AboutSheet(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "difficulty $powDifficulty requires ~${NostrProofOfWork.estimateWork(powDifficulty)} hash attempts",
text = stringResource(R.string.about_pow_difficulty_attempts, powDifficulty, NostrProofOfWork.estimateWork(powDifficulty)),
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
Text(
text = when {
powDifficulty == 0 -> "no proof of work required"
powDifficulty <= 8 -> "very low - minimal spam protection"
powDifficulty <= 12 -> "low - basic spam protection"
powDifficulty <= 16 -> "medium - good spam protection"
powDifficulty <= 20 -> "high - strong spam protection"
powDifficulty <= 24 -> "very high - may cause delays"
else -> "extreme - significant computation required"
powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none)
powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low)
powDifficulty <= 12 -> stringResource(R.string.about_pow_desc_low)
powDifficulty <= 16 -> stringResource(R.string.about_pow_desc_medium)
powDifficulty <= 20 -> stringResource(R.string.about_pow_desc_high)
powDifficulty <= 24 -> stringResource(R.string.about_pow_desc_very_high)
else -> stringResource(R.string.about_pow_desc_extreme)
},
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
@@ -384,7 +385,7 @@ fun AboutSheet(
val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(context)) }
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
Text(
text = "network",
text = stringResource(R.string.about_network),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
modifier = Modifier
@@ -429,7 +430,7 @@ fun AboutSheet(
)
}
Text(
text = "route internet over tor for enhanced privacy.",
text = stringResource(R.string.about_tor_route),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -446,14 +447,14 @@ fun AboutSheet(
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = "tor Status: $statusText, bootstrap ${torStatus.bootstrapPercent}%",
text = stringResource(R.string.about_tor_status, statusText, torStatus.bootstrapPercent),
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.75f)
)
val lastLog = torStatus.lastLogLine
if (lastLog.isNotEmpty()) {
Text(
text = "Last: ${lastLog.take(160)}",
text = stringResource(R.string.about_last, lastLog.take(160)),
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -483,20 +484,20 @@ fun AboutSheet(
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Warning",
contentDescription = stringResource(R.string.cd_warning),
tint = errorColor,
modifier = Modifier.size(16.dp)
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Emergency Data Deletion",
text = stringResource(R.string.about_emergency_title),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
color = errorColor
)
Text(
text = "Tip: Triple-click the app title to emergency delete all stored data including messages, keys, and settings.",
text = stringResource(R.string.about_emergency_tip),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.8f)
@@ -523,14 +524,14 @@ fun AboutSheet(
)
) {
Text(
text = "Debug Settings",
text = stringResource(R.string.about_debug_settings),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
}
Text(
text = "Open Source • Privacy First • Decentralized",
text = stringResource(R.string.about_footer),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
@@ -557,7 +558,7 @@ fun AboutSheet(
.padding(horizontal = 16.dp)
) {
Text(
text = "Close",
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
@@ -588,7 +589,7 @@ fun PasswordPromptDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = "Enter Channel Password",
text = stringResource(R.string.pwd_prompt_title),
style = MaterialTheme.typography.titleMedium,
color = colorScheme.onSurface
)
@@ -596,7 +597,7 @@ fun PasswordPromptDialog(
text = {
Column {
Text(
text = "Channel $channelName is password protected. Enter the password to join.",
text = stringResource(R.string.pwd_prompt_message, channelName ?: ""),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
@@ -605,7 +606,7 @@ fun PasswordPromptDialog(
OutlinedTextField(
value = passwordInput,
onValueChange = onPasswordChange,
label = { Text("Password", style = MaterialTheme.typography.bodyMedium) },
label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) },
textStyle = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
@@ -619,7 +620,7 @@ fun PasswordPromptDialog(
confirmButton = {
TextButton(onClick = onConfirm) {
Text(
text = "Join",
text = stringResource(R.string.join),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
@@ -628,7 +629,7 @@ fun PasswordPromptDialog(
dismissButton = {
TextButton(onClick = onDismiss) {
Text(
text = "Cancel",
text = stringResource(R.string.cancel),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.onSurface
)
@@ -21,11 +21,16 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
/**
* Header components for ChatScreen
@@ -51,23 +56,27 @@ fun isFavoriteReactive(
}
@Composable
fun TorStatusIcon(
fun TorStatusDot(
modifier: Modifier = Modifier
) {
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
val cableColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851)
else -> Color.Red
val dotColor = when {
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
else -> Color.Red // Red - error/disconnected
}
Canvas(
modifier = modifier
) {
val radius = size.minDimension / 2
drawCircle(
color = dotColor,
radius = radius,
center = Offset(size.width / 2, size.height / 2)
)
}
Icon(
imageVector = Icons.Outlined.Cable,
contentDescription = "Tor status",
modifier = modifier,
tint = cableColor
)
}
}
@@ -80,23 +89,23 @@ fun NoiseSessionIcon(
"uninitialized" -> Triple(
Icons.Outlined.NoEncryption,
Color(0x87878700), // Grey - ready to establish
"Ready for handshake"
stringResource(R.string.cd_ready_for_handshake)
)
"handshaking" -> Triple(
Icons.Outlined.Sync,
Color(0x87878700), // Grey - in progress
"Handshake in progress"
stringResource(R.string.cd_handshake_in_progress)
)
"established" -> Triple(
Icons.Filled.Lock,
Color(0xFFFF9500), // Orange - secure
"End-to-end encrypted"
stringResource(R.string.cd_encrypted)
)
else -> { // "failed" or any other state
Triple(
Icons.Outlined.Warning,
Color(0xFFFF4444), // Red - error
"Handshake failed"
stringResource(R.string.cd_handshake_failed)
)
}
}
@@ -129,7 +138,7 @@ fun NicknameEditor(
modifier = modifier
) {
Text(
text = "@",
text = stringResource(R.string.at_symbol),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary.copy(alpha = 0.8f)
)
@@ -193,8 +202,8 @@ fun PeerCounter(
Icon(
imageVector = Icons.Default.Group,
contentDescription = when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> "Geohash participants"
else -> "Connected peers"
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
else -> stringResource(R.string.cd_connected_peers)
},
modifier = Modifier.size(16.dp),
tint = countColor
@@ -211,7 +220,7 @@ fun PeerCounter(
if (joinedChannels.isNotEmpty()) {
Text(
text = " · ⧉ ${joinedChannels.size}",
text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
style = MaterialTheme.typography.bodyMedium,
color = if (isConnected) Color(0xFF00C851) else Color.Red,
fontSize = 16.sp,
@@ -231,7 +240,8 @@ fun ChatHeaderContent(
onSidebarClick: () -> Unit,
onTripleClick: () -> Unit,
onShowAppInfo: () -> Unit,
onLocationChannelsClick: () -> Unit
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
@@ -287,6 +297,7 @@ fun ChatHeaderContent(
onTripleTitleClick = onTripleClick,
onSidebarClick = onSidebarClick,
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = onLocationNotesClick,
viewModel = viewModel
)
}
@@ -373,13 +384,13 @@ private fun PrivateChatHeader(
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.back),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "back",
text = stringResource(R.string.chat_back),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
@@ -405,7 +416,7 @@ private fun PrivateChatHeader(
if (showGlobe) {
Icon(
imageVector = Icons.Outlined.Public,
contentDescription = "Nostr reachable",
contentDescription = stringResource(R.string.cd_nostr_reachable),
modifier = Modifier.size(14.dp),
tint = Color(0xFF9B59B6) // Purple like iOS
)
@@ -428,7 +439,7 @@ private fun PrivateChatHeader(
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
modifier = Modifier.size(18.dp), // Slightly larger than sidebar icon
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700) // Yellow or grey
)
@@ -463,13 +474,13 @@ private fun ChannelHeader(
) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back",
contentDescription = stringResource(R.string.back),
modifier = Modifier.size(16.dp),
tint = colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "back",
text = stringResource(R.string.chat_back),
style = MaterialTheme.typography.bodyMedium,
color = colorScheme.primary
)
@@ -478,7 +489,7 @@ private fun ChannelHeader(
// Title - perfectly centered regardless of other elements
Text(
text = "channel: $channel",
text = stringResource(R.string.chat_channel_prefix, channel),
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFFF9500), // Orange to match input field
modifier = Modifier
@@ -492,7 +503,7 @@ private fun ChannelHeader(
modifier = Modifier.align(Alignment.CenterEnd)
) {
Text(
text = "leave",
text = stringResource(R.string.chat_leave),
style = MaterialTheme.typography.bodySmall,
color = Color.Red
)
@@ -508,6 +519,7 @@ private fun MainHeader(
onTripleTitleClick: () -> Unit,
onSidebarClick: () -> Unit,
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit,
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
@@ -534,7 +546,7 @@ private fun MainHeader(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "bitchat/",
text = stringResource(R.string.app_brand),
style = MaterialTheme.typography.headlineSmall,
color = colorScheme.primary,
modifier = Modifier.singleOrTripleClickable(
@@ -562,7 +574,7 @@ private fun MainHeader(
// Render icon directly to avoid symbol resolution issues
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Unread private messages",
contentDescription = stringResource(R.string.cd_unread_private_messages),
modifier = Modifier
.size(16.dp)
.clickable { viewModel.openLatestUnreadPrivateChat() },
@@ -571,7 +583,7 @@ private fun MainHeader(
}
// Location channels button (matching iOS implementation) and bookmark grouped tightly
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
LocationChannelsButton(
viewModel = viewModel,
onClick = onLocationChannelsClick
@@ -586,14 +598,14 @@ private fun MainHeader(
val isBookmarked = bookmarks.contains(currentGeohash)
Box(
modifier = Modifier
.padding(start = 1.dp) // minimal gap between geohash and bookmark
.padding(start = 2.dp) // minimal gap between geohash and bookmark
.size(20.dp)
.clickable { bookmarksStore.toggle(currentGeohash) },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = "Toggle bookmark",
contentDescription = stringResource(R.string.cd_toggle_bookmark),
tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
modifier = Modifier.size(16.dp)
)
@@ -601,15 +613,25 @@ private fun MainHeader(
}
}
// Tor status cable icon when Tor is enabled
TorStatusIcon(modifier = Modifier.size(14.dp))
// Location Notes button (extracted to separate component)
LocationNotesButton(
viewModel = viewModel,
onClick = onLocationNotesClick
)
// Tor status dot when Tor is enabled
TorStatusDot(
modifier = Modifier
.size(8.dp)
.padding(start = 0.dp, end = 2.dp)
)
// PoW status indicator
PoWStatusIndicator(
modifier = Modifier,
style = PoWIndicatorStyle.COMPACT
)
Spacer(modifier = Modifier.width(2.dp))
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
joinedChannels = joinedChannels,
@@ -668,7 +690,7 @@ private fun LocationChannelsButton(
Spacer(modifier = Modifier.width(2.dp))
Icon(
imageVector = Icons.Default.PinDrop,
contentDescription = "Teleported",
contentDescription = stringResource(R.string.cd_teleported),
modifier = Modifier.size(12.dp),
tint = badgeColor
)
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
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
@@ -62,6 +63,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
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) }
@@ -97,6 +99,13 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
// Determine whether to show media buttons (only hide in geohash location chats)
val showMediaButtons = when {
selectedPrivatePeer != null -> true
currentChannel != null -> true
else -> selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location
}
// Use WindowInsets to handle keyboard properly
Box(
modifier = Modifier
@@ -225,7 +234,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
colorScheme = colorScheme
colorScheme = colorScheme,
showMediaButtons = showMediaButtons
)
}
@@ -240,7 +250,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
onSidebarToggle = { viewModel.showSidebar() },
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true }
onLocationChannelsClick = { showLocationChannelsSheet = true },
onLocationNotesClick = { showLocationNotesSheet = true }
)
// Divider under header - positioned after status bar + header height
@@ -294,7 +305,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
Icon(
imageVector = Icons.Filled.ArrowDownward,
contentDescription = "Scroll to bottom",
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
tint = Color(0xFF00C851)
)
}
@@ -353,6 +364,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
onAppInfoDismiss = { viewModel.hideAppInfo() },
showLocationChannelsSheet = showLocationChannelsSheet,
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
showLocationNotesSheet = showLocationNotesSheet,
onLocationNotesSheetDismiss = { showLocationNotesSheet = false },
showUserSheet = showUserSheet,
onUserSheetDismiss = {
showUserSheet = false
@@ -381,7 +394,8 @@ private fun ChatInputSection(
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
colorScheme: ColorScheme
colorScheme: ColorScheme,
showMediaButtons: Boolean
) {
Surface(
modifier = Modifier.fillMaxWidth(),
@@ -417,6 +431,7 @@ private fun ChatInputSection(
selectedPrivatePeer = selectedPrivatePeer,
currentChannel = currentChannel,
nickname = nickname,
showMediaButtons = showMediaButtons,
modifier = Modifier.fillMaxWidth()
)
}
@@ -434,8 +449,12 @@ private fun ChatFloatingHeader(
onSidebarToggle: () -> Unit,
onShowAppInfo: () -> Unit,
onPanicClear: () -> Unit,
onLocationChannelsClick: () -> Unit
onLocationChannelsClick: () -> Unit,
onLocationNotesClick: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -459,7 +478,12 @@ private fun ChatFloatingHeader(
onSidebarClick = onSidebarToggle,
onTripleClick = onPanicClear,
onShowAppInfo = onShowAppInfo,
onLocationChannelsClick = onLocationChannelsClick
onLocationChannelsClick = onLocationChannelsClick,
onLocationNotesClick = {
// Ensure location is loaded before showing sheet
locationManager.refreshChannels()
onLocationNotesClick()
}
)
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -470,6 +494,7 @@ private fun ChatFloatingHeader(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChatDialogs(
showPasswordDialog: Boolean,
@@ -482,6 +507,8 @@ private fun ChatDialogs(
onAppInfoDismiss: () -> Unit,
showLocationChannelsSheet: Boolean,
onLocationChannelsSheetDismiss: () -> Unit,
showLocationNotesSheet: Boolean,
onLocationNotesSheetDismiss: () -> Unit,
showUserSheet: Boolean,
onUserSheetDismiss: () -> Unit,
selectedUserForSheet: String,
@@ -522,6 +549,14 @@ private fun ChatDialogs(
)
}
// Location notes sheet (extracted to separate presenter)
if (showLocationNotesSheet) {
LocationNotesSheetPresenter(
viewModel = viewModel,
onDismiss = onLocationNotesSheetDismiss
)
}
// User action sheet
if (showUserSheet) {
ChatUserSheet(
@@ -3,10 +3,7 @@ package com.bitchat.android.ui
/**
* UI constants/utilities for nickname rendering.
*/
const val MAX_NICKNAME_LENGTH: Int = 15
fun truncateNickname(name: String, maxLen: Int = MAX_NICKNAME_LENGTH): String {
fun truncateNickname(name: String, maxLen: Int = com.bitchat.android.util.AppConstants.UI.MAX_NICKNAME_LENGTH): String {
return if (name.length <= maxLen) name else name.take(maxLen)
}
@@ -12,6 +12,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import kotlinx.coroutines.launch
@@ -61,7 +63,7 @@ fun ChatUserSheet(
) {
// Header
Text(
text = "@$targetNickname",
text = stringResource(R.string.at_nickname, targetNickname),
fontSize = 18.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -69,7 +71,7 @@ fun ChatUserSheet(
)
Text(
text = if (selectedMessage != null) "choose an action for this message or user" else "choose an action for this user",
text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
@@ -83,8 +85,8 @@ fun ChatUserSheet(
selectedMessage?.let { message ->
item {
UserActionRow(
title = "copy message",
subtitle = "copy this message to clipboard",
title = stringResource(R.string.action_copy_message_title),
subtitle = stringResource(R.string.action_copy_message_subtitle),
titleColor = standardGrey,
onClick = {
// Copy the message content to clipboard
@@ -100,8 +102,8 @@ fun ChatUserSheet(
// Slap action
item {
UserActionRow(
title = "slap $targetNickname",
subtitle = "send a playful slap message",
title = stringResource(R.string.action_slap_title, targetNickname),
subtitle = stringResource(R.string.action_slap_subtitle),
titleColor = standardBlue,
onClick = {
// Send slap command
@@ -114,8 +116,8 @@ fun ChatUserSheet(
// Hug action
item {
UserActionRow(
title = "hug $targetNickname",
subtitle = "send a friendly hug message",
title = stringResource(R.string.action_hug_title, targetNickname),
subtitle = stringResource(R.string.action_hug_subtitle),
titleColor = standardGreen,
onClick = {
// Send hug command
@@ -128,8 +130,8 @@ fun ChatUserSheet(
// Block action
item {
UserActionRow(
title = "block $targetNickname",
subtitle = "block all messages from this user",
title = stringResource(R.string.action_block_title, targetNickname),
subtitle = stringResource(R.string.action_block_subtitle),
titleColor = standardRed,
onClick = {
// Check if we're in a geohash channel
@@ -158,7 +160,7 @@ fun ChatUserSheet(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "cancel",
text = stringResource(R.string.cancel_lower),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace
)
@@ -147,21 +147,13 @@ class ChatViewModel(
mediaSendingManager.handleTransferProgressEvent(evt)
}
}
// Removed background location notes subscription. Notes now load only when sheet opens.
}
fun cancelMediaSend(messageId: String) {
val transferId = synchronized(transferMessageMap) { messageTransferMap[messageId] }
if (transferId != null) {
val cancelled = meshService.cancelFileTransfer(transferId)
if (cancelled) {
// Remove the message from chat upon explicit cancel
messageManager.removeMessageById(messageId)
synchronized(transferMessageMap) {
transferMessageMap.remove(transferId)
messageTransferMap.remove(messageId)
}
}
}
// Delegate to MediaSendingManager which tracks transfer IDs and cleans up UI state
mediaSendingManager.cancelMediaSend(messageId)
}
private fun loadAndInitialize() {
@@ -226,20 +218,6 @@ class ChatViewModel(
} catch (_: Exception) { }
// Note: Mesh service is now started by MainActivity
// Show welcome message if no peers after delay
viewModelScope.launch {
delay(10000)
if (state.getConnectedPeersValue().isEmpty() && state.getMessagesValue().isEmpty()) {
val welcomeMessage = BitchatMessage(
sender = "system",
content = "get people around you to download bitchat and chat with them here!",
timestamp = Date(),
isRelay = false
)
messageManager.addMessage(welcomeMessage)
}
}
// BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch.
}
@@ -605,6 +583,8 @@ class ChatViewModel(
}
}
// Location notes subscription management moved to LocationNotesViewModelExtensions.kt
/**
* Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI)
*/
@@ -20,6 +20,8 @@ import androidx.compose.ui.unit.sp
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import java.util.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* GeohashPeopleList - iOS-compatible component for displaying geohash participants
@@ -66,7 +68,7 @@ fun GeohashPeopleList(
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "PEOPLE",
text = stringResource(R.string.geohash_people_header),
style = MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
@@ -78,7 +80,7 @@ fun GeohashPeopleList(
if (geohashPeople.isEmpty()) {
// Empty state - matches iOS "nobody around..."
Text(
text = "nobody around...",
text = stringResource(R.string.nobody_around),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
@@ -182,7 +184,7 @@ private fun GeohashPersonItem(
// Unread DM indicator (orange envelope)
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Unread message",
contentDescription = stringResource(R.string.cd_unread_message),
modifier = Modifier.size(12.dp),
tint = Color(0xFFFF9500) // iOS orange
)
@@ -253,7 +255,7 @@ private fun GeohashPersonItem(
// "You" indicator for current user
if (isMe) {
Text(
text = " (you)",
text = stringResource(R.string.you_suffix),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
fontSize = BASE_FONT_SIZE.sp
@@ -11,7 +11,6 @@ import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -37,13 +36,15 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import androidx.core.view.updateLayoutParams
import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
@OptIn(ExperimentalMaterial3Api::class)
class GeohashPickerActivity : ComponentActivity() {
class GeohashPickerActivity : OrientationAwareActivity() {
companion object {
const val EXTRA_INITIAL_GEOHASH = "initial_geohash"
@@ -175,7 +176,7 @@ class GeohashPickerActivity : ComponentActivity() {
shadowElevation = 6.dp
) {
Text(
text = "pan and zoom to select a geohash",
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = FontFamily.Monospace,
@@ -228,7 +229,7 @@ class GeohashPickerActivity : ComponentActivity() {
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Remove, contentDescription = "Decrease precision")
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
}
}
@@ -244,7 +245,7 @@ class GeohashPickerActivity : ComponentActivity() {
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Add, contentDescription = "Increase precision")
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
}
}
@@ -264,10 +265,10 @@ class GeohashPickerActivity : ComponentActivity() {
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Check, contentDescription = "Select geohash")
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
Spacer(Modifier.width(6.dp))
Text(
text = "select",
text = stringResource(R.string.select),
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = FontFamily.Monospace
)
@@ -170,6 +170,7 @@ fun MessageInput(
selectedPrivatePeer: String?,
currentChannel: String?,
nickname: String,
showMediaButtons: Boolean,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
@@ -217,7 +218,7 @@ fun MessageInput(
// Show placeholder when there's no text and not recording
if (value.text.isEmpty() && !isRecording) {
Text(
text = "type a message...",
text = stringResource(R.string.type_a_message_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
@@ -252,8 +253,8 @@ fun MessageInput(
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
// Voice and image buttons when no text (always visible for mesh + channels + private)
if (value.text.isEmpty()) {
// Voice and image buttons when no text (only visible in Mesh chat)
if (value.text.isEmpty() && showMediaButtons) {
// Hold-to-record microphone
val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f)
@@ -486,7 +487,7 @@ fun MentionSuggestionItem(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "@$suggestion",
text = stringResource(R.string.mention_suggestion_at, suggestion),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.SemiBold
@@ -498,7 +499,7 @@ fun MentionSuggestionItem(
Spacer(modifier = Modifier.weight(1f))
Text(
text = "mention",
text = stringResource(R.string.mention),
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
@@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -94,7 +95,7 @@ fun LinkPreviewPill(
) {
Icon(
imageVector = Icons.Outlined.Link,
contentDescription = "Link",
contentDescription = stringResource(com.bitchat.android.R.string.cd_link),
modifier = Modifier.size(24.dp),
tint = Color.Blue
)
@@ -37,6 +37,8 @@ import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -133,7 +135,7 @@ fun LocationChannelsSheet(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "#location channels",
text = stringResource(R.string.location_channels_title),
style = MaterialTheme.typography.headlineSmall,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
@@ -141,7 +143,7 @@ fun LocationChannelsSheet(
)
Text(
text = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy.",
text = stringResource(R.string.location_channels_desc),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)
@@ -170,7 +172,7 @@ fun LocationChannelsSheet(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "grant location permission",
text = stringResource(R.string.grant_location_permission),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
@@ -180,7 +182,7 @@ fun LocationChannelsSheet(
LocationChannelManager.PermissionState.RESTRICTED -> {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "location permission denied. enable in settings to use location channels.",
text = stringResource(R.string.location_permission_denied),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = Color.Red.copy(alpha = 0.8f)
@@ -194,7 +196,7 @@ fun LocationChannelsSheet(
}
) {
Text(
text = "open settings",
text = stringResource(R.string.open_settings),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
@@ -203,7 +205,7 @@ fun LocationChannelsSheet(
}
LocationChannelManager.PermissionState.AUTHORIZED -> {
Text(
text = "✓ location permission granted",
text = stringResource(R.string.location_permission_granted),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = standardGreen
@@ -216,7 +218,7 @@ fun LocationChannelsSheet(
) {
CircularProgressIndicator(modifier = Modifier.size(12.dp))
Text(
text = "checking permissions...",
text = stringResource(R.string.checking_permissions),
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
@@ -232,7 +234,7 @@ fun LocationChannelsSheet(
item(key = "mesh") {
ChannelRow(
title = meshTitleWithCount(viewModel),
subtitle = "#bluetooth • ${bluetoothRangeString()}",
subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()),
isSelected = selectedChannel is ChannelID.Mesh,
titleColor = standardBlue,
titleBold = meshCount(viewModel) > 0,
@@ -245,8 +247,11 @@ fun LocationChannelsSheet(
}
// Nearby options (only show if location services are enabled)
// CRITICAL: Filter out .building level (precision 8) - iOS pattern
// iOS: let nearby = manager.availableChannels.filter { $0.level != .building }
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
items(availableChannels) { channel ->
val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
items(nearbyChannels) { channel ->
val coverage = coverageString(channel.geohash.length)
val nameBase = locationNames[channel.level]
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
@@ -262,13 +267,13 @@ fun LocationChannelsSheet(
titleColor = standardGreen,
titleBold = highlight,
trailingContent = {
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = if (isBookmarked) "Unbookmark" else "Bookmark",
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
Icon(
imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
},
onClick = {
// Selecting a suggested nearby channel is not a teleport
@@ -286,7 +291,7 @@ fun LocationChannelsSheet(
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Text(
text = "finding nearby channels…",
text = stringResource(R.string.finding_nearby_channels),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
@@ -298,7 +303,7 @@ fun LocationChannelsSheet(
if (bookmarks.isNotEmpty()) {
item(key = "bookmarked_header") {
Text(
text = "bookmarked",
text = stringResource(R.string.bookmarked),
style = MaterialTheme.typography.labelLarge,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
@@ -328,7 +333,7 @@ fun LocationChannelsSheet(
IconButton(onClick = { bookmarksStore.toggle(gh) }) {
Icon(
imageVector = Icons.Filled.Bookmark,
contentDescription = "Remove bookmark",
contentDescription = stringResource(R.string.cd_remove_bookmark),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
)
}
@@ -366,7 +371,7 @@ fun LocationChannelsSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "#",
text = stringResource(R.string.hash_symbol),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
@@ -409,7 +414,7 @@ fun LocationChannelsSheet(
decorationBox = { innerTextField ->
if (customGeohash.isEmpty()) {
Text(
text = "geohash",
text = stringResource(R.string.geohash_placeholder),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
@@ -435,7 +440,7 @@ fun LocationChannelsSheet(
}) {
Icon(
imageVector = Icons.Filled.Map,
contentDescription = "Open map",
contentDescription = stringResource(R.string.cd_open_map),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
@@ -453,7 +458,7 @@ fun LocationChannelsSheet(
locationManager.select(ChannelID.Location(channel))
onDismiss()
} else {
customError = "invalid geohash"
customError = context.getString(R.string.invalid_geohash)
}
},
enabled = isValid,
@@ -467,14 +472,13 @@ fun LocationChannelsSheet(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "teleport",
text = stringResource(R.string.teleport),
fontSize = BASE_FONT_SIZE.sp,
fontFamily = FontFamily.Monospace
)
// iOS has a face.dashed icon, use closest Material equivalent
Icon(
imageVector = Icons.Filled.PinDrop,
contentDescription = "Teleport",
contentDescription = stringResource(R.string.cd_teleport),
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurface
)
@@ -530,11 +534,7 @@ fun LocationChannelsSheet(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (locationServicesEnabled) {
"disable location services"
} else {
"enable location services"
},
text = if (locationServicesEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services),
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
@@ -558,7 +558,7 @@ fun LocationChannelsSheet(
.padding(horizontal = 16.dp)
) {
Text(
text = "Close",
text = stringResource(R.string.close_plain),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onBackground
)
@@ -573,8 +573,6 @@ fun LocationChannelsSheet(
if (isPresented) {
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
locationManager.refreshChannels()
}
if (locationServicesEnabled) {
locationManager.beginLiveRefresh()
}
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
@@ -668,7 +666,7 @@ private fun ChannelRow(
if (isSelected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
contentDescription = stringResource(R.string.cd_selected),
tint = Color(0xFF32D74B), // iOS green for checkmark
modifier = Modifier.size(20.dp)
)
@@ -695,10 +693,13 @@ private fun splitTitleAndCount(title: String): Pair<String, String?> {
}
}
@Composable
private fun meshTitleWithCount(viewModel: ChatViewModel): String {
val meshCount = meshCount(viewModel)
val noun = if (meshCount == 1) "person" else "people"
return "mesh [$meshCount $noun]"
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount)
val meshLabel = stringResource(com.bitchat.android.R.string.mesh_label)
return "$meshLabel [$peopleText]"
}
private fun meshCount(viewModel: ChatViewModel): Int {
@@ -708,14 +709,26 @@ private fun meshCount(viewModel: ChatViewModel): Int {
} ?: 0
}
@Composable
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
val noun = if (participantCount == 1) "person" else "people"
return "${channel.level.displayName.lowercase()} [$participantCount $noun]"
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
val levelName = when (channel.level) {
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood)
com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city)
com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE -> stringResource(com.bitchat.android.R.string.location_level_province)
com.bitchat.android.geohash.GeohashChannelLevel.REGION -> stringResource(com.bitchat.android.R.string.location_level_region)
}
return "$levelName [$peopleText]"
}
@Composable
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
val noun = if (participantCount == 1) "person" else "people"
return "#$geohash [$participantCount $noun]"
val ctx = androidx.compose.ui.platform.LocalContext.current
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
return "#$geohash [$peopleText]"
}
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
@@ -738,7 +751,8 @@ private fun levelForLength(length: Int): GeohashChannelLevel {
5 -> GeohashChannelLevel.CITY
6 -> GeohashChannelLevel.NEIGHBORHOOD
7 -> GeohashChannelLevel.BLOCK
else -> GeohashChannelLevel.BLOCK
8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level
else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK
}
}
@@ -0,0 +1,67 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
/**
* Location Notes button component for MainHeader
* Shows in mesh mode when location permission granted AND services enabled
* Icon turns primary color when notes exist, gray otherwise
*/
@Composable
fun LocationNotesButton(
viewModel: ChatViewModel,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val context = LocalContext.current
// Get channel and permission state
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.observeAsState()
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
// Check both permission AND location services enabled
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
val locationEnabled = locationPermissionGranted && locationServicesEnabled
// Get notes count from LocationNotesManager
val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.observeAsState(emptyList())
val notesCount = notes.size
// Only show in mesh mode when location is authorized (iOS pattern)
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
val hasNotes = notesCount > 0
IconButton(
onClick = onClick,
modifier = modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
contentDescription = stringResource(R.string.cd_location_notes),
modifier = Modifier.size(16.dp),
tint = if (hasNotes) colorScheme.primary else Color.Gray
)
}
}
}
@@ -0,0 +1,612 @@
package com.bitchat.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
import java.text.SimpleDateFormat
import java.util.*
import java.util.Calendar
/**
* Location Notes Sheet - EXACT iOS UI match for bitchat
* Matches iOS LocationNotesView.swift exactly in style, colors, fonts, and text
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationNotesSheet(
geohash: String,
locationName: String?,
nickname: String?,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val isDark = isSystemInDarkTheme()
// iOS color scheme
val backgroundColor = if (isDark) Color.Black else Color.White
val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0)
// Managers
val notesManager = remember { LocationNotesManager.getInstance() }
val locationManager = remember { LocationChannelManager.getInstance(context) }
// State
val notes by notesManager.notes.observeAsState(emptyList())
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE)
val errorMessage by notesManager.errorMessage.observeAsState()
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
// SIMPLIFIED: Get count directly from notes list (no separate counter needed)
val count = notes.size
// Get location name (building or block) - matches iOS locationNames lookup
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() }
?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() }
// Input field state
var draft by remember { mutableStateOf("") }
val sendButtonEnabled = draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS
// Scroll state
val listState = rememberLazyListState()
// Effect to set geohash when sheet opens
LaunchedEffect(geohash) {
notesManager.setGeohash(geohash)
}
// Cleanup when sheet closes
DisposableEffect(Unit) {
onDispose {
notesManager.cancel()
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
modifier = modifier,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = backgroundColor,
contentColor = if (isDark) Color.White else Color.Black
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f)
) {
// Header section (matches iOS headerSection)
LocationNotesHeader(
geohash = geohash,
count = count,
locationName = displayLocationName,
state = state,
accentGreen = accentGreen,
backgroundColor = backgroundColor,
onClose = onDismiss
)
// ScrollView with notes content
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(backgroundColor)
) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
// Notes content (matches iOS notesContent)
when {
state == LocationNotesManager.State.NO_RELAYS -> {
item {
NoRelaysRow(
onRetry = { notesManager.refresh() }
)
}
}
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
item {
LoadingRow()
}
}
notes.isEmpty() -> {
item {
EmptyRow()
}
}
else -> {
items(notes, key = { it.id }) { note ->
NoteRow(note = note)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
// Error row (matches iOS errorRow)
errorMessage?.let { error ->
if (state != LocationNotesManager.State.NO_RELAYS) {
item {
ErrorRow(
message = error,
onDismiss = { notesManager.clearError() }
)
}
}
}
}
}
// Divider before input (matches iOS overlay)
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
thickness = 1.dp
)
// Input section (matches iOS inputSection)
LocationNotesInputSection(
draft = draft,
onDraftChange = { draft = it },
sendButtonEnabled = sendButtonEnabled,
accentGreen = accentGreen,
backgroundColor = backgroundColor,
onSend = {
val content = draft.trim()
if (content.isNotEmpty()) {
notesManager.send(content, nickname)
draft = ""
}
}
)
}
}
}
/**
* Header section - matches iOS headerSection exactly
* Shows: "#geohash • X notes", location name, description, and close button
*/
@Composable
private fun LocationNotesHeader(
geohash: String,
count: Int,
locationName: String?,
state: LocationNotesManager.State,
accentGreen: Color,
backgroundColor: Color,
onClose: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 12.dp)
) {
// Title row with close button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Localized title with ±1 and note count
Text(
text = pluralStringResource(
id = R.plurals.location_notes_title,
count = count,
geohash,
count
),
fontFamily = FontFamily.Monospace,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
// Close button - iOS style with xmark icon
Box(
modifier = Modifier
.size(32.dp)
.clickable(onClick = onClose),
contentAlignment = Alignment.Center
) {
Text(
text = "",
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Location name in green (building or block)
locationName?.let { name ->
if (name.isNotEmpty()) {
Text(
text = name,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = accentGreen
)
Spacer(modifier = Modifier.height(8.dp))
}
}
// Description
Text(
text = stringResource(R.string.location_notes_description),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
// Relays paused message if no relays
if (state == LocationNotesManager.State.NO_RELAYS) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_relays_unavailable),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
/**
* Note row - matches iOS noteRow exactly
* Shows @basename then timestamp, then content below
*/
@Composable
private fun NoteRow(note: LocationNotesManager.Note) {
// Extract baseName (before #suffix like iOS)
val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName
val ts = timestampText(note.createdAt)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
// First row: @nickname and timestamp
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "@$baseName",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
if (ts.isNotEmpty()) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = ts,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
Spacer(modifier = Modifier.height(2.dp))
// Second row: content
Text(
text = note.content,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
}
/**
* No relays row - matches iOS noRelaysRow
*/
@Composable
private fun NoRelaysRow(onRetry: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Text(
text = stringResource(R.string.location_notes_no_relays_title),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_no_relays_desc),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.retry),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onRetry)
)
}
}
/**
* Loading row - matches iOS loadingRow
*/
@Composable
private fun LoadingRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = stringResource(R.string.loading_location_notes),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
/**
* Empty row - matches iOS emptyRow
*/
@Composable
private fun EmptyRow() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Text(
text = stringResource(R.string.location_notes_empty_title),
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_empty_desc),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
/**
* Error row - matches iOS errorRow
*/
@Composable
private fun ErrorRow(message: String, onDismiss: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "",
fontSize = 12.sp
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = message,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.dismiss),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable(onClick = onDismiss)
)
}
}
/**
* Input section - matches main chat input exactly
*/
@Composable
private fun LocationNotesInputSection(
draft: String,
onDraftChange: (String) -> Unit,
sendButtonEnabled: Boolean,
accentGreen: Color,
backgroundColor: Color,
onSend: () -> Unit
) {
val isDark = isSystemInDarkTheme()
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing
) {
// Text input with placeholder overlay (matches main chat exactly)
Box(
modifier = Modifier.weight(1f)
) {
androidx.compose.foundation.text.BasicTextField(
value = draft,
onValueChange = onDraftChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
fontFamily = FontFamily.Monospace
),
cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
imeAction = androidx.compose.ui.text.input.ImeAction.Send
),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
onSend = { if (sendButtonEnabled) onSend() }
),
modifier = Modifier.fillMaxWidth()
)
// Placeholder when empty (matches main chat)
if (draft.isEmpty()) {
Text(
text = stringResource(R.string.location_notes_input_placeholder),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
)
}
}
// Send button - circular with icon (matches main chat exactly)
IconButton(
onClick = { if (sendButtonEnabled) onSend() },
enabled = sendButtonEnabled,
modifier = Modifier.size(32.dp)
) {
Box(
modifier = Modifier
.size(30.dp)
.background(
color = if (!sendButtonEnabled) {
colorScheme.onSurface.copy(alpha = 0.3f)
} else {
accentGreen.copy(alpha = 0.75f)
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = stringResource(R.string.send_message),
modifier = Modifier.size(20.dp),
tint = if (!sendButtonEnabled) {
colorScheme.onSurface.copy(alpha = 0.5f)
} else if (isDark) {
Color.Black // Black arrow on green in dark theme
} else {
Color.White // White arrow on green in light theme
}
)
}
}
}
}
/**
* Timestamp text - matches iOS timestampText exactly
* Shows relative time for < 7 days, absolute date otherwise
*/
private fun timestampText(createdAt: Int): String {
val date = Date(createdAt * 1000L)
val now = Date()
// Calculate days difference
val calendar = Calendar.getInstance()
calendar.time = date
val dateDay = calendar.get(Calendar.DAY_OF_YEAR)
val dateYear = calendar.get(Calendar.YEAR)
calendar.time = now
val nowDay = calendar.get(Calendar.DAY_OF_YEAR)
val nowYear = calendar.get(Calendar.YEAR)
val daysDiff = if (dateYear == nowYear) {
nowDay - dateDay
} else {
// Simplified: just check if less than 7 days by timestamp
val diff = (now.time - date.time) / (1000 * 60 * 60 * 24)
diff.toInt()
}
return if (daysDiff < 7) {
// Relative formatting (abbreviated)
val diffMillis = now.time - date.time
val diffSeconds = diffMillis / 1000
when {
diffSeconds < 60 -> "" // Don't show "just now" in iOS
diffSeconds < 3600 -> {
val minutes = (diffSeconds / 60).toInt()
"${minutes}m ago"
}
diffSeconds < 86400 -> {
val hours = (diffSeconds / 3600).toInt()
"${hours}h ago"
}
else -> {
val days = (diffSeconds / 86400).toInt()
"${days}d ago"
}
}
} else {
// Absolute date formatting
val sameYear = dateYear == nowYear
val formatter = if (sameYear) {
SimpleDateFormat("MMM d", Locale.getDefault())
} else {
SimpleDateFormat("MMM d, y", Locale.getDefault())
}
formatter.format(date)
}
}
@@ -0,0 +1,100 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
/**
* Presenter component for LocationNotesSheet
* Handles sheet presentation logic with proper error states
* Extracts this logic from ChatScreen for better separation of concerns
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationNotesSheetPresenter(
viewModel: ChatViewModel,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
val nickname by viewModel.nickname.observeAsState("")
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
if (buildingGeohash != null) {
// Get location name from locationManager
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
val locationName = locationNames[GeohashChannelLevel.BUILDING]
?: locationNames[GeohashChannelLevel.BLOCK]
LocationNotesSheet(
geohash = buildingGeohash,
locationName = locationName,
nickname = nickname,
onDismiss = onDismiss
)
} else {
// No building geohash available - show error state (matches iOS)
LocationNotesErrorSheet(
onDismiss = onDismiss,
locationManager = locationManager
)
}
}
/**
* Error sheet when location is unavailable
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LocationNotesErrorSheet(
onDismiss: () -> Unit,
locationManager: LocationChannelManager
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Location Unavailable",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Location permission is required for notes",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = {
// UNIFIED FIX: Enable location services first (user toggle)
locationManager.enableLocationServices()
// Then request location channels (which will also request permission if needed)
locationManager.enableLocationChannels()
locationManager.refreshChannels()
}) {
Text("Enable Location")
}
}
}
}
@@ -20,7 +20,7 @@ class MediaSendingManager(
) {
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB limit
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
}
// Track in-flight transfer progress: transferId -> messageId and reverse
@@ -275,6 +275,9 @@ class MediaSendingManager(
if (transferId != null) {
val cancelled = meshService.cancelFileTransfer(transferId)
if (cancelled) {
// Try to remove cached local file for this message (if any)
runCatching { findMessagePathById(messageId)?.let { java.io.File(it).delete() } }
// Remove the message from chat upon explicit cancel
messageManager.removeMessageById(messageId)
synchronized(transferMessageMap) {
@@ -285,6 +288,20 @@ class MediaSendingManager(
}
}
private fun findMessagePathById(messageId: String): String? {
// Search main timeline
state.getMessagesValue().firstOrNull { it.id == messageId }?.content?.let { return it }
// Search private chats
state.getPrivateChatsValue().values.forEach { list ->
list.firstOrNull { it.id == messageId }?.content?.let { return it }
}
// Search channel messages
state.getChannelMessagesValue().values.forEach { list ->
list.firstOrNull { it.id == messageId }?.content?.let { return it }
}
return null
}
/**
* Update progress for a transfer
*/
@@ -43,6 +43,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import com.bitchat.android.ui.media.FileMessageItem
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.R
import androidx.compose.ui.res.stringResource
// VoiceNotePlayer moved to com.bitchat.android.ui.media.VoiceNotePlayer
@@ -334,11 +337,11 @@ fun MessageItem(
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(14.dp))
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
}
}
} else {
Text(text = "[file unavailable]", fontFamily = FontFamily.Monospace, color = Color.Gray)
Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
}
}
}
@@ -471,7 +474,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
when (status) {
is DeliveryStatus.Sending -> {
Text(
text = "",
text = stringResource(R.string.status_sending),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
@@ -479,7 +482,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
is DeliveryStatus.Sent -> {
// Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity)
Text(
text = "",
text = stringResource(R.string.status_pending),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
@@ -487,14 +490,14 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
is DeliveryStatus.Delivered -> {
// Single check for Delivered (matches iOS expectations)
Text(
text = "",
text = stringResource(R.string.status_sent),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.8f)
)
}
is DeliveryStatus.Read -> {
Text(
text = "✓✓",
text = stringResource(R.string.status_delivered),
fontSize = 10.sp,
color = Color(0xFF007AFF), // Blue
fontWeight = FontWeight.Bold
@@ -502,7 +505,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
}
is DeliveryStatus.Failed -> {
Text(
text = "",
text = stringResource(R.string.status_failed),
fontSize = 10.sp,
color = Color.Red.copy(alpha = 0.8f)
)
@@ -510,7 +513,7 @@ fun DeliveryStatusIcon(status: DeliveryStatus) {
is DeliveryStatus.PartiallyDelivered -> {
// Show a single subdued check without numeric label
Text(
text = "",
text = stringResource(R.string.status_sent),
fontSize = 10.sp,
color = colorScheme.primary.copy(alpha = 0.6f)
)
@@ -13,8 +13,8 @@ class MessageManager(private val state: ChatState) {
// Message deduplication - FIXED: Prevent duplicate messages from dual connection paths
private val processedUIMessages = Collections.synchronizedSet(mutableSetOf<String>())
private val recentSystemEvents = Collections.synchronizedMap(mutableMapOf<String, Long>())
private val MESSAGE_DEDUP_TIMEOUT = 30000L // 30 seconds
private val SYSTEM_EVENT_DEDUP_TIMEOUT = 5000L // 5 seconds
private val MESSAGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.MESSAGE_DEDUP_TIMEOUT_MS // 30 seconds
private val SYSTEM_EVENT_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.UI.SYSTEM_EVENT_DEDUP_TIMEOUT_MS // 5 seconds
// MARK: - Public Message Management
@@ -37,6 +37,7 @@ class MessageManager(private val state: ChatState) {
fun clearMessages() {
state.setMessages(emptyList())
state.setChannelMessages(emptyMap())
}
// MARK: - Channel Message Management
@@ -42,7 +42,7 @@ class NotificationManager(
private const val SUMMARY_NOTIFICATION_ID = 999
private const val GEOHASH_SUMMARY_NOTIFICATION_ID = 998
private const val ACTIVE_PEERS_NOTIFICATION_ID = 997
private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = 300_000L
private const val ACTIVE_PEERS_NOTIFICATION_TIME_INTERVAL = com.bitchat.android.util.AppConstants.UI.ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS
// Intent extras for notification handling
const val EXTRA_OPEN_PRIVATE_CHAT = "open_private_chat"
@@ -258,7 +258,10 @@ class NotificationManager(
}
if (messageCount > 5) {
style.setSummaryText("and ${messageCount - 5} more")
val extra = messageCount - 5
style.setSummaryText(context.resources.getQuantityString(
R.plurals.notification_and_more, extra, extra
))
}
builder.setStyle(style)
@@ -291,11 +294,11 @@ class NotificationManager(
)
// Build notification content
val contentTitle = "👥 bitchatters nearby!"
val contentTitle = context.getString(R.string.notification_active_peers_title)
val contentText = if (peersSize == 1) {
"1 person around"
context.getString(R.string.notification_active_peers_one)
} else {
"$peersSize people around"
context.getString(R.string.notification_active_peers_many, peersSize)
}
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -331,8 +334,8 @@ class NotificationManager(
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("bitchat")
.setContentText("$totalMessages messages from $senderCount people")
.setContentTitle(context.getString(R.string.app_name))
.setContentText(context.getString(R.string.notification_messages_from_people, totalMessages, senderCount))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -342,7 +345,7 @@ class NotificationManager(
// Add inbox style showing recent senders
val style = NotificationCompat.InboxStyle()
.setBigContentTitle("New Messages")
.setBigContentTitle(context.getString(R.string.notification_new_location_messages))
pendingNotifications.entries.take(5).forEach { (peerID, notifications) ->
val latestNotif = notifications.last()
@@ -356,7 +359,7 @@ class NotificationManager(
}
if (pendingNotifications.size > 5) {
style.setSummaryText("and ${pendingNotifications.size - 5} more conversations")
style.setSummaryText(context.getString(R.string.notification_more_conversations, pendingNotifications.size - 5))
}
builder.setStyle(style)
@@ -459,15 +462,15 @@ class NotificationManager(
// Build notification content with location name if available
val geohashDisplay = latestNotification.locationName?.let { "$it (#$geohash)" } ?: "#$geohash"
val contentTitle = when {
mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> "Mentioned in $geohashDisplay (+${messageCount - 1} more)"
mentionCount > 0 -> if (mentionCount == 1) "Mentioned in $geohashDisplay" else "$mentionCount mentions in $geohashDisplay"
firstMessageCount > 0 -> "New activity in $geohashDisplay"
else -> "Messages in $geohashDisplay"
mentionCount > 0 && firstMessageCount > 0 && messageCount > 1 -> context.getString(R.string.notification_mentions_in_more, geohashDisplay, messageCount - 1)
mentionCount > 0 -> if (mentionCount == 1) context.getString(R.string.notification_mentions_in, geohashDisplay) else context.getString(R.string.notification_mentions_in_plural, mentionCount, geohashDisplay)
firstMessageCount > 0 -> context.getString(R.string.notification_new_activity_in, geohashDisplay)
else -> context.getString(R.string.notification_messages_in, geohashDisplay)
}
val contentText = when {
latestNotification.isMention -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}"
latestNotification.isFirstMessage -> "${latestNotification.senderNickname} joined the conversation"
latestNotification.isFirstMessage -> context.getString(R.string.notification_joined_conversation, latestNotification.senderNickname)
else -> "${latestNotification.senderNickname}: ${latestNotification.messageContent}"
}
@@ -503,7 +506,8 @@ class NotificationManager(
}
if (messageCount > 5) {
style.setSummaryText("and ${messageCount - 5} more")
val extra = messageCount - 5
style.setSummaryText(context.resources.getQuantityString(R.plurals.notification_and_more, extra, extra))
}
builder.setStyle(style)
@@ -543,12 +547,12 @@ class NotificationManager(
)
val contentTitle = if (totalMentions > 0) {
"bitchat - $totalMentions mentions"
context.getString(R.string.notification_geohash_summary_title_mentions, totalMentions)
} else {
"bitchat - location chats"
context.getString(R.string.notification_geohash_summary_title)
}
val contentText = "$totalMessages messages from $geohashCount locations"
val contentText = context.getString(R.string.notification_geohash_summary_text, totalMessages, geohashCount)
val builder = NotificationCompat.Builder(context, GEOHASH_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
@@ -563,7 +567,7 @@ class NotificationManager(
// Add inbox style showing recent geohashes
val style = NotificationCompat.InboxStyle()
.setBigContentTitle("New Location Messages")
.setBigContentTitle(context.getString(R.string.notification_new_messages))
pendingGeohashNotifications.entries.take(5).forEach { (geohash, notifications) ->
val mentionCount = notifications.count { it.isMention }
@@ -579,7 +583,7 @@ class NotificationManager(
}
if (pendingGeohashNotifications.size > 5) {
style.setSummaryText("and ${pendingGeohashNotifications.size - 5} more locations")
style.setSummaryText(context.getString(R.string.notification_more_locations, pendingGeohashNotifications.size - 5))
}
builder.setStyle(style)
@@ -676,9 +680,9 @@ class NotificationManager(
// Build notification content
val contentTitle = if (messageCount == 1) {
"Mentioned in Mesh Chat"
context.getString(R.string.notification_mesh_mention_title_singular)
} else {
"$messageCount mentions in Mesh Chat"
context.getString(R.string.notification_mesh_mention_title_plural, messageCount)
}
val contentText = "${latestNotification.senderNickname}: ${latestNotification.messageContent}"
@@ -710,7 +714,8 @@ class NotificationManager(
}
if (messageCount > 5) {
style.setSummaryText("and ${messageCount - 5} more")
val extra = messageCount - 5
style.setSummaryText(context.resources.getQuantityString(R.plurals.notification_and_more, extra, extra))
}
builder.setStyle(style)
@@ -0,0 +1,28 @@
package com.bitchat.android.ui
import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.activity.ComponentActivity
import com.bitchat.android.utils.DeviceUtils
/**
* Base activity that automatically sets orientation based on device type.
* Tablets can rotate to landscape, phones are locked to portrait.
*/
abstract class OrientationAwareActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setOrientationBasedOnDeviceType()
}
private fun setOrientationBasedOnDeviceType() {
requestedOrientation = if (DeviceUtils.isTablet(this)) {
// Allow all orientations on tablets
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
} else {
// Lock to portrait on phones
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
}
@@ -15,6 +15,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.nostr.NostrProofOfWork
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.nostr.PoWPreferenceManager
/**
@@ -54,7 +56,7 @@ fun PoWStatusIndicator(
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Mining PoW",
contentDescription = stringResource(R.string.cd_mining_pow),
tint = Color(0xFFFF9500), // Orange for mining
modifier = Modifier
.size(12.dp)
@@ -63,7 +65,7 @@ fun PoWStatusIndicator(
} else {
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "PoW Enabled",
contentDescription = stringResource(R.string.cd_pow_enabled),
tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready
modifier = Modifier.size(12.dp)
)
@@ -85,7 +87,7 @@ fun PoWStatusIndicator(
// PoW icon
Icon(
imageVector = Icons.Filled.Security,
contentDescription = "Proof of Work",
contentDescription = stringResource(R.string.cd_proof_of_work),
tint = if (isMining) Color(0xFFFF9500) else {
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
},
@@ -95,9 +97,9 @@ fun PoWStatusIndicator(
// Status text
Text(
text = if (isMining) {
"mining..."
stringResource(R.string.pow_mining_ellipsis)
} else {
"pow: ${powDifficulty}bit"
stringResource(R.string.pow_label_format, powDifficulty)
},
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
@@ -109,7 +111,7 @@ fun PoWStatusIndicator(
// Time estimate
if (!isMining && powDifficulty > 0) {
Text(
text = "(~${NostrProofOfWork.estimateMiningTime(powDifficulty)})",
text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)),
fontSize = 9.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.5f)
@@ -237,7 +237,7 @@ fun ChannelsSection(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Leave channel",
contentDescription = stringResource(com.bitchat.android.R.string.cd_leave_channel),
modifier = Modifier.size(14.dp),
tint = colorScheme.onSurface.copy(alpha = 0.5f)
)
@@ -552,7 +552,7 @@ private fun PeerItem(
// Show mail icon for unread DMs (iOS orange)
Icon(
imageVector = Icons.Filled.Email,
contentDescription = "Unread message",
contentDescription = stringResource(com.bitchat.android.R.string.cd_unread_message),
modifier = Modifier.size(16.dp),
tint = Color(0xFFFF9500) // iOS orange
)
@@ -562,7 +562,7 @@ private fun PeerItem(
// Purple globe to indicate Nostr availability
Icon(
imageVector = Icons.Filled.Public,
contentDescription = "Reachable via Nostr",
contentDescription = stringResource(com.bitchat.android.R.string.cd_reachable_via_nostr),
modifier = Modifier.size(16.dp),
tint = Color(0xFF9C27B0) // Purple
)
@@ -571,7 +571,7 @@ private fun PeerItem(
Icon(
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
imageVector = Icons.Outlined.Circle,
contentDescription = "Offline favorite",
contentDescription = stringResource(com.bitchat.android.R.string.cd_offline_favorite),
modifier = Modifier.size(16.dp),
tint = Color.Gray
)
@@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.unit.dp
@@ -129,7 +130,7 @@ fun VoiceRecordButton(
) {
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = "Record voice note",
contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice),
tint = Color.Black,
modifier = Modifier.size(20.dp)
)
@@ -29,6 +29,8 @@ import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
@Composable
fun MeshTopologySection() {
@@ -182,10 +184,10 @@ fun DebugSettingsSheet(
item {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
Text("debug tools", fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_tools), fontFamily = FontFamily.Monospace, fontSize = 18.sp, fontWeight = FontWeight.Medium)
}
Text(
text = "developer utilities for diagnostics and control",
text = stringResource(R.string.debug_tools_desc),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -198,12 +200,12 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851))
Text("verbose logging", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) })
}
Text(
"logs peer joins/leaves, connection direction, packet routing and relays",
stringResource(R.string.debug_verbose_hint),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -223,10 +225,10 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Text("bluetooth roles", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("gatt server", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = gattServerEnabled, onCheckedChange = {
manager.setGattServerEnabled(it)
scope.launch {
@@ -235,9 +237,9 @@ fun DebugSettingsSheet(
})
}
val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER }
Text("connections: $serverCount / $maxServer", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max server", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxServer.toFloat(),
onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) },
@@ -246,7 +248,7 @@ fun DebugSettingsSheet(
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("gatt client", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
Switch(checked = gattClientEnabled, onCheckedChange = {
manager.setGattClientEnabled(it)
scope.launch {
@@ -255,9 +257,9 @@ fun DebugSettingsSheet(
})
}
val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT }
Text("connections: $clientCount / $maxClient", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max client", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxClient.toFloat(),
onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) },
@@ -266,9 +268,9 @@ fun DebugSettingsSheet(
)
}
val overallCount = connectedDevices.size
Text("connections: $overallCount / $maxOverall", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
Slider(
value = maxOverall.toFloat(),
onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) },
@@ -277,7 +279,7 @@ fun DebugSettingsSheet(
)
}
Text(
"turn roles on/off and close all connections when disabled",
stringResource(R.string.debug_roles_hint),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
@@ -292,12 +294,12 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
Text("packet relay", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = packetRelayEnabled, onCheckedChange = { manager.setPacketRelayEnabled(it) })
}
Text("since start: ${relayStats.totalRelaysCount}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Text("last 10s: ${relayStats.last10SecondRelays} • 1m: ${relayStats.lastMinuteRelays} • 15m: ${relayStats.last15MinuteRelays}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Text(stringResource(R.string.debug_since_start_fmt, relayStats.totalRelaysCount), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
Text(stringResource(R.string.debug_relays_window_fmt, relayStats.last10SecondRelays, relayStats.lastMinuteRelays, relayStats.last15MinuteRelays), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
// Realtime graph: per-second relays, full-width canvas, bottom-up bars, fast decay
var series by remember { mutableStateOf(List(60) { 0f }) }
LaunchedEffect(isPresented) {
@@ -428,29 +430,32 @@ fun MeshTopologySection() {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0))
Text("sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Text("max packets per sync: $seenCapacity", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99)
Text("max GCS filter size: $gcsMaxBytes bytes (1281024)", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0)
Text("target FPR: ${String.format("%.2f", gcsFpr)}%", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49)
val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) }
val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) }
Text("derived P: $p • est. max elements: $nmax", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
}
}
// Connected devices
item {
Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
Text("connected devices", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() }
Text("our device id: ${localAddr ?: "unknown"}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
if (connectedDevices.isEmpty()) {
Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
@@ -458,11 +463,11 @@ fun MeshTopologySection() {
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("${dev.peerID ?: "unknown"}${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) "as server (we host)" else "as client (we connect)"
Text("${dev.nickname ?: ""}RSSI: ${dev.rssi ?: "?"}$roleLabel${if (dev.isDirectConnection) " • direct" else ""}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text((dev.peerID ?: stringResource(R.string.unknown)) + "${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client)
Text("${dev.nickname ?: ""}" + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + "$roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
Text("disconnect", color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
meshService.connectionManager.disconnectAddress(dev.deviceAddress)
})
}
@@ -479,19 +484,19 @@ fun MeshTopologySection() {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Text("recent scan results", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
if (scanResults.isEmpty()) {
Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
scanResults.forEach { res ->
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("${res.peerID ?: "unknown"}${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text("${res.deviceName ?: ""} • RSSI: ${res.rssi}", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text((res.peerID ?: stringResource(R.string.unknown)) + "${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
Text("connect", color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
meshService.connectionManager.connectToAddress(res.deviceAddress)
})
}
@@ -508,9 +513,9 @@ fun MeshTopologySection() {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
Text("debug console", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Text("clear", color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
manager.clearDebugMessages()
})
}
@@ -19,6 +19,8 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
@@ -91,7 +93,7 @@ fun AudioMessageItem(
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(16.dp))
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
@@ -19,6 +19,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,6 +30,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.model.BitchatFilePacket
@@ -60,7 +63,7 @@ fun FileMessageItem(
// File icon
Icon(
imageVector = Icons.Filled.Description,
contentDescription = "File",
contentDescription = stringResource(com.bitchat.android.R.string.cd_file),
tint = getFileIconColor(packet.fileName),
modifier = Modifier.size(32.dp)
)
@@ -70,13 +73,13 @@ fun FileMessageItem(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// File name
Text(
text = packet.fileName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = packet.fileName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// File details
Row(
@@ -14,6 +14,8 @@ import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import com.bitchat.android.features.file.FileUtils
@Composable
@@ -44,7 +46,7 @@ fun FilePickerButton(
) {
Icon(
imageVector = Icons.Filled.Attachment,
contentDescription = "Pick file",
contentDescription = stringResource(R.string.cd_pick_file),
tint = Color.Gray,
modifier = Modifier.size(20.dp).rotate(90f)
)
@@ -28,6 +28,9 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import kotlinx.coroutines.delay
/**
@@ -77,7 +80,7 @@ fun FileSendingAnimation(
// File icon
Icon(
imageVector = Icons.Filled.Description,
contentDescription = "File",
contentDescription = stringResource(R.string.cd_file),
tint = Color(0xFF00C851), // Green like app theme
modifier = Modifier.size(32.dp)
)
@@ -102,7 +105,7 @@ fun FileSendingAnimation(
// Blinking cursor (only if not fully revealed)
if (animatedChars < fileName.length && showCursor) {
androidx.compose.material3.Text(
text = "_",
text = stringResource(R.string.underscore),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
color = Color.White
@@ -132,10 +135,12 @@ private fun FileProgressBars(
val filledBars = (progress * bars).toInt()
// Create a matrix-style progress bar string
val ctx = LocalContext.current
val progressString = buildString {
val brackets = ctx.getString(R.string.progress_bar_brackets, "", 0)
append("[")
for (i in 0 until bars) {
append(if (i < filledBars) "" else "")
append(if (i < filledBars) ctx.getString(R.string.progress_filled) else ctx.getString(R.string.progress_empty))
}
append("] ")
append("${(progress * 100).toInt()}%")
@@ -20,9 +20,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.bitchat.android.R
import com.bitchat.android.features.file.FileUtils
import com.bitchat.android.model.BitchatFilePacket
import kotlinx.coroutines.launch
@@ -54,7 +56,7 @@ fun FileViewerDialog(
) {
// File received header
Text(
text = "📎 File Received",
text = stringResource(R.string.file_viewer_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
@@ -65,17 +67,17 @@ fun FileViewerDialog(
horizontalAlignment = Alignment.Start
) {
Text(
text = "📄 ${packet.fileName}",
text = stringResource(R.string.file_viewer_name, packet.fileName),
style = MaterialTheme.typography.bodyLarge,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
)
Text(
text = "📏 Size: ${FileUtils.formatFileSize(packet.fileSize)}",
text = stringResource(R.string.file_viewer_size, FileUtils.formatFileSize(packet.fileSize)),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "🏷️ Type: ${packet.mimeType}",
text = stringResource(R.string.file_viewer_type, packet.mimeType),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -108,7 +110,7 @@ fun FileViewerDialog(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("📂 Open / Save")
Text(stringResource(R.string.file_viewer_open_save))
}
// Dismiss button
@@ -119,13 +121,13 @@ fun FileViewerDialog(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("❌ Close")
Text(stringResource(R.string.close_with_emoji))
}
}
}
}
}
}
}
/**
* Attempts to open a file using system viewers or save to device
@@ -32,6 +32,8 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
import java.io.File
/**
@@ -75,13 +77,13 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
bmp?.let {
androidx.compose.foundation.Image(
bitmap = it.asImageBitmap(),
contentDescription = "Image ${page + 1} of ${imagePaths.size}",
contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
} ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = "Image unavailable", color = Color.White)
Text(text = stringResource(R.string.image_unavailable), color = Color.White)
}
}
}
@@ -96,7 +98,7 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = "${(pagerState.currentPage ?: 0) + 1} / ${imagePaths.size}",
text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size),
color = Color.White,
fontSize = 14.sp,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
@@ -118,7 +120,7 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
.clickable { saveToDownloads(context, imagePaths[pagerState.currentPage].toString()) },
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(Icons.Filled.Download, "Save current image", tint = Color.White)
androidx.compose.material3.Icon(Icons.Filled.Download, stringResource(R.string.cd_save_current_image), tint = Color.White)
}
Spacer(Modifier.width(12.dp))
Box(
@@ -128,7 +130,7 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
.clickable { onClose() },
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Icon(Icons.Filled.Close, "Close", tint = Color.White)
androidx.compose.material3.Icon(Icons.Filled.Close, stringResource(R.string.cd_close), tint = Color.White)
}
}
}
@@ -161,10 +163,10 @@ private fun saveToDownloads(context: android.content.Context, path: String) {
context.contentResolver.update(uri, v2, null, null)
}
// Show toast message indicating the image has been saved
Toast.makeText(context, "Image saved to Downloads", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT).show()
}
}.onFailure {
// Optionally handle failure case (e.g., show error toast)
Toast.makeText(context, "Failed to save image", Toast.LENGTH_SHORT).show()
Toast.makeText(context, context.getString(R.string.toast_failed_to_save_image), Toast.LENGTH_SHORT).show()
}
}
@@ -16,15 +16,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.font.FontFamily
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
@@ -113,7 +115,7 @@ fun ImageMessageItem(
// Fully revealed image
Image(
bitmap = img,
contentDescription = "Image",
contentDescription = stringResource(com.bitchat.android.R.string.cd_image),
modifier = Modifier
.widthIn(max = 300.dp)
.aspectRatio(aspect)
@@ -137,13 +139,13 @@ fun ImageMessageItem(
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
Icon(imageVector = Icons.Filled.Close, contentDescription = "Cancel", tint = Color.White, modifier = Modifier.size(14.dp))
Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(com.bitchat.android.R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
}
}
}
}
} else {
Text(text = "[image unavailable]", fontFamily = FontFamily.Monospace, color = Color.Gray)
Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
}
}
}
@@ -1,25 +1,37 @@
package com.bitchat.android.ui.media
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Camera
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import com.bitchat.android.features.media.ImageUtils
import java.io.File
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImagePickerButton(
modifier: Modifier = Modifier,
onImageReady: (String) -> Unit
) {
val context = LocalContext.current
var capturedImagePath by remember { mutableStateOf<String?>(null) }
val imagePicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: android.net.Uri? ->
@@ -28,17 +40,57 @@ fun ImagePickerButton(
if (!outPath.isNullOrBlank()) onImageReady(outPath)
}
}
val takePictureLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
val path = capturedImagePath
if (success && !path.isNullOrBlank()) {
// Downscale + correct orientation, then send; delete original
val outPath = com.bitchat.android.features.media.ImageUtils.downscalePathAndSaveToAppFiles(context, path)
if (!outPath.isNullOrBlank()) {
onImageReady(outPath)
}
runCatching { File(path).delete() }
} else {
// Cleanup on cancel/failure
path?.let { runCatching { File(it).delete() } }
}
capturedImagePath = null
}
IconButton(
onClick = { imagePicker.launch("image/*") },
modifier = modifier.size(32.dp)
fun startCameraCapture() {
try {
val dir = File(context.filesDir, "images/outgoing").apply { mkdirs() }
val file = File(dir, "camera_${System.currentTimeMillis()}.jpg")
val uri = FileProvider.getUriForFile(
context,
context.packageName + ".fileprovider",
file
)
capturedImagePath = file.absolutePath
takePictureLauncher.launch(uri)
} catch (_: Exception) {
// Ignore errors; no-op
}
}
Box(
modifier = modifier
.size(32.dp)
.combinedClickable(
onClick = { imagePicker.launch("image/*") },
onLongClick = { startCameraCapture() }
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Photo,
contentDescription = "Pick image",
imageVector = Icons.Filled.PhotoCamera,
contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
tint = Color.Gray,
modifier = Modifier.size(20.dp)
)
}
}
// No custom preview: native camera UI handles confirmation
}
@@ -26,6 +26,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.compose.ui.res.stringResource
import com.bitchat.android.R
/**
* Media picker that offers image and file options
@@ -53,7 +55,7 @@ fun MediaPickerOptions(
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Pick media",
contentDescription = stringResource(R.string.cd_pick_media),
tint = Color.Black,
modifier = Modifier.size(20.dp)
)
@@ -98,7 +100,7 @@ fun MediaPickerOptions(
modifier = Modifier.size(16.dp)
)
androidx.compose.material3.Text(
text = "Image",
text = stringResource(R.string.media_type_image),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
@@ -126,7 +128,7 @@ fun MediaPickerOptions(
modifier = Modifier.size(16.dp)
)
androidx.compose.material3.Text(
text = "File",
text = stringResource(R.string.media_type_file),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
@@ -7,7 +7,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Base font size for consistent scaling across the app
const val BASE_FONT_SIZE = 15 // sp - increased from 14sp for better readability
internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability
// Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency
val Typography = Typography(