diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9516695d6..816c36e21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,8 @@ android:label="@string/app_name" android:supportsRtl="true" android:theme="@style/Theme.BitchatAndroid" + android:localeConfig="@xml/locales_config" + android:enableOnBackInvokedCallback="true" tools:targetApi="31"> Unit)? = null, modifier: Modifier = Modifier ) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (isPresented) { + ModalBottomSheet( + modifier = modifier.statusBarsPadding(), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.background, + dragHandle = null + ) { + AboutSheetContent( + onDismiss = onDismiss, + onShowDebug = onShowDebug + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutSheetContent( + onDismiss: () -> Unit, + onShowDebug: (() -> Unit)? = null, + ) { + val context = LocalContext.current + val lazyListState = rememberLazyListState() + val isScrolled by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) + + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 80.dp, bottom = 20.dp) + ) { + item(key = "header") { + ScreenHeader(modifier = Modifier.padding(horizontal = 24.dp)) + } + items(featuresData, key = { it.title }) { rowData -> + FeatureRow( + data = rowData, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + + // Appearance Section + item(key = "appearance_section") { + SectionHeader(titleRes = R.string.appearance_title) + AppearanceSection( + context = context, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + + item(key = "pow_section") { + SectionHeader(titleRes = R.string.pow_title) + ProofOfWorkSection( + modifier = Modifier.padding(horizontal = 24.dp), + context = context + ) + } + + // Network Section + item(key = "network_section") { + SectionHeader(titleRes = R.string.network_title) + NetworkSection( + modifier = Modifier.padding(horizontal = 24.dp), + context = context, + ) + } + + // Emergency Warning Section + item(key = "warning_section") { + WarningSection( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 24.dp) + ) + } + + item(key = "footer") { + Footer( + onShowDebug = onShowDebug, + modifier = Modifier.padding(horizontal = 24.dp) + ) + } + } + + TopBar( + alpha = topBarAlpha, + onDismiss = onDismiss, + modifier = Modifier.align(Alignment.TopCenter) + ) + } +} + +@Composable +private fun ScreenHeader(modifier: Modifier = Modifier) { val context = LocalContext.current - // Get version name from package info val versionName = remember { try { @@ -47,442 +177,355 @@ fun AboutSheet( "1.0.0" // fallback version } } - - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = false + Column( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + text = stringResource(R.string.app_name), + style = TextStyle( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ), + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = stringResource(R.string.version_prefix, versionName ?: "1.0.0"), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.5f), + style = MaterialTheme.typography.bodySmall.copy( + baselineShift = BaselineShift(0.2f) + ) + ) + } + Text( + text = stringResource(R.string.about_sheet_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) + ) + } +} + +@Composable +private fun SectionHeader(@StringRes titleRes: Int) { + Text( + text = stringResource(titleRes).uppercase(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + modifier = Modifier + .padding(horizontal = 24.dp) + .padding(top = 24.dp, bottom = 8.dp) ) - - // Color scheme matching LocationChannelsSheet +} + +@Composable +private fun FeatureRow(data: InfoRow, modifier: Modifier = Modifier) { + Row( + verticalAlignment = Alignment.Top, + modifier = modifier.padding(vertical = 8.dp) + ) { + Icon( + imageVector = data.icon, + contentDescription = stringResource(data.title), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(top = 2.dp) + .size(20.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text( + text = stringResource(data.title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(data.description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) + ) + } + } +} + +@Composable +private fun AppearanceSection( + context: Context, + modifier: Modifier = Modifier +) { + val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() + + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = themePref == com.bitchat.android.ui.theme.ThemePreference.System, + onClick = { + com.bitchat.android.ui.theme.ThemePreferenceManager.set( + context, + com.bitchat.android.ui.theme.ThemePreference.System + ) + }, + label = { + Text( + stringResource(R.string.theme_system), + fontFamily = FontFamily.Monospace + ) + } + ) + FilterChip( + selected = themePref == com.bitchat.android.ui.theme.ThemePreference.Light, + onClick = { + com.bitchat.android.ui.theme.ThemePreferenceManager.set( + context, + com.bitchat.android.ui.theme.ThemePreference.Light + ) + }, + label = { + Text( + stringResource(R.string.theme_light), + fontFamily = FontFamily.Monospace + ) + } + ) + FilterChip( + selected = themePref == com.bitchat.android.ui.theme.ThemePreference.Dark, + onClick = { + com.bitchat.android.ui.theme.ThemePreferenceManager.set( + context, + com.bitchat.android.ui.theme.ThemePreference.Dark + ) + }, + label = { Text(stringResource(R.string.theme_dark), fontFamily = FontFamily.Monospace) } + ) + } +} + +@Composable +private fun ProofOfWorkSection(modifier: Modifier = Modifier, context: Context) { + LaunchedEffect(Unit) { + PoWPreferenceManager.init(context) + } + + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardBlue = Color(0xFF007AFF) // iOS blue - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - - if (isPresented) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - modifier = modifier + + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically ) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Header - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + FilterChip( + selected = !powEnabled, + onClick = { PoWPreferenceManager.setPowEnabled(false) }, + label = { Text(stringResource(R.string.pow_off), fontFamily = FontFamily.Monospace) } + ) + FilterChip( + selected = powEnabled, + onClick = { PoWPreferenceManager.setPowEnabled(true) }, + label = { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Bottom - ) { - Text( - text = "bitchat", - fontSize = 18.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface - ) - - Text( - text = "v$versionName", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f), - style = MaterialTheme.typography.bodySmall.copy( - baselineShift = BaselineShift(0.1f) - ) - ) + Text(stringResource(R.string.pow_on), fontFamily = FontFamily.Monospace) + if (powEnabled) { + val statusColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + Surface(color = statusColor, shape = CircleShape) { + Box(Modifier.size(8.dp)) + } } - - Text( - text = "decentralized mesh messaging with end-to-end encryption", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - } - } - - // Features section - item { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - FeatureCard( - icon = Icons.Filled.Bluetooth, - iconColor = standardBlue, - title = "offline mesh chat", - description = "communicate directly via bluetooth le without internet or servers. messages relay through nearby devices to extend range.", - modifier = Modifier.fillMaxWidth() - ) - - FeatureCard( - icon = Icons.Filled.Public, - iconColor = standardGreen, - title = "online geohash channels", - description = "connect with people in your area using geohash-based channels. extend the mesh using public internet relays.", - modifier = Modifier.fillMaxWidth() - ) - - FeatureCard( - icon = Icons.Filled.Lock, - iconColor = if (isDark) Color(0xFFFFD60A) else Color(0xFFF5A623), - title = "end-to-end encryption", - description = "private messages are encrypted. channel messages are public.", - modifier = Modifier.fillMaxWidth() - ) } } + ) + } - // Appearance section (theme toggle) - item { - val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState() - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "appearance", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface.copy(alpha = 0.8f) - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - 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) } - ) - 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) } - ) - 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) } - ) - } - } - } + Text( + text = stringResource(R.string.pow_description), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) - // Proof of Work section - item { - val context = LocalContext.current - - // Initialize PoW preferences if not already done - LaunchedEffect(Unit) { - PoWPreferenceManager.init(context) - } - - val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() - val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() - + if (powEnabled) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource( + R.string.pow_difficulty_label, + powDifficulty, + NostrProofOfWork.estimateMiningTime(powDifficulty) + ), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + + Slider( + value = powDifficulty.toFloat(), + onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, + valueRange = 0f..32f, + steps = 33, // 33 discrete values (0-32) + colors = SliderDefaults.colors( + thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + ) + ) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp) + ) { Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = "proof of work", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface.copy(alpha = 0.8f) + text = stringResource( + R.string.pow_difficulty_attempts, + powDifficulty, + NostrProofOfWork.estimateWork(powDifficulty) + ), + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurface.copy(alpha = 0.7f) ) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - FilterChip( - selected = !powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(false) }, - label = { Text("pow off", fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = powEnabled, - onClick = { PoWPreferenceManager.setPowEnabled(true) }, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("pow on", fontFamily = FontFamily.Monospace) - // Show current difficulty - if (powEnabled) { - Surface( - color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - shape = RoundedCornerShape(50) - ) { Box(Modifier.size(8.dp)) } - } - } - } - ) - } - Text( - text = "add proof of work to geohash messages for spam deterrence.", - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, + text = when { + powDifficulty == 0 -> stringResource(R.string.pow_difficulty_desc_0) + powDifficulty <= 8 -> stringResource(R.string.pow_difficulty_desc_8) + powDifficulty <= 12 -> stringResource(R.string.pow_difficulty_desc_12) + powDifficulty <= 16 -> stringResource(R.string.pow_difficulty_desc_16) + powDifficulty <= 20 -> stringResource(R.string.pow_difficulty_desc_20) + powDifficulty <= 24 -> stringResource(R.string.pow_difficulty_desc_24) + else -> stringResource(R.string.pow_difficulty_desc_else) + }, + style = MaterialTheme.typography.labelSmall, color = colorScheme.onSurface.copy(alpha = 0.6f) ) - - // Show difficulty slider when enabled - if (powEnabled) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "difficulty: $powDifficulty bits (~${NostrProofOfWork.estimateMiningTime(powDifficulty)})", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - - Slider( - value = powDifficulty.toFloat(), - onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, - valueRange = 0f..32f, - steps = 33, // 33 discrete values (0-32) - colors = SliderDefaults.colors( - thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), - activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - ) - ) - - // Show difficulty description - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = "difficulty $powDifficulty requires ~${NostrProofOfWork.estimateWork(powDifficulty)} hash attempts", - 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" - }, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - } - } - } } } + } + } + } +} +@Composable +private fun NetworkSection(modifier: Modifier = Modifier, context: Context) { + val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) } + val torStatus by TorManager.statusFlow.collectAsState() - // Network (Tor) section - item { - val ctx = LocalContext.current - val torMode = remember { mutableStateOf(com.bitchat.android.net.TorPreferenceManager.get(ctx)) } - val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState() - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "network", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = colorScheme.onSurface.copy(alpha = 0.8f) - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.OFF, - onClick = { - torMode.value = com.bitchat.android.net.TorMode.OFF - com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value) - }, - label = { Text("tor off", fontFamily = FontFamily.Monospace) } - ) - FilterChip( - selected = torMode.value == com.bitchat.android.net.TorMode.ON, - onClick = { - torMode.value = com.bitchat.android.net.TorMode.ON - com.bitchat.android.net.TorPreferenceManager.set(ctx, torMode.value) - }, - label = { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("tor on", fontFamily = FontFamily.Monospace) - // Status indicator (red/orange/green) moved inside the "tor on" button - val statusColor = when { - torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) - torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) - else -> Color.Red - } - Surface( - color = statusColor, - shape = RoundedCornerShape(50) - ) { Box(Modifier.size(8.dp)) } - } - } - ) - } - Text( - text = "route internet over tor for enhanced privacy.", - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - // Debug status (temporary) - Surface( - modifier = Modifier.fillMaxWidth(), - color = colorScheme.surfaceVariant.copy(alpha = 0.25f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - text = "tor status: " + - (if (torStatus.running) "running" else "stopped") + - ", bootstrap=" + torStatus.bootstrapPercent + "%", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.75f) - ) - val last = torStatus.lastLogLine - if (last.isNotEmpty()) { - Text( - text = "last: " + last.take(160), - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) - } - } - } - } + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilterChip( + selected = torMode.value == com.bitchat.android.net.TorMode.OFF, + onClick = { + torMode.value = com.bitchat.android.net.TorMode.OFF + TorPreferenceManager.set(context, torMode.value) + }, + label = { + Text( + stringResource(R.string.network_tor_off), + fontFamily = FontFamily.Monospace + ) } - - // Emergency warning - item { - Surface( - modifier = Modifier.fillMaxWidth(), - color = Color.Red.copy(alpha = 0.08f), - shape = RoundedCornerShape(12.dp) + ) + FilterChip( + selected = torMode.value == com.bitchat.android.net.TorMode.ON, + onClick = { + torMode.value = com.bitchat.android.net.TorMode.ON + TorPreferenceManager.set(context, torMode.value) + }, + label = { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = "Warning", - tint = Color(0xFFBF1A1A), - modifier = Modifier.size(16.dp) - ) - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = "emergency data deletion", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = Color(0xFFBF1A1A) - ) - - Text( - text = "tip: triple-click the app title to emergency delete all stored data including messages, keys, and settings.", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.7f) - ) - } + Text( + stringResource(R.string.network_tor_on), + fontFamily = FontFamily.Monospace + ) + val statusColor = when { + torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) + torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + else -> Color.Red } - } - } - - // Debug settings button - item { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Debug button styled to match the app aesthetic - TextButton( - onClick = { onShowDebug?.invoke() }, - colors = ButtonDefaults.textButtonColors( - contentColor = colorScheme.onSurface.copy(alpha = 0.6f) - ) - ) { - Text( - text = "debug settings", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.6f) - ) + Surface(color = statusColor, shape = CircleShape) { + Box(Modifier.size(8.dp)) } } } + ) + } + Text( + text = stringResource(R.string.network_tor_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + // Debug status (optional) + if (torMode.value == com.bitchat.android.net.TorMode.ON) { + TorStatusDebug(torStatus) + } + } +} - // Version and footer space - item { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "open source • privacy first • decentralized", - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - color = colorScheme.onSurface.copy(alpha = 0.5f) - ) - - // Add extra space at bottom for gesture area - Spacer(modifier = Modifier.height(16.dp)) - } - } +// Debug status (temporary) +@Composable +fun TorStatusDebug(torStatus: TorManager.TorStatus) { + val statusText = if (torStatus.running) stringResource(R.string.tor_status_running) else stringResource(R.string.tor_status_stopped) + val colorScheme = MaterialTheme.colorScheme + + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(R.string.tor_status_label, statusText, torStatus.bootstrapPercent), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.75f) + ) + val lastLog = torStatus.lastLogLine + if (lastLog.isNotEmpty()) { + Text( + text = stringResource(R.string.tor_status_last_log, lastLog.take(160)), + style = MaterialTheme.typography.labelSmall, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) } } } } +// Emergency warning @Composable -private fun FeatureCard( - icon: ImageVector, - iconColor: Color, - title: String, - description: String, - modifier: Modifier = Modifier -) { +private fun WarningSection(modifier: Modifier = Modifier) { + val colorScheme = MaterialTheme.colorScheme + val errorColor = colorScheme.error + Surface( - modifier = modifier, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), + modifier = modifier.fillMaxWidth(), + color = errorColor.copy(alpha = 0.1f), shape = RoundedCornerShape(12.dp) ) { Row( @@ -491,30 +534,24 @@ private fun FeatureCard( verticalAlignment = Alignment.Top ) { Icon( - imageVector = icon, - contentDescription = title, - tint = iconColor, - modifier = Modifier.size(20.dp) + imageVector = Icons.Filled.Warning, + contentDescription = stringResource(R.string.warning_title), + tint = errorColor, + modifier = Modifier.size(16.dp) ) - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = title, - fontSize = 13.sp, + text = stringResource(R.string.emergency_delete_title), + fontSize = 12.sp, fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + fontWeight = FontWeight.Bold, + color = errorColor ) - Text( - text = description, + text = stringResource(R.string.emergency_delete_desc), fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - lineHeight = 15.sp + color = colorScheme.onSurface.copy(alpha = 0.8f) ) } } @@ -522,25 +559,66 @@ private fun FeatureCard( } @Composable -private fun FeatureItem(text: String) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.Top +private fun Footer( + modifier: Modifier = Modifier, + onShowDebug: (() -> Unit)? = null, ) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Debug button styled to match the app aesthetic + if (onShowDebug != null) { + TextButton( + onClick = onShowDebug, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + ) { + Text( + text = stringResource(R.string.debug_settings), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + } + } + Text( - text = "•", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - - Text( - text = text, + text = stringResource(R.string.footer_text), fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - modifier = Modifier.weight(1f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) ) + Spacer(modifier = Modifier.height(16.dp)) // Extra space for gesture area + } +} + +@Composable +private fun TopBar( + alpha: Float, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + Box( + modifier = modifier + .fillMaxWidth() + .height(64.dp) + .background(colorScheme.background.copy(alpha = alpha)) + ) { + TextButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 16.dp) + ) { + Text( + text = stringResource(R.string.close), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = colorScheme.onBackground + ) + } } } @@ -564,7 +642,7 @@ fun PasswordPromptDialog( onDismissRequest = onDismiss, title = { Text( - text = "Enter Channel Password", + text = stringResource(R.string.channel_password), style = MaterialTheme.typography.titleMedium, color = colorScheme.onSurface ) @@ -572,7 +650,7 @@ fun PasswordPromptDialog( text = { Column { Text( - text = "Channel $channelName is password protected. Enter the password to join.", + text = stringResource(R.string.channel_password_prompt, channelName), style = MaterialTheme.typography.bodyMedium, color = colorScheme.onSurface ) @@ -581,7 +659,7 @@ fun PasswordPromptDialog( OutlinedTextField( value = passwordInput, onValueChange = onPasswordChange, - label = { Text("Password", style = MaterialTheme.typography.bodyMedium) }, + label = { Text(stringResource(R.string.channel_password_hint), style = MaterialTheme.typography.bodyMedium) }, textStyle = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace ), @@ -595,7 +673,7 @@ fun PasswordPromptDialog( confirmButton = { TextButton(onClick = onConfirm) { Text( - text = "Join", + text = stringResource(R.string.join_button), style = MaterialTheme.typography.bodyMedium, color = colorScheme.primary ) @@ -604,7 +682,7 @@ fun PasswordPromptDialog( dismissButton = { TextButton(onClick = onDismiss) { Text( - text = "Cancel", + text = stringResource(R.string.cancel), style = MaterialTheme.typography.bodyMedium, color = colorScheme.onSurface ) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index c33c3c14f..eca1a6498 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -20,11 +20,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalFocusManager +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.input.ImeAction import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.R import com.bitchat.android.core.ui.utils.singleOrTripleClickable /** @@ -379,7 +381,7 @@ private fun PrivateChatHeader( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = "back", + text = stringResource(R.string.back).lowercase(), style = MaterialTheme.typography.bodyMedium, color = colorScheme.primary ) @@ -463,13 +465,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.back).lowercase(), style = MaterialTheme.typography.bodyMedium, color = colorScheme.primary ) @@ -478,7 +480,7 @@ private fun ChannelHeader( // Title - perfectly centered regardless of other elements Text( - text = "channel: $channel", + text = stringResource(R.string.channel_label, channel), style = MaterialTheme.typography.titleMedium, color = Color(0xFFFF9500), // Orange to match input field modifier = Modifier @@ -492,7 +494,7 @@ private fun ChannelHeader( modifier = Modifier.align(Alignment.CenterEnd) ) { Text( - text = "leave", + text = stringResource(R.string.leave_channel).lowercase(), style = MaterialTheme.typography.bodySmall, color = Color.Red ) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt index e1f5df5e9..ddaa6c12a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt @@ -1,12 +1,22 @@ package com.bitchat.android.ui -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -16,6 +26,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import kotlinx.coroutines.launch import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.R /** * User Action Sheet for selecting actions on a specific user (slap, hug, block) @@ -31,140 +42,156 @@ fun ChatUserSheet( viewModel: ChatViewModel, modifier: Modifier = Modifier ) { - val coroutineScope = rememberCoroutineScope() - val clipboardManager = LocalClipboardManager.current - - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - - // iOS system colors (matches LocationChannelsSheet exactly) - val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f - val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green - val standardBlue = Color(0xFF007AFF) // iOS blue - val standardRed = Color(0xFFFF3B30) // iOS red - val standardGrey = if (isDark) Color(0xFF8E8E93) else Color(0xFF6D6D70) // iOS grey - + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + if (isPresented) { ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - modifier = modifier + modifier = modifier, + containerColor = MaterialTheme.colorScheme.background, + dragHandle = null ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Header - Text( - text = "@$targetNickname", - fontSize = 18.sp, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - - Text( - text = if (selectedMessage != null) "choose an action for this message or user" else "choose an action for this user", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - - // Action list (iOS-style plain list) - LazyColumn( - modifier = Modifier.fillMaxWidth() - ) { - // Copy message action (only show if we have a message) - selectedMessage?.let { message -> - item { - UserActionRow( - title = "copy message", - subtitle = "copy this message to clipboard", - titleColor = standardGrey, - onClick = { - // Copy the message content to clipboard - clipboardManager.setText(AnnotatedString(message.content)) - onDismiss() - } - ) - } - } - - // Only show user actions for other users' messages or when no message is selected - if (selectedMessage?.sender != viewModel.nickname.value) { - // Slap action - item { - UserActionRow( - title = "slap $targetNickname", - subtitle = "send a playful slap message", - titleColor = standardBlue, - onClick = { - // Send slap command - viewModel.sendMessage("/slap $targetNickname") - onDismiss() - } - ) + ChatUserSheetContent( + onDismiss = onDismiss, + targetNickname = targetNickname, + selectedMessage = selectedMessage, + viewModel = viewModel + ) + } + } +} + +@Composable +private fun ChatUserSheetContent( + onDismiss: () -> Unit, + targetNickname: String, + selectedMessage: BitchatMessage?, + viewModel: ChatViewModel +) { + val clipboardManager = LocalClipboardManager.current + // Define colors for consistency + val colorScheme = MaterialTheme.colorScheme + val isDark = + colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + val standardBlue = Color(0xFF007AFF) + val standardRed = Color(0xFFFF3B30) + val standardGrey = MaterialTheme.colorScheme.onSurfaceVariant + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Header + Text( + text = stringResource(R.string.user_sheet_header, targetNickname), + fontSize = 18.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp) + ) + + Text( + text = if (selectedMessage != null) stringResource(R.string.user_sheet_subtitle_message_or_user) else stringResource( + R.string.user_sheet_subtitle + ), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + // Action list (iOS-style plain list) + LazyColumn( + modifier = Modifier.fillMaxWidth() + ) { + // Copy message action (only show if we have a message) + selectedMessage?.let { message -> + item { + UserActionRow( + title = stringResource(R.string.user_sheet_action_copy_message), + subtitle = stringResource(R.string.user_sheet_action_copy_message_subtitle), + titleColor = standardGrey, + onClick = { + // Copy the message content to clipboard + clipboardManager.setText(AnnotatedString(message.content)) + onDismiss() } - - // Hug action - item { - UserActionRow( - title = "hug $targetNickname", - subtitle = "send a friendly hug message", - titleColor = standardGreen, - onClick = { - // Send hug command - viewModel.sendMessage("/hug $targetNickname") - onDismiss() - } - ) + ) + } + } + + // Only show user actions for other users' messages or when no message is selected + if (selectedMessage?.sender != viewModel.nickname.value) { + // Slap action + item { + UserActionRow( + title = stringResource(R.string.user_sheet_action_slap, targetNickname), + subtitle = stringResource(R.string.user_sheet_action_slap_subtitle), + titleColor = standardBlue, + onClick = { + // Send slap command + viewModel.sendMessage("/slap $targetNickname") + onDismiss() } - - // Block action - item { - UserActionRow( - title = "block $targetNickname", - subtitle = "block all messages from this user", - titleColor = standardRed, - onClick = { - // Check if we're in a geohash channel - val selectedLocationChannel = viewModel.selectedLocationChannel.value - if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { - // Get user's nostr public key and add to geohash block list - viewModel.blockUserInGeohash(targetNickname) - } else { - // Regular mesh blocking - viewModel.sendMessage("/block $targetNickname") - } - onDismiss() - } - ) + ) + } + + // Hug action + item { + UserActionRow( + title = stringResource(R.string.user_sheet_action_hug, targetNickname), + subtitle = stringResource(R.string.user_sheet_action_hug_subtitle), + titleColor = standardGreen, + onClick = { + // Send hug command + viewModel.sendMessage("/hug $targetNickname") + onDismiss() } - } + ) } - - // Cancel button (iOS-style) - Button( - onClick = onDismiss, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "cancel", - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace + + // Block action + item { + UserActionRow( + title = stringResource(R.string.user_sheet_action_block, targetNickname), + subtitle = stringResource(R.string.user_sheet_action_block_subtitle), + titleColor = standardRed, + onClick = { + // Check if we're in a geohash channel + val selectedLocationChannel = viewModel.selectedLocationChannel.value + if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { + // Get user's nostr public key and add to geohash block list + viewModel.blockUserInGeohash(targetNickname) + } else { + // Regular mesh blocking + viewModel.sendMessage("/block $targetNickname") + } + onDismiss() + } ) } } } + + // Cancel button (iOS-style) + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(id = R.string.cancel), + fontSize = BASE_FONT_SIZE.sp, + fontFamily = FontFamily.Monospace + ) + } } } @@ -195,7 +222,7 @@ private fun UserActionRow( fontWeight = FontWeight.Medium, color = titleColor ) - + Text( text = subtitle, fontSize = 12.sp, diff --git a/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt b/app/src/main/java/com/bitchat/android/ui/DialogComponents.kt new file mode 100644 index 000000000..e69de29bb diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt index 6ebeb21da..7a251a37f 100644 --- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource @@ -23,17 +24,19 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.bitchat.android.R import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.withStyle +import com.bitchat.android.R import com.bitchat.android.ui.theme.BASE_FONT_SIZE import androidx.compose.foundation.isSystemInDarkTheme @@ -165,11 +168,10 @@ fun MessageInput( val colorScheme = MaterialTheme.colorScheme val isFocused = remember { mutableStateOf(false) } val hasText = value.text.isNotBlank() // Check if there's text for send button state - + Row( modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + verticalAlignment = Alignment.CenterVertically ) { // Text input with placeholder Box( @@ -200,7 +202,7 @@ fun MessageInput( // Show placeholder when there's no text if (value.text.isEmpty()) { Text( - text = "type a message...", + text = stringResource(id = R.string.message_hint), style = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace ), @@ -269,6 +271,49 @@ fun MessageInput( } ) } + // Send button with enabled/disabled state + IconButton( + onClick = { if (hasText) onSend() }, // Only execute if there's text + enabled = hasText, // Enable only when there's text + modifier = Modifier.size(32.dp) + ) { + Box( + modifier = Modifier + .size(30.dp) + .background( + color = if (!hasText) { + // Disabled state - muted grey + colorScheme.onSurface.copy(alpha = 0.3f) + } else if (selectedPrivatePeer != null || currentChannel != null) { + // Orange for both private messages and channels when enabled + Color(0xFFFF9500).copy(alpha = 0.75f) + } else if (colorScheme.background == Color.Black) { + Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme + } else { + Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme + }, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.ArrowUpward, + contentDescription = stringResource(R.string.send_message), + modifier = Modifier.size(20.dp), + tint = if (!hasText) { + // Disabled state - muted grey icon + colorScheme.onSurface.copy(alpha = 0.5f) + } else if (selectedPrivatePeer != null || currentChannel != null) { + // Black arrow on orange for both private and channel modes + Color.Black + } else if (colorScheme.background == Color.Black) { + Color.Black // Black arrow on bright green in dark theme + } else { + Color.White // White arrow on dark green in light theme + } + ) + } + } } } } @@ -311,8 +356,7 @@ fun CommandSuggestionItem( .clickable { onClick() } .padding(horizontal = 12.dp, vertical = 3.dp) .background(Color.Gray.copy(alpha = 0.1f)), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + verticalAlignment = Alignment.CenterVertically ) { // Show all aliases together val allCommands = if (suggestion.aliases.isNotEmpty()) { diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index e21dd8f5c..af5b6c923 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -3,9 +3,10 @@ package com.bitchat.android.ui import android.content.Intent import android.net.Uri import android.provider.Settings +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicTextField @@ -18,9 +19,13 @@ 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.drawBehind import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -33,6 +38,7 @@ import com.bitchat.android.geohash.LocationChannelManager import java.util.* import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import com.bitchat.android.R /** * Location Channels Sheet for selecting geohash-based location channels @@ -45,6 +51,31 @@ fun LocationChannelsSheet( onDismiss: () -> Unit, viewModel: ChatViewModel, modifier: Modifier = Modifier +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + if (isPresented) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.statusBarsPadding(), + containerColor = MaterialTheme.colorScheme.background, + dragHandle = null + ) { + LocationChannelsContent( + viewModel = viewModel, + isPresented = isPresented, + onDismiss = onDismiss, + ) + } + } +} + +@Composable +private fun LocationChannelsContent( + viewModel: ChatViewModel, + isPresented: Boolean, + onDismiss: () -> Unit ) { val context = LocalContext.current val locationManager = LocationChannelManager.getInstance(context) @@ -64,16 +95,12 @@ fun LocationChannelsSheet( var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(null) } var isInputFocused by remember { mutableStateOf(false) } - - // Bottom sheet state - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = isInputFocused - ) + val coroutineScope = rememberCoroutineScope() - + // Scroll state for LazyColumn val listState = rememberLazyListState() - + val mapPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> @@ -85,360 +112,394 @@ fun LocationChannelsSheet( } } } - + + val isScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + val topBarAlpha by animateFloatAsState( + targetValue = if (isScrolled) 0.95f else 0f, + label = "topBarAlpha" + ) // iOS system colors (matches iOS exactly) val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green val standardBlue = Color(0xFF007AFF) // iOS blue - - if (isPresented) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - modifier = modifier + val invalidGeohashErrorText = stringResource(R.string.invalid_geohash_error) + + Box(modifier = Modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .then( - if (isInputFocused) { - Modifier.fillMaxHeight().padding(horizontal = 16.dp, vertical = 24.dp) - } else { - Modifier.padding(horizontal = 16.dp, vertical = 12.dp) - } - ), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Header - Text( - text = "#location channels", - fontSize = 18.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface - ) - - 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.", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - - // Location Services Control - Show permission handling if enabled - if (locationServicesEnabled) { - when (permissionState) { - LocationChannelManager.PermissionState.NOT_DETERMINED -> { - Button( - onClick = { locationManager.enableLocationChannels() }, - colors = ButtonDefaults.buttonColors( - containerColor = standardGreen.copy(alpha = 0.12f), - contentColor = standardGreen - ), - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "grant location permission", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace - ) + // Header + item { + Column( + modifier = Modifier + .fillMaxWidth() + .then( + if (isInputFocused) { + Modifier + .fillMaxHeight() + .padding(horizontal = 16.dp, vertical = 6.dp) + } else { + Modifier.padding(horizontal = 16.dp, vertical = 6.dp) } - } - - LocationChannelManager.PermissionState.DENIED, - LocationChannelManager.PermissionState.RESTRICTED -> { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = "location permission denied. enable in settings to use location channels.", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = Color.Red.copy(alpha = 0.8f) - ) - - TextButton( - onClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - context.startActivity(intent) - } + ), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = stringResource(R.string.location_channels_sheet_title), + fontSize = 18.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = stringResource(R.string.location_channels_sheet_description), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + + // Location Services Control - Show permission handling if enabled + item { + Box(modifier = Modifier.padding(horizontal = 24.dp)) { + if (locationServicesEnabled) { + when (permissionState) { + LocationChannelManager.PermissionState.NOT_DETERMINED -> { + Button( + onClick = { locationManager.enableLocationChannels() }, + colors = ButtonDefaults.buttonColors( + containerColor = standardGreen.copy(alpha = 0.12f), + contentColor = standardGreen + ), + modifier = Modifier.fillMaxWidth() ) { Text( - text = "open settings", - fontSize = 11.sp, + text = stringResource(R.string.location_channels_sheet_get_location_button), + fontSize = 12.sp, fontFamily = FontFamily.Monospace ) } } - } - - LocationChannelManager.PermissionState.AUTHORIZED -> { - Text( - text = "✓ location permission granted", - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = standardGreen - ) - } - - null -> { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator(modifier = Modifier.size(12.dp)) + + LocationChannelManager.PermissionState.DENIED, + LocationChannelManager.PermissionState.RESTRICTED -> { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.location_channels_sheet_permission_denied), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + + TextButton( + onClick = { + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts( + "package", + context.packageName, + null + ) + } + context.startActivity(intent) + } + ) { + Text( + text = stringResource(R.string.open_settings), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace + ) + } + } + } + + LocationChannelManager.PermissionState.AUTHORIZED -> { Text( - text = "checking permissions...", + text = stringResource(R.string.location_permission_granted), fontSize = 11.sp, fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + color = standardGreen ) } + + null -> { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(12.dp)) + Text( + text = stringResource(R.string.checking_permissions), + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } } } } - - // Channel list (iOS-style plain list) - LazyColumn( - state = listState, - modifier = Modifier.weight(1f) - ) { - // Mesh option first - item { - ChannelRow( - title = meshTitleWithCount(viewModel), - subtitle = "#bluetooth • ${bluetoothRangeString()}", - isSelected = selectedChannel is ChannelID.Mesh, - titleColor = standardBlue, - titleBold = meshCount(viewModel) > 0, - onClick = { - locationManager.select(ChannelID.Mesh) - onDismiss() - } + } + // Mesh option first + item { + ChannelRow( + title = meshTitleWithCount(viewModel), + subtitle = stringResource( + R.string.location_channels_sheet_bluetooth_subtitle, + bluetoothRangeString() + ), + isSelected = selectedChannel is ChannelID.Mesh, + titleColor = standardBlue, + titleBold = meshCount(viewModel) > 0, + onClick = { + locationManager.select(ChannelID.Mesh) + onDismiss() + } + ) + } + + // Nearby options (only show if location services are enabled) + if (availableChannels.isNotEmpty() && locationServicesEnabled) { + items(availableChannels) { channel -> + val coverage = coverageString(channel.geohash.length) + val nameBase = locationNames[channel.level] + val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } + val subtitlePrefix = "#${channel.geohash} • $coverage" + // CRITICAL FIX: Use reactive participant count from LiveData + val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 + val highlight = participantCount > 0 + + ChannelRow( + title = geohashTitleWithCount(channel, participantCount), + subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), + isSelected = isChannelSelected(channel, selectedChannel), + titleColor = standardGreen, + titleBold = highlight, + onClick = { + // Selecting a suggested nearby channel is not a teleport + locationManager.setTeleported(false) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } + ) + } + } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + Text( + text = stringResource(R.string.location_channels_sheet_finding_channels), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace ) } - - // Nearby options (only show if location services are enabled) - if (availableChannels.isNotEmpty() && locationServicesEnabled) { - items(availableChannels) { channel -> - val coverage = coverageString(channel.geohash.length) - val nameBase = locationNames[channel.level] - val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it } - val subtitlePrefix = "#${channel.geohash} • $coverage" - // CRITICAL FIX: Use reactive participant count from LiveData - val participantCount = geohashParticipantCounts[channel.geohash] ?: 0 - val highlight = participantCount > 0 - - ChannelRow( - title = geohashTitleWithCount(channel, participantCount), - subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""), - isSelected = isChannelSelected(channel, selectedChannel), - titleColor = standardGreen, - titleBold = highlight, - onClick = { - // Selecting a suggested nearby channel is not a teleport - locationManager.setTeleported(false) - locationManager.select(ChannelID.Location(channel)) - onDismiss() + } + } + + // Custom geohash teleport (iOS-style inline form) + item { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + color = Color.Transparent + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "#", + fontSize = BASE_FONT_SIZE.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + + BasicTextField( + value = customGeohash, + onValueChange = { newValue -> + // iOS-style geohash validation (base32 characters only) + val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() + val filtered = newValue + .lowercase() + .replace("#", "") + .filter { it in allowed } + .take(12) + + customGeohash = filtered + customError = null + }, + textStyle = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier + .weight(1f) + .onFocusChanged { focusState -> + isInputFocused = focusState.isFocused + if (focusState.isFocused) { + coroutineScope.launch { + // Scroll to bottom to show input and remove button + + listState.animateScrollToItem( + index = listState.layoutInfo.totalItemsCount - 1 + ) + } + } + }, + singleLine = true, + decorationBox = { innerTextField -> + if (customGeohash.isEmpty()) { + Text( + text = stringResource(R.string.geohash_placeholder), + fontSize = 14.sp, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + } + innerTextField() } ) - } - } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) { - item { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator(modifier = Modifier.size(16.dp)) - Text( - text = "finding nearby channels…", - fontSize = 12.sp, - fontFamily = FontFamily.Monospace + + val normalized = customGeohash.trim().lowercase().replace("#", "") + + // Map picker button + IconButton(onClick = { + val initial = when { + normalized.isNotBlank() -> normalized + selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash + else -> "" + } + val intent = Intent(context, GeohashPickerActivity::class.java).apply { + putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) + } + mapPickerLauncher.launch(intent) + }) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = "Open map", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) ) } - } - } - - // Custom geohash teleport (iOS-style inline form) - item { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - color = Color.Transparent - ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + + val isValid = validateGeohash(normalized) + + // iOS-style teleport button + Button( + onClick = { + if (isValid) { + val level = levelForLength(normalized.length) + val channel = + GeohashChannel(level = level, geohash = normalized) + // Mark this selection as a manual teleport + locationManager.setTeleported(true) + locationManager.select(ChannelID.Location(channel)) + onDismiss() + } else { + customError = invalidGeohashErrorText + } + }, + enabled = isValid, + shape = MaterialTheme.shapes.medium, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { Row( - horizontalArrangement = Arrangement.spacedBy(1.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically ) { Text( - text = "#", + text = stringResource(R.string.teleport_button).lowercase(), fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) - ) - - BasicTextField( - value = customGeohash, - onValueChange = { newValue -> - // iOS-style geohash validation (base32 characters only) - val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() - val filtered = newValue - .lowercase() - .replace("#", "") - .filter { it in allowed } - .take(12) - - customGeohash = filtered - customError = null - }, - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface - ), - modifier = Modifier - .weight(1f) - .onFocusChanged { focusState -> - isInputFocused = focusState.isFocused - if (focusState.isFocused) { - coroutineScope.launch { - sheetState.expand() - // Scroll to bottom to show input and remove button - listState.animateScrollToItem( - index = listState.layoutInfo.totalItemsCount - 1 - ) - } - } - }, - singleLine = true, - decorationBox = { innerTextField -> - if (customGeohash.isEmpty()) { - Text( - text = "geohash", - fontSize = BASE_FONT_SIZE.sp, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - ) - } - innerTextField() - } + fontFamily = FontFamily.Monospace ) - - val normalized = customGeohash.trim().lowercase().replace("#", "") - - // Map picker button - IconButton(onClick = { - val initial = when { - normalized.isNotBlank() -> normalized - selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash - else -> "" - } - val intent = Intent(context, GeohashPickerActivity::class.java).apply { - putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) - } - mapPickerLauncher.launch(intent) - }) { - Icon( - imageVector = Icons.Filled.Map, - contentDescription = "Open map", - tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) - ) - } - - val isValid = validateGeohash(normalized) - - // iOS-style teleport button - Button( - onClick = { - if (isValid) { - val level = levelForLength(normalized.length) - val channel = GeohashChannel(level = level, geohash = normalized) - // Mark this selection as a manual teleport - locationManager.setTeleported(true) - locationManager.select(ChannelID.Location(channel)) - onDismiss() - } else { - customError = "invalid geohash" - } - }, - enabled = isValid, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "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", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - } - - customError?.let { error -> - Text( - text = error, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - color = Color.Red + // iOS has a face.dashed icon, use closest Material equivalent + Icon( + imageVector = Icons.Filled.PinDrop, + contentDescription = stringResource(R.string.teleport_button_description), + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurface ) } } } - } - - // Location services toggle button - item { - Button( - onClick = { - if (locationServicesEnabled) { - locationManager.disableLocationServices() - } else { - locationManager.enableLocationServices() - } - }, - colors = ButtonDefaults.buttonColors( - containerColor = if (locationServicesEnabled) { - Color.Red.copy(alpha = 0.08f) - } else { - standardGreen.copy(alpha = 0.12f) - }, - contentColor = if (locationServicesEnabled) { - Color(0xFFBF1A1A) - } else { - standardGreen - } - ), - modifier = Modifier.fillMaxWidth() - ) { + + customError?.let { error -> Text( - text = if (locationServicesEnabled) { - "disable location services" - } else { - "enable location services" - }, + text = error, fontSize = 12.sp, - fontFamily = FontFamily.Monospace + fontFamily = FontFamily.Monospace, + color = Color.Red ) } } } } + + // Location services toggle button + item { + Button( + onClick = { + if (locationServicesEnabled) { + locationManager.disableLocationServices() + } else { + locationManager.enableLocationServices() + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (locationServicesEnabled) { + Color.Red.copy(alpha = 0.08f) + } else { + standardGreen.copy(alpha = 0.12f) + }, + contentColor = if (locationServicesEnabled) { + Color(0xFFBF1A1A) + } else { + standardGreen + } + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Text( + text = if (locationServicesEnabled) { + stringResource(R.string.disable_location_services) + } else { + stringResource(R.string.enable_location_services) + }, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace + ) + } + } } + + SheetTopBar( + alpha = topBarAlpha, + onDismiss = onDismiss, + modifier = Modifier.align(Alignment.TopCenter) + ) } // Lifecycle management @@ -452,7 +513,10 @@ fun LocationChannelsSheet( if (locationServicesEnabled) { locationManager.beginLiveRefresh() } - + + // Begin periodic refresh while sheet is open + locationManager.beginLiveRefresh() + // Begin multi-channel sampling for counts val geohashes = availableChannels.map { it.geohash } viewModel.beginGeohashSampling(geohashes) @@ -483,6 +547,46 @@ fun LocationChannelsSheet( } } +@Composable +private fun SheetTopBar( + alpha: Float, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + Box( + modifier = modifier + .fillMaxWidth() + .height(64.dp) + .background(colorScheme.background.copy(alpha = alpha)) + .drawBehind { + if (alpha > 0.1f) { + val strokeWidth = 1.dp.toPx() + drawLine( + color = colorScheme.outline.copy(alpha = 0.5f), + start = Offset(0f, size.height - strokeWidth / 2), + end = Offset(size.width, size.height - strokeWidth / 2), + strokeWidth = strokeWidth + ) + } + } + ) { + TextButton( + onClick = onDismiss, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(R.string.close), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = colorScheme.onBackground + ) + } + } +} + @Composable private fun ChannelRow( title: String, @@ -501,7 +605,9 @@ private fun ChannelRow( Color.Transparent }, shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) ) { Row( modifier = Modifier @@ -546,7 +652,7 @@ private fun ChannelRow( if (isSelected) { Text( - text = "✔︎", + text = stringResource(R.string.checkmark_symbol), fontSize = 16.sp, fontFamily = FontFamily.Monospace, color = Color(0xFF32D74B) // iOS green for checkmark @@ -569,10 +675,12 @@ private fun splitTitleAndCount(title: String): Pair { } } +@Composable private fun meshTitleWithCount(viewModel: ChatViewModel): String { + val context = LocalContext.current val meshCount = meshCount(viewModel) - val noun = if (meshCount == 1) "person" else "people" - return "mesh [$meshCount $noun]" + val noun = context.resources.getQuantityString(R.plurals.person_count, meshCount) + return stringResource(R.string.location_channels_sheet_mesh_title, meshCount, noun) } private fun meshCount(viewModel: ChatViewModel): Int { @@ -582,9 +690,17 @@ 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 context = LocalContext.current + val noun = context.resources.getQuantityString(R.plurals.person_count, participantCount) + val levelName = channel.level.displayName.lowercase() + return stringResource( + R.string.location_channels_sheet_geohash_title, + levelName, + participantCount, + noun + ) } private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean { @@ -611,6 +727,7 @@ private fun levelForLength(length: Int): GeohashChannelLevel { } } +@Composable private fun coverageString(precision: Int): String { // Approximate max cell dimension at equator for a given geohash length val maxMeters = when (precision) { @@ -623,12 +740,15 @@ private fun coverageString(precision: Int): String { 8 -> 38.2 9 -> 4.77 10 -> 1.19 - else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble()) + else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow( + 0.25, + (precision - 10).toDouble() + ) } // Use metric system for simplicity (could be made locale-aware) val km = maxMeters / 1000.0 - return "~${formatDistance(km)} km" + return stringResource(R.string.coverage_format, formatDistance(km)) } private fun formatDistance(value: Double): String { @@ -639,9 +759,10 @@ private fun formatDistance(value: Double): String { } } +@Composable private fun bluetoothRangeString(): String { // Approximate Bluetooth LE range for typical mobile devices - return "~10–50 m" + return stringResource(R.string.bluetooth_range_approximate) } private fun formattedNamePrefix(level: GeohashChannelLevel): String { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 783120f28..ae1717485 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -10,7 +10,6 @@ Join Channel Leave Send - Show commands Back People Channels @@ -37,4 +36,174 @@ Continue Retry Skip - + + channel: %s + + Bluetooth + Bluetooth Required + bitchat needs Bluetooth to: + • Discover nearby users\n• Create mesh network connections\n• Send and receive messages\n• Work without internet or servers + Enable Bluetooth + Check Again + Bluetooth Not Supported + This device doesn\'t support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet. + Checking Bluetooth status… + + Enter Channel Password + Channel %1$s is password protected. Enter the password to join. + Cancel + About bitchat + Decentralized mesh messaging over Bluetooth LE\n\n• No servers or internet required\n• End-to-end encrypted private messages\n• Password-protected channels\n• Store-and-forward for offline peers\n\nTriple-click title to emergency clear all data + OK + + Error + Initializing mesh network + Setting up Bluetooth mesh networking… + This should only take a few seconds + Setup Not Complete + Try Again + Open Settings + Location Services + Location Services Required + Privacy + Privacy First + bitchat does NOT track your location or use GPS.\n\nLocation services are required by Android for Bluetooth scanning to work properly. This is an Android system requirement. + bitchat needs location services for: + • Bluetooth device scanning (Android requirement)\n• Discovering nearby users on mesh network\n• Creating connections without internet\n• No GPS tracking or location collection + Open Location Settings + Location Services Unavailable + Location services are not available on this device. This is unusual as location services are standard on Android devices.\n\nbitchat needs location services for Bluetooth scanning to work properly (Android requirement). Without this, the app cannot discover nearby users. + Checking location services… + + Welcome to bitchat + Decentralized mesh messaging over Bluetooth + Your Privacy is Protected + • bitchat doesn\'t track you or collect personal data\n• No servers, no internet required, no data logging\n• Location permission is only used by Android for Bluetooth scanning\n• Your messages stay on your device and peer devices only + To work properly, bitchat needs these permissions: + Grant Permissions + bitchat does NOT use GPS or track location + + mention + + mesh sidegroupchat + offline communication + works without internet using Bluetooth low energy + end-to-end encryption + private messages encrypted with noise protocol + extended range + messages relay through peers, going the distance + favorites + get notified when your favorite people join + mutual favorites + private message each other via nostr when out of mesh range + mentions + use @nickname to notify specific people + + Privacy + no tracking + no servers, accounts, or data collection + ephemeral identity + new peer ID generated regularly + panic mode + triple-tap logo to instantly clear all data + + How to use + • set your nickname by tapping it + • swipe left for sidebar + • tap a peer to start a private chat + • use @nickname to mention someone + • triple-tap chat to clear + + Warning + private message security has not yet been fully audited. do not use for critical situations until this warning disappears. + + Done + close + + + \@%1$s + Choose an action for this user + Choose an action for this message or user + Copy message + Copy this message to clipboard + Slap %1$s + Send a playful slap message + Hug %1$s + Send a friendly hug message + Block %1$s + Block all messages from this user + + + #location channels + 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. + Get location and my geohashes + Location permission denied. Enable in settings to use location channels. + Finding nearby channels… + #bluetooth • %1$s + ~10–50 m + geohash + Teleport + Teleport + Invalid geohash + Remove location access + ✔︎ + mesh [%1$d %2$s] + %1$s [%2$d %3$s] + ✓ location permission granted + checking permissions… + disable location services + enable location services + Teleport + ~%1$s km + + + + person + people + + + + decentralized mesh messaging with end-to-end encryption + offline mesh chat + communicate directly via bluetooth le without internet or servers. messages relay through nearby devices to extend range. + online geohash channels + connect with people in your area using geohash-based channels. extend the mesh using public internet relays. + private messages are encrypted. channel messages are public. + + appearance + system + light + dark + Proof of Work + + + pow off + pow on + add proof of work to geohash messages for spam deterrence. + difficulty: %d bits (~%s) + difficulty %d requires ~%s hash attempts + no proof of work required + very low - minimal spam protection + low - basic spam protection + medium - good spam protection + high - strong spam protection + very high - may cause delays + extreme - significant computation required + + network + tor off + tor on + route internet over tor for enhanced privacy. + running + stopped + tor status: %1$s, bootstrap=%2$d%% + last: %1$s + + emergency data deletion + tip: triple-click the app title to emergency delete all stored data including messages, keys, and settings. + open source • privacy first • decentralized + Join + v%1$s + debug settings + + \ No newline at end of file diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml new file mode 100644 index 000000000..ef4e01dff --- /dev/null +++ b/app/src/main/res/xml/locales_config.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file